Merge pull request #22848 from edx/dcs/course-api
Add API to support courseware MFE
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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')}">
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
0
openedx/core/djangoapps/courseware_api/__init__.py
Normal file
0
openedx/core/djangoapps/courseware_api/__init__.py
Normal file
26
openedx/core/djangoapps/courseware_api/apps.py
Normal file
26
openedx/core/djangoapps/courseware_api/apps.py
Normal 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',
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
120
openedx/core/djangoapps/courseware_api/serializers.py
Normal file
120
openedx/core/djangoapps/courseware_api/serializers.py
Normal 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}
|
||||
108
openedx/core/djangoapps/courseware_api/tests/test_views.py
Normal file
108
openedx/core/djangoapps/courseware_api/tests/test_views.py
Normal 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
|
||||
18
openedx/core/djangoapps/courseware_api/urls.py
Normal file
18
openedx/core/djangoapps/courseware_api/urls.py
Normal 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"),
|
||||
]
|
||||
133
openedx/core/djangoapps/courseware_api/views.py
Normal file
133
openedx/core/djangoapps/courseware_api/views.py
Normal 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)))
|
||||
1
setup.py
1
setup.py
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user