Merge pull request #22848 from edx/dcs/course-api

Add API to support courseware MFE
This commit is contained in:
Dave St.Germain
2020-01-29 09:51:14 -05:00
committed by GitHub
23 changed files with 571 additions and 40 deletions

View File

@@ -1156,9 +1156,7 @@ class CCXCoachTabTestCase(CcxTestCase):
def check_ccx_tab(self, course, user):
"""Helper function for verifying the ccx tab."""
request = RequestFactory().request()
request.user = user
all_tabs = get_course_tab_list(request, course)
all_tabs = get_course_tab_list(user, course)
return any(tab.type == 'ccx_coach' for tab in all_tabs)
@ddt.data(

View File

@@ -65,11 +65,13 @@ def course_detail(request, username, course_key):
`CourseOverview` object representing the requested course
"""
user = get_effective_user(request.user, username)
return get_course_overview_with_access(
overview = get_course_overview_with_access(
user,
get_permission_for_course_about(),
course_key,
)
overview.effective_user = user
return overview
def _filter_by_role(course_queryset, user, roles):

View File

@@ -5,6 +5,7 @@ Course API Views
from django.core.exceptions import ValidationError
from edx_django_utils.monitoring import set_custom_metric
from edx_rest_framework_extensions.paginators import NamespacedPageNumberPagination
from rest_framework.generics import ListAPIView, RetrieveAPIView
from rest_framework.throttling import UserRateThrottle

View File

@@ -24,8 +24,7 @@ class WikiTabTestCase(ModuleStoreTestCase):
def get_wiki_tab(self, user, course):
"""Returns true if the "Wiki" tab is shown."""
request = RequestFactory().request()
request.user = user
all_tabs = get_course_tab_list(request, course)
all_tabs = get_course_tab_list(user, course)
wiki_tabs = [tab for tab in all_tabs if tab.name == 'Wiki']
return wiki_tabs[0] if len(wiki_tabs) == 1 else None

View File

@@ -17,7 +17,8 @@ def course_has_entrance_exam(course):
"""
if not is_entrance_exams_enabled():
return False
if not course.entrance_exam_enabled:
entrance_exam_enabled = getattr(course, 'entrance_exam_enabled', None)
if not entrance_exam_enabled:
return False
if not course.entrance_exam_id:
return False

View File

@@ -307,11 +307,10 @@ class SingleTextbookTab(CourseTab):
raise NotImplementedError('SingleTextbookTab should not be serialized.')
def get_course_tab_list(request, course):
def get_course_tab_list(user, course):
"""
Retrieves the course tab list from xmodule.tabs and manipulates the set as necessary
"""
user = request.user
xmodule_tab_list = CourseTabList.iterate_displayable(course, user=user)
# Now that we've loaded the tabs for this course, perform the Entrance Exam work.

View File

@@ -385,7 +385,6 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, Mi
'description': 'Testing Courseware Tabs'
}
self.user.is_staff = False
request = get_mock_request(self.user)
self.course.entrance_exam_enabled = True
self.course.entrance_exam_id = six.text_type(entrance_exam.location)
milestone = add_milestone(milestone)
@@ -400,7 +399,7 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, Mi
self.relationship_types['FULFILLS'],
milestone
)
course_tab_list = get_course_tab_list(request, self.course)
course_tab_list = get_course_tab_list(self.user, self.course)
self.assertEqual(len(course_tab_list), 1)
self.assertEqual(course_tab_list[0]['tab_id'], 'courseware')
self.assertEqual(course_tab_list[0]['name'], 'Entrance Exam')
@@ -425,8 +424,7 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, Mi
# log in again as student
self.client.logout()
self.login(self.email, self.password)
request = get_mock_request(self.user)
course_tab_list = get_course_tab_list(request, self.course)
course_tab_list = get_course_tab_list(self.user, self.course)
self.assertEqual(len(course_tab_list), 4)
def test_course_tabs_list_for_staff_members(self):
@@ -438,8 +436,7 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, Mi
self.client.logout()
staff_user = StaffFactory(course_key=self.course.id)
self.client.login(username=staff_user.username, password='test')
request = get_mock_request(staff_user)
course_tab_list = get_course_tab_list(request, self.course)
course_tab_list = get_course_tab_list(staff_user, self.course)
self.assertEqual(len(course_tab_list), 4)
@@ -480,8 +477,7 @@ class TextBookCourseViewsTestCase(LoginEnrollmentTestCase, SharedModuleStoreTest
"""
type_to_reverse_name = {'textbook': 'book', 'pdftextbook': 'pdf_book', 'htmltextbook': 'html_book'}
self.addCleanup(set_current_request, None)
request = get_mock_request(self.user)
course_tab_list = get_course_tab_list(request, self.course)
course_tab_list = get_course_tab_list(self.user, self.course)
num_of_textbooks_found = 0
for tab in course_tab_list:
# Verify links of all textbook type tabs.
@@ -706,8 +702,7 @@ class CourseTabListTestCase(TabListTestCase):
user = self.create_mock_user(is_staff=False, is_enrolled=True)
self.addCleanup(set_current_request, None)
request = get_mock_request(user)
course_tab_list = get_course_tab_list(request, self.course)
course_tab_list = get_course_tab_list(user, self.course)
name_list = [x.name for x in course_tab_list]
self.assertIn('Static Tab Free', name_list)
self.assertNotIn('Static Tab Instructors Only', name_list)
@@ -716,8 +711,7 @@ class CourseTabListTestCase(TabListTestCase):
self.client.logout()
staff_user = StaffFactory(course_key=self.course.id)
self.client.login(username=staff_user.username, password='test')
request = get_mock_request(staff_user)
course_tab_list_staff = get_course_tab_list(request, self.course)
course_tab_list_staff = get_course_tab_list(staff_user, self.course)
name_list_staff = [x.name for x in course_tab_list_staff]
self.assertIn('Static Tab Free', name_list_staff)
self.assertIn('Static Tab Instructors Only', name_list_staff)
@@ -775,18 +769,17 @@ class CourseInfoTabTestCase(TabTestCase):
def setUp(self):
self.user = self.create_mock_user()
self.addCleanup(set_current_request, None)
self.request = get_mock_request(self.user)
@override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=False)
def test_default_tab(self):
# Verify that the course info tab is the first tab
tabs = get_course_tab_list(self.request, self.course)
tabs = get_course_tab_list(self.user, self.course)
self.assertEqual(tabs[0].type, 'course_info')
@override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=True)
def test_default_tab_for_new_course_experience(self):
# Verify that the unified course experience hides the course info tab
tabs = get_course_tab_list(self.request, self.course)
tabs = get_course_tab_list(self.user, self.course)
self.assertEqual(tabs[0].type, 'courseware')
# TODO: LEARNER-611 - remove once course_info is removed.

View File

@@ -1248,8 +1248,7 @@ class DiscussionTabTestCase(ModuleStoreTestCase):
def discussion_tab_present(self, user):
""" Returns true if the user has access to the discussion tab. """
request = RequestFactory().request()
request.user = user
all_tabs = get_course_tab_list(request, self.course)
all_tabs = get_course_tab_list(user, self.course)
return any(tab.type == 'discussion' for tab in all_tabs)
def test_tab_access(self):

View File

@@ -1003,9 +1003,7 @@ class EdxNotesViewsTest(ModuleStoreTestCase):
"""
def has_notes_tab(user, course):
"""Returns true if the "Notes" tab is shown."""
request = RequestFactory().request()
request.user = user
tabs = get_course_tab_list(request, course)
tabs = get_course_tab_list(user, course)
return len([tab for tab in tabs if tab.type == 'edxnotes']) == 1
self.assertFalse(has_notes_tab(self.user, self.course))

View File

@@ -107,9 +107,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT
"""
def has_instructor_tab(user, course):
"""Returns true if the "Instructor" tab is shown."""
request = RequestFactory().request()
request.user = user
tabs = get_course_tab_list(request, course)
tabs = get_course_tab_list(user, course)
return len([tab for tab in tabs if tab.name == 'Instructor']) == 1
self.assertTrue(has_instructor_tab(self.instructor, self.course))

View File

@@ -35,7 +35,7 @@ if course is not None:
% if disable_tabs is UNDEFINED or not disable_tabs:
<%
tab_list = get_course_tab_list(request, course)
tab_list = get_course_tab_list(request.user, course)
%>
% if uses_bootstrap:
<nav class="navbar course-tabs pb-0 navbar-expand" aria-label="${_('Course')}">

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.27 on 2020-01-16 17:23
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('course_overviews', '0018_add_start_end_in_CourseOverview'),
]
operations = [
migrations.AddField(
model_name='courseoverviewtab',
name='course_staff_only',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='courseoverviewtab',
name='name',
field=models.TextField(null=True),
),
migrations.AddField(
model_name='courseoverviewtab',
name='type',
field=models.CharField(max_length=50, null=True),
),
migrations.AlterField(
model_name='courseoverviewtab',
name='course_overview',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tab_set', to='course_overviews.CourseOverview'),
),
]

View File

@@ -16,6 +16,7 @@ from django.db.models.fields import BooleanField, DateTimeField, DecimalField, F
from django.db.utils import IntegrityError
from django.template import defaultfilters
from django.utils.encoding import python_2_unicode_compatible
from django.utils.functional import cached_property
from model_utils.models import TimeStampedModel
from opaque_keys.edx.django.models import CourseKeyField, UsageKeyField
from six import text_type # pylint: disable=ungrouped-imports
@@ -31,6 +32,8 @@ from xmodule import block_metadata_utils, course_metadata_utils
from xmodule.course_module import DEFAULT_START_DATE, CourseDescriptor
from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore.django import modulestore
from xmodule.tabs import CourseTab
log = logging.getLogger(__name__)
@@ -54,7 +57,7 @@ class CourseOverview(TimeStampedModel):
app_label = 'course_overviews'
# IMPORTANT: Bump this whenever you modify this model and/or add a migration.
VERSION = 7
VERSION = 8
# Cache entry versioning.
version = IntegerField()
@@ -247,7 +250,12 @@ class CourseOverview(TimeStampedModel):
# Remove and recreate all the course tabs
CourseOverviewTab.objects.filter(course_overview=course_overview).delete()
CourseOverviewTab.objects.bulk_create([
CourseOverviewTab(tab_id=tab.tab_id, course_overview=course_overview)
CourseOverviewTab(
tab_id=tab.tab_id,
type=tab.type,
name=tab.name,
course_staff_only=tab.course_staff_only,
course_overview=course_overview)
for tab in course.tabs
])
# Remove and recreate course images
@@ -629,13 +637,25 @@ class CourseOverview(TimeStampedModel):
"""
Returns True if course has discussion tab and is enabled
"""
tabs = self.tabs.all()
tabs = self.tab_set.all()
# creates circular import; hence explicitly referenced is_discussion_enabled
for tab in tabs:
if tab.tab_id == "discussion" and django_comment_client.utils.is_discussion_enabled(self.id):
return True
return False
@property
def tabs(self):
"""
Returns an iterator of CourseTabs.
"""
for tab_dict in self.tab_set.all().values():
tab = CourseTab.from_json(tab_dict)
if tab is None:
log.warning("Can't instantiate CourseTab from %r", tab_dict)
else:
yield tab
@property
def image_urls(self):
"""
@@ -733,6 +753,49 @@ class CourseOverview(TimeStampedModel):
return urlunparse(('', base_url, path, params, query, fragment))
@cached_property
def _original_course(self):
"""
Returns the course from the modulestore.
"""
log.warning('Falling back on modulestore to get course information for %s', self.id)
return modulestore().get_course(self.id)
@property
def allow_public_wiki_access(self):
"""
TODO: move this to the model.
"""
return self._original_course.allow_public_wiki_access
@property
def textbooks(self):
"""
TODO: move this to the model.
"""
return self._original_course.textbooks
@property
def hide_progress_tab(self):
"""
TODO: move this to the model.
"""
return self._original_course.hide_progress_tab
@property
def edxnotes(self):
"""
TODO: move this to the model.
"""
return self._original_course.edxnotes
@property
def enable_ccx(self):
"""
TODO: move this to the model.
"""
return self._original_course.enable_ccx
def __str__(self):
"""Represent ourselves with the course key."""
return six.text_type(self.id)
@@ -745,7 +808,13 @@ class CourseOverviewTab(models.Model):
.. no_pii:
"""
tab_id = models.CharField(max_length=50)
course_overview = models.ForeignKey(CourseOverview, db_index=True, related_name="tabs", on_delete=models.CASCADE)
course_overview = models.ForeignKey(CourseOverview, db_index=True, related_name="tab_set", on_delete=models.CASCADE)
type = models.CharField(max_length=50, null=True)
name = models.TextField(null=True)
course_staff_only = models.BooleanField(default=False)
def __str__(self):
return self.tab_id
@python_2_unicode_compatible

View File

@@ -202,7 +202,7 @@ class CourseOverviewTestCase(CatalogIntegrationMixin, ModuleStoreTestCase, Cache
# test tabs for both cached miss and cached hit courses
for course_overview in [course_overview_cache_miss, course_overview_cache_hit]:
course_overview_tabs = course_overview.tabs.all()
course_overview_tabs = course_overview.tab_set.all()
course_resp_tabs = {tab.tab_id for tab in course_overview_tabs}
self.assertEqual(self.COURSE_OVERVIEW_TABS, course_resp_tabs)
@@ -1089,7 +1089,7 @@ class CourseOverviewTabTestCase(ModuleStoreTestCase):
"""
course = CourseFactory.create(default_store=modulestore_type)
course_overview = CourseOverview.get_from_id(course.id)
expected_tabs = {tab.tab_id for tab in course_overview.tabs.all()}
expected_tabs = {tab.tab_id for tab in course_overview.tab_set.all()}
with mock.patch(
'openedx.core.djangoapps.content.course_overviews.models.CourseOverviewTab.objects.bulk_create'
@@ -1105,6 +1105,6 @@ class CourseOverviewTabTestCase(ModuleStoreTestCase):
# Asserts that the tabs deletion is properly rolled back to a save point and
# the course overview is not updated.
actual_tabs = {tab.tab_id for tab in course_overview.tabs.all()}
actual_tabs = {tab.tab_id for tab in course_overview.tab_set.all()}
self.assertEqual(actual_tabs, expected_tabs)
self.assertNotEqual(course_overview.display_name, course.display_name)

View File

@@ -0,0 +1,26 @@
"""
Courseware API Application Configuration
Signal handlers are connected here.
"""
from django.apps import AppConfig
from openedx.core.djangoapps.plugins.constants import PluginURLs, ProjectType
class CoursewareAPIConfig(AppConfig):
"""
AppConfig for courseware API app
"""
name = 'openedx.core.djangoapps.courseware_api'
plugin_app = {
PluginURLs.CONFIG: {
ProjectType.LMS: {
PluginURLs.NAMESPACE: 'courseware_api',
PluginURLs.REGEX: 'api/courseware/',
PluginURLs.RELATIVE_PATH: 'urls',
}
},
}

View File

@@ -0,0 +1,32 @@
1. Record Architecture Decisions
--------------------------------
Status
------
Accepted
Context
-------
We would like to keep a historical record on the architectural
decisions we make with this app as it evolves over time.
Decision
--------
We will use Architecture Decision Records, as described by
Michael Nygard in `Documenting Architecture Decisions`_
.. _Documenting Architecture Decisions: http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions
Consequences
------------
See Michael Nygard's article, linked above.
References
----------
* https://resources.sei.cmu.edu/asset_files/Presentation/2017_017_001_497746.pdf
* https://github.com/npryce/adr-tools/tree/master/doc/adr

View File

@@ -0,0 +1,120 @@
"""
Course API Serializers. Representing course catalog data
"""
from django.urls import reverse
from rest_framework import serializers
from lms.djangoapps.courseware.tabs import get_course_tab_list
from openedx.core.lib.api.fields import AbsoluteURLField
from student.models import CourseEnrollment
class _MediaSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""
Nested serializer to represent a media object.
"""
def __init__(self, uri_attribute, *args, **kwargs):
super(_MediaSerializer, self).__init__(*args, **kwargs)
self.uri_attribute = uri_attribute
uri = serializers.SerializerMethodField(source='*')
class Meta:
ref_name = 'courseware_api'
def get_uri(self, course_overview):
"""
Get the representation for the media resource's URI
"""
return getattr(course_overview, self.uri_attribute)
class ImageSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""
Collection of URLs pointing to images of various sizes.
The URLs will be absolute URLs with the host set to the host of the current request. If the values to be
serialized are already absolute URLs, they will be unchanged.
"""
raw = AbsoluteURLField()
small = AbsoluteURLField()
large = AbsoluteURLField()
class Meta:
ref_name = 'courseware_api'
class _CourseApiMediaCollectionSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""
Nested serializer to represent a collection of media objects
"""
course_image = _MediaSerializer(source='*', uri_attribute='course_image_url')
course_video = _MediaSerializer(source='*', uri_attribute='course_video_url')
image = ImageSerializer(source='image_urls')
class Meta:
ref_name = 'courseware_api'
class CourseInfoSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""
Serializer for Course objects providing minimal data about the course.
Compare this with CourseDetailSerializer.
"""
effort = serializers.CharField()
end = serializers.DateTimeField()
enrollment_start = serializers.DateTimeField()
enrollment_end = serializers.DateTimeField()
id = serializers.CharField() # pylint: disable=invalid-name
media = _CourseApiMediaCollectionSerializer(source='*')
name = serializers.CharField(source='display_name_with_default_escaped')
number = serializers.CharField(source='display_number_with_default')
org = serializers.CharField(source='display_org_with_default')
short_description = serializers.CharField()
start = serializers.DateTimeField()
start_display = serializers.CharField()
start_type = serializers.CharField()
pacing = serializers.CharField()
enrollment = serializers.SerializerMethodField()
tabs = serializers.SerializerMethodField()
def __init__(self, *args, **kwargs):
"""
Initialize the serializer.
If `requested_fields` is set, then only return that subset of fields.
"""
super().__init__(*args, **kwargs)
requested_fields = self.context['requested_fields']
if requested_fields is not None:
allowed = set(requested_fields.split(','))
existing = set(self.fields)
for field_name in existing - allowed:
self.fields.pop(field_name)
def get_tabs(self, course_overview):
"""
Return course tab metadata.
"""
tabs = []
for priority, tab in enumerate(get_course_tab_list(course_overview.effective_user, course_overview)):
tabs.append({
'title': tab.title,
'slug': tab.tab_id,
'priority': priority,
'type': tab.type,
'url': tab.link_func(course_overview, reverse),
})
return tabs
def get_enrollment(self, course_overview):
"""
Return the enrollment for the logged in user.
"""
mode, is_active = CourseEnrollment.enrollment_mode_for_user(
course_overview.effective_user,
course_overview.id
)
return {'mode': mode, 'is_active': is_active}

View File

@@ -0,0 +1,108 @@
"""
Tests for courseware API
"""
from datetime import datetime
import unittest
import ddt
from django.conf import settings
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import (
TEST_DATA_SPLIT_MODULESTORE,
SharedModuleStoreTestCase
)
from xmodule.modulestore.tests.factories import ItemFactory, ToyCourseFactory
from student.tests.factories import UserFactory
from student.models import CourseEnrollment
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class BaseCoursewareTests(SharedModuleStoreTestCase):
"""
Base class for courseware API tests
"""
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.store = modulestore()
cls.course = ToyCourseFactory.create(
end=datetime(2028, 1, 1, 1, 1, 1),
enrollment_start=datetime(2020, 1, 1, 1, 1, 1),
enrollment_end=datetime(2028, 1, 1, 1, 1, 1),
emit_signals=True,
modulestore=cls.store,
)
cls.user = UserFactory(
username='student',
email=u'user@example.com',
password='foo',
is_staff=False
)
cls.url = '/api/courseware/course/{}'.format(cls.course.id)
@classmethod
def tearDownClass(cls):
super().tearDownClass()
cls.store.delete_course(cls.course.id, cls.user.id)
def setUp(self):
super().setUp()
self.client.login(username=self.user.username, password='foo')
def test_unauth(self):
self.client.logout()
response = self.client.get(self.url)
assert response.status_code == 401
# pylint: disable=test-inherits-tests
@ddt.ddt
class CourseApiTestViews(BaseCoursewareTests):
"""
Tests for the courseware REST API
"""
@ddt.data((None,), ('audit',), ('verified',))
@ddt.unpack
def test_course_metadata(self, enrollment_mode):
if enrollment_mode:
CourseEnrollment.enroll(self.user, self.course.id, enrollment_mode)
response = self.client.get(self.url)
assert response.status_code == 200
enrollment = response.data['enrollment']
if enrollment_mode:
assert enrollment_mode == enrollment['mode']
assert enrollment['is_active']
assert len(response.data['tabs']) == 4
else:
assert len(response.data['tabs']) == 2
assert not enrollment['is_active']
# pylint: disable=test-inherits-tests
class SequenceApiTestViews(BaseCoursewareTests):
"""
Tests for the sequence REST API
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
chapter = ItemFactory(parent=cls.course, category='chapter')
cls.sequence = ItemFactory(parent=chapter, category='sequential', display_name='sequence')
ItemFactory.create(parent=cls.sequence, category='vertical', display_name="Vertical")
cls.url = '/api/courseware/sequence/{}'.format(cls.sequence.location)
@classmethod
def tearDownClass(cls):
cls.store.delete_item(cls.sequence.location, cls.user.id)
super().tearDownClass()
def test_sequence_metadata(self):
print(self.url)
print(self.course.location)
response = self.client.get(self.url)
assert response.status_code == 200
assert response.data['display_name'] == 'sequence'
assert len(response.data['items']) == 1

View File

@@ -0,0 +1,18 @@
"""
Contains all the URLs
"""
from django.conf import settings
from django.conf.urls import url
from openedx.core.djangoapps.courseware_api import views
urlpatterns = [
url(r'^course/{}'.format(settings.COURSE_KEY_PATTERN),
views.CoursewareInformation.as_view(),
name="courseware-api"),
url(r'^sequence/{}'.format(settings.USAGE_KEY_PATTERN),
views.SequenceMetadata.as_view(),
name="sequence-api"),
]

View File

@@ -0,0 +1,133 @@
"""
Course API Views
"""
import json
from opaque_keys.edx.keys import CourseKey, UsageKey
from rest_framework.generics import RetrieveAPIView
from rest_framework.response import Response
from rest_framework.views import APIView
from lms.djangoapps.course_api.api import course_detail
from lms.djangoapps.courseware.module_render import get_module_by_usage_id
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes
from .serializers import CourseInfoSerializer
@view_auth_classes(is_authenticated=True)
class CoursewareInformation(DeveloperErrorViewMixin, RetrieveAPIView):
"""
**Use Cases**
Request details for a course
**Example Requests**
GET /api/courseware/course/{course_key}
**Response Values**
Body consists of the following fields:
* effort: A textual description of the weekly hours of effort expected
in the course.
* end: Date the course ends, in ISO 8601 notation
* enrollment_end: Date enrollment ends, in ISO 8601 notation
* enrollment_start: Date enrollment begins, in ISO 8601 notation
* id: A unique identifier of the course; a serialized representation
of the opaque key identifying the course.
* 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: Name of the course
* number: Catalog number of the course
* org: Name of the organization that owns the course
* short_description: A textual description of the course
* start: Date the course begins, in ISO 8601 notation
* start_display: Readably formatted start of the course
* start_type: Hint describing how `start_display` is set. One of:
* `"string"`: manually set by the course author
* `"timestamp"`: generated from the `start` timestamp
* `"empty"`: no start date is specified
* pacing: Course pacing. Possible values: instructor, self
* tabs: Course tabs
* enrollment: Enrollment status of authenticated user
* mode: `audit`, `verified`, etc
* is_active: boolean
**Parameters:**
requested_fields (optional) comma separated list:
If set, then only those fields will be returned.
**Returns**
* 200 on success with above fields.
* 400 if an invalid parameter was sent or the username was not provided
for an authenticated request.
* 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.
"""
serializer_class = CourseInfoSerializer
def get_object(self):
"""
Return the requested course object, if the user has appropriate
permissions.
"""
return course_detail(
self.request,
self.request.user.username,
CourseKey.from_string(self.kwargs['course_key_string']),
)
def get_serializer_context(self):
"""
Return extra context to be used by the serializer class.
"""
context = super().get_serializer_context()
context['requested_fields'] = self.request.GET.get('requested_fields', None)
return context
@view_auth_classes(is_authenticated=True)
class SequenceMetadata(DeveloperErrorViewMixin, APIView):
"""
**Use Cases**
Request details for a sequence/subsection
**Example Requests**
GET /api/courseware/sequence/{usage_key}
**Response Values**
Body consists of the following fields:
TODO
**Returns**
* 200 on success with above fields.
* 400 if an invalid parameter was sent.
* 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.
"""
def get(self, request, usage_key_string, *args, **kwargs): # pylint: disable=unused-argument
"""
Return response to a GET request.
"""
usage_key = UsageKey.from_string(usage_key_string)
sequence, _ = get_module_by_usage_id(
self.request,
str(usage_key.course_key),
str(usage_key),
disable_staff_debug_info=True)
return Response(json.loads(sequence.handle_ajax('metadata', None)))

View File

@@ -83,6 +83,7 @@ setup(
"password_policy = openedx.core.djangoapps.password_policy.apps:PasswordPolicyConfig",
"user_authn = openedx.core.djangoapps.user_authn.apps:UserAuthnConfig",
"program_enrollments = lms.djangoapps.program_enrollments.apps:ProgramEnrollmentsConfig",
"courseware_api = openedx.core.djangoapps.courseware_api.apps:CoursewareAPIConfig",
],
"cms.djangoapp": [
"announcements = openedx.features.announcements.apps:AnnouncementsConfig",