feat: Add REST APIs for course advanced settings and course tabs
This commit adds new APIs that allow MFEs to modify a course's advanced settings and to update tab settings to show/hide/move tabs.
This commit is contained in:
committed by
Awais Jibran
parent
c2ba5e7975
commit
8cf751a405
@@ -4,10 +4,12 @@ Contentstore API URLs.
|
|||||||
|
|
||||||
from django.urls import include, re_path
|
from django.urls import include, re_path
|
||||||
|
|
||||||
|
from .v0 import urls as v0_urls
|
||||||
from .v1 import urls as v1_urls
|
from .v1 import urls as v1_urls
|
||||||
|
|
||||||
app_name = 'cms.djangoapps.contentstore'
|
app_name = 'cms.djangoapps.contentstore'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
re_path(r'^v1/', include(v1_urls))
|
re_path(r'^v0/', include(v0_urls)),
|
||||||
|
re_path(r'^v1/', include(v1_urls)),
|
||||||
]
|
]
|
||||||
|
|||||||
0
cms/djangoapps/contentstore/rest_api/v0/__init__.py
Normal file
0
cms/djangoapps/contentstore/rest_api/v0/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Serializers for v0 contentstore API.
|
||||||
|
"""
|
||||||
|
from .advanced_settings import AdvancedSettingsFieldSerializer, CourseAdvancedSettingsSerializer
|
||||||
|
from .tabs import CourseTabSerializer, CourseTabUpdateSerializer, TabIDLocatorSerializer
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
""" Serializers for course advanced settings"""
|
||||||
|
from typing import Type, Dict as DictType
|
||||||
|
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
from rest_framework import serializers
|
||||||
|
from rest_framework.fields import Field as SerializerField
|
||||||
|
from xblock.fields import (
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
Dict,
|
||||||
|
Field as XBlockField,
|
||||||
|
Float,
|
||||||
|
Integer,
|
||||||
|
List,
|
||||||
|
String,
|
||||||
|
)
|
||||||
|
from xmodule.course_module import CourseFields, EmailString
|
||||||
|
from xmodule.fields import Date
|
||||||
|
|
||||||
|
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
|
||||||
|
|
||||||
|
# Maps xblock fields to their corresponding Django Rest Framework serializer field
|
||||||
|
XBLOCK_DRF_FIELD_MAP = [
|
||||||
|
(Boolean, serializers.BooleanField),
|
||||||
|
(String, serializers.CharField),
|
||||||
|
(List, serializers.ListField),
|
||||||
|
(Dict, serializers.DictField),
|
||||||
|
(Date, serializers.DateField),
|
||||||
|
(DateTime, serializers.DateTimeField),
|
||||||
|
(Integer, serializers.IntegerField),
|
||||||
|
(EmailString, serializers.EmailField),
|
||||||
|
(Float, serializers.FloatField),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AdvancedSettingsFieldSerializer(serializers.Serializer): # pylint: disable=abstract-method
|
||||||
|
"""
|
||||||
|
Serializer for a single course setting field.
|
||||||
|
|
||||||
|
This serializer accepts a ``value_field`` parameter that allows you to
|
||||||
|
specify what field to use for a particular instance of this serializer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value_field (SerializerField): The ``value`` field will have this type
|
||||||
|
"""
|
||||||
|
|
||||||
|
deprecated = serializers.BooleanField(read_only=True, help_text=_("Marks a field as deprecated."))
|
||||||
|
display_name = serializers.CharField(read_only=True, help_text=_("User-friendly display name for the field"))
|
||||||
|
help = serializers.CharField(read_only=True, help_text=_("Help text that describes the setting."))
|
||||||
|
|
||||||
|
def __init__(self, value_field: SerializerField, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["value"] = value_field
|
||||||
|
|
||||||
|
|
||||||
|
class CourseAdvancedSettingsSerializer(serializers.Serializer): # pylint: disable=abstract-method
|
||||||
|
"""
|
||||||
|
Serializer for course advanced settings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_drf_field_type_from_xblock_field(xblock_field: XBlockField) -> Type[SerializerField]:
|
||||||
|
"""
|
||||||
|
Return the corresponding DRF Serializer field for an XBlock field.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xblock_field (XBlockField): An XBlock field
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Type[SerializerField]: Return the DRF Serializer type
|
||||||
|
corresponding to the XBlock field.
|
||||||
|
"""
|
||||||
|
for xblock_type, drf_type in XBLOCK_DRF_FIELD_MAP:
|
||||||
|
if isinstance(xblock_field, xblock_type):
|
||||||
|
return drf_type
|
||||||
|
return serializers.JSONField
|
||||||
|
|
||||||
|
def get_fields(self) -> DictType[str, SerializerField]:
|
||||||
|
"""
|
||||||
|
Return the fields for this serializer.
|
||||||
|
|
||||||
|
This method dynamically generates the fields and field types based on
|
||||||
|
fields available on the Course.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DictType[str, SerializerField]: A mapping of field names to field serializers
|
||||||
|
"""
|
||||||
|
fields = {}
|
||||||
|
for field, field_type in vars(CourseFields).items():
|
||||||
|
if isinstance(field_type, XBlockField) and field not in CourseMetadata.FIELDS_EXCLUDE_LIST:
|
||||||
|
fields[field] = AdvancedSettingsFieldSerializer(
|
||||||
|
required=False,
|
||||||
|
label=field_type.name,
|
||||||
|
help_text=field_type.help,
|
||||||
|
value_field=self._get_drf_field_type_from_xblock_field(field_type)(),
|
||||||
|
)
|
||||||
|
return fields
|
||||||
101
cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py
Normal file
101
cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
""" Serializers for course tabs """
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
from rest_framework import serializers
|
||||||
|
from xmodule.tabs import CourseTab
|
||||||
|
|
||||||
|
from openedx.core.lib.api.serializers import UsageKeyField
|
||||||
|
|
||||||
|
|
||||||
|
class CourseTabSerializer(serializers.Serializer): # pylint: disable=abstract-method
|
||||||
|
"""Serializer for course tabs."""
|
||||||
|
|
||||||
|
type = serializers.CharField(read_only=True, help_text=_("Tab type"))
|
||||||
|
title = serializers.CharField(read_only=True, help_text=_("Default name for the tab displayed to users"))
|
||||||
|
is_hideable = serializers.BooleanField(
|
||||||
|
read_only=True,
|
||||||
|
help_text=_("True if it's possible to hide the tab for a course"),
|
||||||
|
)
|
||||||
|
is_hidden = serializers.BooleanField(
|
||||||
|
help_text=_("True the tab is hidden for the course"),
|
||||||
|
)
|
||||||
|
is_movable = serializers.BooleanField(
|
||||||
|
read_only=True,
|
||||||
|
help_text=_("True if it's possible to reorder the tab in the list of tabs"),
|
||||||
|
)
|
||||||
|
course_staff_only = serializers.BooleanField(
|
||||||
|
read_only=True,
|
||||||
|
help_text=_("True if this tab should be displayed only for instructors"),
|
||||||
|
)
|
||||||
|
name = serializers.CharField(
|
||||||
|
read_only=True,
|
||||||
|
help_text=_("Name of the tab displayed to users. Overrides title."),
|
||||||
|
)
|
||||||
|
tab_id = serializers.CharField(
|
||||||
|
read_only=True,
|
||||||
|
help_text=_("Name of the tab displayed to users. Overrides title."),
|
||||||
|
)
|
||||||
|
settings = serializers.DictField(
|
||||||
|
read_only=True,
|
||||||
|
help_text=_("Additional settings specific to the tab"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_representation(self, instance: CourseTab) -> Dict:
|
||||||
|
"""
|
||||||
|
Returns a dict representation of a ``CourseTab`` that contains more data than its ``to_json`` method.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance (CourseTab): A course tab instance to serialize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing key values from course tab.
|
||||||
|
"""
|
||||||
|
tab_data = {
|
||||||
|
"type": instance.type,
|
||||||
|
"title": instance.title,
|
||||||
|
"is_hideable": instance.is_hideable,
|
||||||
|
"is_hidden": instance.is_hidden,
|
||||||
|
"is_movable": instance.is_movable,
|
||||||
|
"course_staff_only": instance.course_staff_only,
|
||||||
|
"name": instance.name,
|
||||||
|
"tab_id": instance.tab_id,
|
||||||
|
}
|
||||||
|
tab_settings = {
|
||||||
|
key: value for key, value in instance.tab_dict.items() if key not in tab_data and key != "link_func"
|
||||||
|
}
|
||||||
|
tab_data["settings"] = tab_settings
|
||||||
|
return tab_data
|
||||||
|
|
||||||
|
|
||||||
|
class TabIDLocatorSerializer(serializers.Serializer): # pylint: disable=abstract-method
|
||||||
|
"""Serializer for tab locations, used to identify a tab in a course."""
|
||||||
|
|
||||||
|
tab_id = serializers.CharField(required=False, help_text=_("ID of tab to update"))
|
||||||
|
tab_locator = UsageKeyField(required=False, help_text=_("Location (Usage Key) of tab to update"))
|
||||||
|
|
||||||
|
def validate(self, attrs: Dict) -> Dict:
|
||||||
|
"""
|
||||||
|
Validates that either the ``tab_id`` or ``tab_locator`` are specified, but not both.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attrs (Dict): A dictionary of attributes to validate
|
||||||
|
"""
|
||||||
|
has_tab_id = "tab_id" in attrs
|
||||||
|
has_tab_locator = "tab_locator" in attrs
|
||||||
|
if has_tab_locator ^ has_tab_id:
|
||||||
|
return super().validate(attrs)
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"non_field_errors": _("Need to supply either a valid tab_id or a tab_location.")}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CourseTabUpdateSerializer(serializers.Serializer): # pylint: disable=abstract-method
|
||||||
|
"""
|
||||||
|
Serializer to update course tabs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
is_hidden = serializers.BooleanField(
|
||||||
|
required=True,
|
||||||
|
help_text=_("True to hide the tab, and False to show it."),
|
||||||
|
)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
Tests for the course advanced settings API.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import ddt
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from milestones.tests.utils import MilestonesTestCaseMixin
|
||||||
|
|
||||||
|
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
|
||||||
|
|
||||||
|
|
||||||
|
@ddt.ddt
|
||||||
|
class CourseAdvanceSettingViewTest(CourseTestCase, MilestonesTestCaseMixin):
|
||||||
|
"""
|
||||||
|
Tests for AdvanceSettings API View.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.url = reverse(
|
||||||
|
"cms.djangoapps.contentstore:v0:course_advanced_settings",
|
||||||
|
kwargs={"course_id": self.course.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_and_check_developer_response(self, response):
|
||||||
|
"""
|
||||||
|
Make basic asserting about the presence of an error response, and return the developer response.
|
||||||
|
"""
|
||||||
|
content = json.loads(response.content.decode("utf-8"))
|
||||||
|
assert "developer_message" in content
|
||||||
|
return content["developer_message"]
|
||||||
|
|
||||||
|
def test_permissions_unauthenticated(self):
|
||||||
|
"""
|
||||||
|
Test that an error is returned in the absence of auth credentials.
|
||||||
|
"""
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
error = self.get_and_check_developer_response(response)
|
||||||
|
assert error == "Authentication credentials were not provided."
|
||||||
|
|
||||||
|
def test_permissions_unauthorized(self):
|
||||||
|
"""
|
||||||
|
Test that an error is returned if the user is unauthorised.
|
||||||
|
"""
|
||||||
|
client, _ = self.create_non_staff_authed_user_client()
|
||||||
|
response = client.get(self.url)
|
||||||
|
error = self.get_and_check_developer_response(response)
|
||||||
|
assert error == "You do not have permission to perform this action."
|
||||||
|
|
||||||
|
@ddt.data(
|
||||||
|
("ENABLE_EDXNOTES", "edxnotes"),
|
||||||
|
("ENABLE_OTHER_COURSE_SETTINGS", "other_course_settings"),
|
||||||
|
)
|
||||||
|
@ddt.unpack
|
||||||
|
def test_conditionally_excluded_fields_present(self, setting, excluded_field):
|
||||||
|
"""
|
||||||
|
Test that the response contain all fields irrespective of exclusions.
|
||||||
|
"""
|
||||||
|
for setting_value in (True, False):
|
||||||
|
with override_settings(FEATURES={setting: setting_value}):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
content = json.loads(response.content.decode("utf-8"))
|
||||||
|
assert excluded_field in content
|
||||||
|
|
||||||
|
@ddt.data(
|
||||||
|
("", ("display_name", "due"), ()),
|
||||||
|
("display_name", ("display_name",), ("due", "edxnotes")),
|
||||||
|
("display_name,edxnotes", ("display_name", "edxnotes"), ("due", "tags")),
|
||||||
|
)
|
||||||
|
@ddt.unpack
|
||||||
|
def test_filtered_fields(self, filtered_fields, present_fields, absent_fields):
|
||||||
|
"""
|
||||||
|
Test that the response contain all fields that are in the filter, and none that are filtered out.
|
||||||
|
"""
|
||||||
|
response = self.client.get(self.url, {"filter_fields": filtered_fields})
|
||||||
|
content = json.loads(response.content.decode("utf-8"))
|
||||||
|
for field in present_fields:
|
||||||
|
assert field in content.keys()
|
||||||
|
for field in absent_fields:
|
||||||
|
assert field not in content.keys()
|
||||||
241
cms/djangoapps/contentstore/rest_api/v0/tests/test_tabs.py
Normal file
241
cms/djangoapps/contentstore/rest_api/v0/tests/test_tabs.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"""
|
||||||
|
Tests for the course tab API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import ddt
|
||||||
|
from django.urls import reverse
|
||||||
|
from xmodule.modulestore.tests.factories import ItemFactory
|
||||||
|
from xmodule.tabs import CourseTabList
|
||||||
|
|
||||||
|
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
|
||||||
|
|
||||||
|
|
||||||
|
@ddt.ddt
|
||||||
|
class TabsAPITests(CourseTestCase):
|
||||||
|
"""
|
||||||
|
Test cases for Tabs (a.k.a Pages) page
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""
|
||||||
|
Common setup for tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# call super class to setup course, etc.
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
# Set the URLs for tests
|
||||||
|
self.url = reverse(
|
||||||
|
"cms.djangoapps.contentstore:v0:course_tab_list",
|
||||||
|
kwargs={"course_id": self.course.id},
|
||||||
|
)
|
||||||
|
self.url_settings = reverse(
|
||||||
|
"cms.djangoapps.contentstore:v0:course_tab_settings",
|
||||||
|
kwargs={"course_id": self.course.id},
|
||||||
|
)
|
||||||
|
self.url_reorder = reverse(
|
||||||
|
"cms.djangoapps.contentstore:v0:course_tab_reorder",
|
||||||
|
kwargs={"course_id": self.course.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
# add a static tab to the course, for code coverage
|
||||||
|
self.test_tab = ItemFactory.create(
|
||||||
|
parent_location=self.course.location,
|
||||||
|
category="static_tab",
|
||||||
|
display_name="Static_1",
|
||||||
|
)
|
||||||
|
self.reload_course()
|
||||||
|
|
||||||
|
def check_invalid_response(self, resp):
|
||||||
|
"""
|
||||||
|
Check the response is an error and return the developer message.
|
||||||
|
"""
|
||||||
|
assert resp.status_code, 400
|
||||||
|
resp_content = json.loads(resp.content)
|
||||||
|
assert "developer_message" in resp_content
|
||||||
|
return resp_content["developer_message"]
|
||||||
|
|
||||||
|
def make_reorder_tabs_request(self, data):
|
||||||
|
"""
|
||||||
|
Helper method to make a request for reordering tabs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data (List): Data to send in the post request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response received from API.
|
||||||
|
"""
|
||||||
|
return self.client.post(
|
||||||
|
self.url_reorder,
|
||||||
|
data=data,
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_update_tab_request(self, tab_id_locator, data):
|
||||||
|
"""
|
||||||
|
Helper method to make a request for hiding/showing tabs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tab_id_locator (Dict): A dict containing the tab_id/tab_locator to update
|
||||||
|
data (Dict): Data to send in the post request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response received from API.
|
||||||
|
"""
|
||||||
|
return self.client.post(
|
||||||
|
f"{self.url_settings}?{urlencode(tab_id_locator)}",
|
||||||
|
data=data,
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reorder_tabs(self):
|
||||||
|
"""
|
||||||
|
Test re-ordering of tabs
|
||||||
|
"""
|
||||||
|
|
||||||
|
# get the original tab ids
|
||||||
|
orig_tab_ids = [tab.tab_id for tab in self.course.tabs]
|
||||||
|
tab_ids = list(orig_tab_ids)
|
||||||
|
num_orig_tabs = len(orig_tab_ids)
|
||||||
|
|
||||||
|
# make sure we have enough tabs to play around with
|
||||||
|
assert num_orig_tabs >= 5
|
||||||
|
|
||||||
|
# reorder the last two tabs
|
||||||
|
tab_ids[num_orig_tabs - 1], tab_ids[num_orig_tabs - 2] = tab_ids[num_orig_tabs - 2], tab_ids[num_orig_tabs - 1]
|
||||||
|
|
||||||
|
# remove the middle tab
|
||||||
|
# (the code needs to handle the case where tabs requested for re-ordering is a subset of the tabs in the course)
|
||||||
|
removed_tab = tab_ids.pop(num_orig_tabs // 2)
|
||||||
|
assert len(tab_ids) == num_orig_tabs - 1
|
||||||
|
|
||||||
|
# post the request
|
||||||
|
resp = self.make_reorder_tabs_request([{"tab_id": tab_id} for tab_id in tab_ids])
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
# reload the course and verify the new tab order
|
||||||
|
self.reload_course()
|
||||||
|
new_tab_ids = [tab.tab_id for tab in self.course.tabs]
|
||||||
|
assert new_tab_ids == tab_ids + [removed_tab]
|
||||||
|
assert new_tab_ids != orig_tab_ids
|
||||||
|
|
||||||
|
def test_reorder_tabs_invalid_list(self):
|
||||||
|
"""
|
||||||
|
Test re-ordering of tabs with invalid tab list.
|
||||||
|
|
||||||
|
Not all tabs can be rearranged. Here we are trying to swap the first
|
||||||
|
two tabs, which is disallowed since the first tab is the "Course" tab
|
||||||
|
which is immovable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
orig_tab_ids = [tab.tab_id for tab in self.course.tabs]
|
||||||
|
tab_ids = list(orig_tab_ids)
|
||||||
|
|
||||||
|
# reorder the first two tabs
|
||||||
|
tab_ids[0], tab_ids[1] = tab_ids[1], tab_ids[0]
|
||||||
|
|
||||||
|
# post the request
|
||||||
|
resp = self.make_reorder_tabs_request([{"tab_id": tab_id} for tab_id in tab_ids])
|
||||||
|
assert resp.status_code == 400
|
||||||
|
error = self.check_invalid_response(resp)
|
||||||
|
assert "error" in error
|
||||||
|
|
||||||
|
def test_reorder_tabs_invalid_tab_ids(self):
|
||||||
|
"""
|
||||||
|
Test re-ordering of tabs with invalid tab.
|
||||||
|
"""
|
||||||
|
|
||||||
|
invalid_tab_ids = ["courseware", "info", "invalid_tab_id"]
|
||||||
|
|
||||||
|
# post the request
|
||||||
|
resp = self.make_reorder_tabs_request([{"tab_id": tab_id} for tab_id in invalid_tab_ids])
|
||||||
|
self.check_invalid_response(resp)
|
||||||
|
|
||||||
|
def test_reorder_tabs_invalid_tab_locators(self):
|
||||||
|
"""
|
||||||
|
Test re-ordering of tabs with invalid tab.
|
||||||
|
"""
|
||||||
|
|
||||||
|
invalid_tab_locators = ["invalid_tab_locator", "block-v1:test+test+test+type@static_tab+block@invalid"]
|
||||||
|
|
||||||
|
# post the request
|
||||||
|
resp = self.make_reorder_tabs_request([{"tab_locator": tab_id} for tab_id in invalid_tab_locators])
|
||||||
|
self.check_invalid_response(resp)
|
||||||
|
|
||||||
|
def check_toggle_tab_visibility(self, tab_type, new_is_hidden_setting):
|
||||||
|
"""
|
||||||
|
Helper method to check changes in tab visibility.
|
||||||
|
"""
|
||||||
|
old_tab = CourseTabList.get_tab_by_type(self.course.tabs, tab_type)
|
||||||
|
# visibility should be different from new setting
|
||||||
|
assert old_tab.is_hidden != new_is_hidden_setting
|
||||||
|
|
||||||
|
resp = self.make_update_tab_request({"tab_id": old_tab.tab_id}, {"is_hidden": new_is_hidden_setting})
|
||||||
|
assert resp.status_code == 204, resp.content
|
||||||
|
# reload the course and verify the new visibility setting
|
||||||
|
self.reload_course()
|
||||||
|
new_tab = CourseTabList.get_tab_by_type(self.course.tabs, tab_type)
|
||||||
|
assert new_tab.is_hidden == new_is_hidden_setting
|
||||||
|
|
||||||
|
def test_toggle_tab_visibility(self):
|
||||||
|
"""
|
||||||
|
Test that toggling the visibility via the API works.
|
||||||
|
"""
|
||||||
|
self.check_toggle_tab_visibility("wiki", True)
|
||||||
|
self.check_toggle_tab_visibility("wiki", False)
|
||||||
|
|
||||||
|
def test_toggle_tab_visibility_fail(self):
|
||||||
|
"""
|
||||||
|
Test that it isn't possible to toggle visibility of unsupported tabs
|
||||||
|
"""
|
||||||
|
|
||||||
|
tab_type = "courseware"
|
||||||
|
|
||||||
|
tab = CourseTabList.get_tab_by_type(self.course.tabs, tab_type)
|
||||||
|
|
||||||
|
assert not tab.is_hideable
|
||||||
|
assert not tab.is_hidden
|
||||||
|
|
||||||
|
resp = self.make_update_tab_request({"tab_id": tab.tab_id}, {"is_hidden": True})
|
||||||
|
|
||||||
|
assert resp.status_code == 400
|
||||||
|
error = self.check_invalid_response(resp)
|
||||||
|
assert error["error"] == f"Tab of type {tab_type} can not be hidden"
|
||||||
|
|
||||||
|
# Make sure the visibility wasn't affected
|
||||||
|
self.reload_course()
|
||||||
|
updated_tab = CourseTabList.get_tab_by_type(self.course.tabs, tab_type)
|
||||||
|
assert not updated_tab.is_hidden
|
||||||
|
|
||||||
|
@ddt.data(
|
||||||
|
{"tab_id": "wiki", "tab_locator": "block-v1:test+test+test+type@static_tab+block@invalid"},
|
||||||
|
{"tab_id": "invalid_tab_id"},
|
||||||
|
{"tab_locator": "invalid_tab_locator"},
|
||||||
|
{"tab_locator": "block-v1:test+test+test+type@static_tab+block@invalid"},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
def test_toggle_invalid_tab_visibility(self, invalid_tab_locator):
|
||||||
|
"""
|
||||||
|
Test toggling visibility of an invalid tab
|
||||||
|
"""
|
||||||
|
|
||||||
|
# post the request
|
||||||
|
resp = self.make_update_tab_request(invalid_tab_locator, {"is_hidden": False})
|
||||||
|
self.check_invalid_response(resp)
|
||||||
|
|
||||||
|
@ddt.data(
|
||||||
|
dict(is_hidden=None),
|
||||||
|
dict(is_hidden="abc"),
|
||||||
|
dict(),
|
||||||
|
)
|
||||||
|
def test_toggle_tab_invalid_visibility(self, invalid_visibility):
|
||||||
|
"""
|
||||||
|
Test toggling visibility of an invalid tab
|
||||||
|
"""
|
||||||
|
|
||||||
|
# post the request
|
||||||
|
resp = self.make_update_tab_request({"tab_id": "wiki"}, invalid_visibility)
|
||||||
|
self.check_invalid_response(resp)
|
||||||
31
cms/djangoapps/contentstore/rest_api/v0/urls.py
Normal file
31
cms/djangoapps/contentstore/rest_api/v0/urls.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
""" Contenstore API v0 URLs. """
|
||||||
|
|
||||||
|
from django.urls import re_path
|
||||||
|
|
||||||
|
from openedx.core.constants import COURSE_ID_PATTERN
|
||||||
|
from .views import AdvancedCourseSettingsView, CourseTabSettingsView, CourseTabListView, CourseTabReorderView
|
||||||
|
|
||||||
|
app_name = "v0"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
re_path(
|
||||||
|
fr"^advanced_settings/{COURSE_ID_PATTERN}$",
|
||||||
|
AdvancedCourseSettingsView.as_view(),
|
||||||
|
name="course_advanced_settings",
|
||||||
|
),
|
||||||
|
re_path(
|
||||||
|
fr"^tabs/{COURSE_ID_PATTERN}$",
|
||||||
|
CourseTabListView.as_view(),
|
||||||
|
name="course_tab_list",
|
||||||
|
),
|
||||||
|
re_path(
|
||||||
|
fr"^tabs/{COURSE_ID_PATTERN}/settings$",
|
||||||
|
CourseTabSettingsView.as_view(),
|
||||||
|
name="course_tab_settings",
|
||||||
|
),
|
||||||
|
re_path(
|
||||||
|
fr"^tabs/{COURSE_ID_PATTERN}/reorder$",
|
||||||
|
CourseTabReorderView.as_view(),
|
||||||
|
name="course_tab_reorder",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Views for v0 contentstore API.
|
||||||
|
"""
|
||||||
|
from .advanced_settings import AdvancedCourseSettingsView
|
||||||
|
from .tabs import CourseTabSettingsView, CourseTabListView, CourseTabReorderView
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
""" API Views for course advanced settings """
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
import edx_api_doc_tools as apidocs
|
||||||
|
from opaque_keys.edx.keys import CourseKey
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from xmodule.modulestore.django import modulestore
|
||||||
|
|
||||||
|
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
|
||||||
|
from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
|
||||||
|
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes
|
||||||
|
from ..serializers import CourseAdvancedSettingsSerializer
|
||||||
|
from ....views.course import update_course_advanced_settings
|
||||||
|
|
||||||
|
|
||||||
|
@view_auth_classes(is_authenticated=True)
|
||||||
|
class AdvancedCourseSettingsView(DeveloperErrorViewMixin, APIView):
|
||||||
|
"""
|
||||||
|
View for getting and setting the advanced settings for a course.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class FilterQuery(forms.Form):
|
||||||
|
"""
|
||||||
|
Form for validating query marameters passed to advanced course settings view
|
||||||
|
to filter the data it returns.
|
||||||
|
"""
|
||||||
|
filter_fields = forms.CharField(strip=True, empty_value=None, required=False)
|
||||||
|
|
||||||
|
def clean_filter_fields(self):
|
||||||
|
if 'filter_fields' in self.data and self.cleaned_data['filter_fields']:
|
||||||
|
return set(self.cleaned_data['filter_fields'].split(','))
|
||||||
|
return None
|
||||||
|
|
||||||
|
@apidocs.schema(
|
||||||
|
parameters=[
|
||||||
|
apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"),
|
||||||
|
apidocs.string_parameter(
|
||||||
|
"filter_fields",
|
||||||
|
apidocs.ParameterLocation.PATH,
|
||||||
|
description="Comma separated list of fields to filter",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
responses={
|
||||||
|
200: CourseAdvancedSettingsSerializer,
|
||||||
|
401: "The requester is not authenticated.",
|
||||||
|
403: "The requester cannot access the specified course.",
|
||||||
|
404: "The requested course does not exist.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
@verify_course_exists()
|
||||||
|
def get(self, request: Request, course_id: str):
|
||||||
|
"""
|
||||||
|
Get an object containing all the advanced settings in a course.
|
||||||
|
|
||||||
|
**Example Request**
|
||||||
|
|
||||||
|
GET /api/contentstore/v0/advanced_settings/{course_id}
|
||||||
|
|
||||||
|
**Response Values**
|
||||||
|
|
||||||
|
If the request is successful, an HTTP 200 "OK" response is returned.
|
||||||
|
|
||||||
|
The HTTP 200 response contains a single dict that contains keys that
|
||||||
|
are the course's advanced settings. For each setting a dictionary is
|
||||||
|
returned that contains the following fields:
|
||||||
|
|
||||||
|
* **deprecated**: This is true for settings that are deprecated.
|
||||||
|
* **display_name**: This is a friendly name for the setting.
|
||||||
|
* **help**: Contains help text that explains how the setting works.
|
||||||
|
* **value**: Contains the value of the setting. The exact format
|
||||||
|
depends on the setting and is often explained in the ``help`` field
|
||||||
|
above.
|
||||||
|
|
||||||
|
There may be other fields returned by the response.
|
||||||
|
|
||||||
|
**Example Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"display_name": {
|
||||||
|
"value": "Demonstration Course",
|
||||||
|
"display_name": "Course Display Name",
|
||||||
|
"help": "Enter the name of the course as it should appear in the course list.",
|
||||||
|
"deprecated": false,
|
||||||
|
"hide_on_enabled_publisher": false
|
||||||
|
},
|
||||||
|
"course_edit_method": {
|
||||||
|
"value": "Studio",
|
||||||
|
"display_name": "Course Editor",
|
||||||
|
"help": "Enter the method by which this course is edited (\"XML\" or \"Studio\").",
|
||||||
|
"deprecated": true,
|
||||||
|
"hide_on_enabled_publisher": false
|
||||||
|
},
|
||||||
|
"days_early_for_beta": {
|
||||||
|
"value": null,
|
||||||
|
"display_name": "Days Early for Beta Users",
|
||||||
|
"help": "Enter the number of days before the start date that beta users can access the course.",
|
||||||
|
"deprecated": false,
|
||||||
|
"hide_on_enabled_publisher": false
|
||||||
|
},
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
filter_query_data = AdvancedCourseSettingsView.FilterQuery(request.query_params)
|
||||||
|
if not filter_query_data.is_valid():
|
||||||
|
raise ValidationError(filter_query_data.errors)
|
||||||
|
course_key = CourseKey.from_string(course_id)
|
||||||
|
if not has_studio_read_access(request.user, course_key):
|
||||||
|
self.permission_denied(request)
|
||||||
|
course_module = modulestore().get_course(course_key)
|
||||||
|
return Response(CourseMetadata.fetch_all(
|
||||||
|
course_module,
|
||||||
|
filter_fields=filter_query_data.cleaned_data['filter_fields'],
|
||||||
|
))
|
||||||
|
|
||||||
|
@apidocs.schema(
|
||||||
|
body=CourseAdvancedSettingsSerializer,
|
||||||
|
parameters=[apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID")],
|
||||||
|
responses={
|
||||||
|
200: CourseAdvancedSettingsSerializer,
|
||||||
|
401: "The requester is not authenticated.",
|
||||||
|
403: "The requester cannot access the specified course.",
|
||||||
|
404: "The requested course does not exist.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
@verify_course_exists()
|
||||||
|
def patch(self, request: Request, course_id: str):
|
||||||
|
"""
|
||||||
|
Update a course's advanced settings.
|
||||||
|
|
||||||
|
**Example Request**
|
||||||
|
|
||||||
|
PATCH /api/contentstore/v0/advanced_settings/{course_id} {
|
||||||
|
"{setting_name}": {
|
||||||
|
"value": {setting_value}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
**PATCH Parameters**
|
||||||
|
|
||||||
|
The data sent for a patch request should follow a similar format as
|
||||||
|
is returned by a ``GET`` request. Multiple settings can be updated in
|
||||||
|
a single request, however only the ``value`` field can be updated
|
||||||
|
any other fields, if included, will be ignored.
|
||||||
|
|
||||||
|
Here is an example request that updates the ``advanced_modules``
|
||||||
|
available for the course, and enables the calculator tool:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"advanced_modules": {
|
||||||
|
"value": [
|
||||||
|
"poll",
|
||||||
|
"survey",
|
||||||
|
"drag-and-drop-v2",
|
||||||
|
"lti_consumer"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"show_calculator": {
|
||||||
|
"value": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response Values**
|
||||||
|
|
||||||
|
If the request is successful, an HTTP 200 "OK" response is returned,
|
||||||
|
along with all the course's settings similar to a ``GET`` request.
|
||||||
|
"""
|
||||||
|
course_key = CourseKey.from_string(course_id)
|
||||||
|
if not has_studio_write_access(request.user, course_key):
|
||||||
|
self.permission_denied(request)
|
||||||
|
course_module = modulestore().get_course(course_key)
|
||||||
|
updated_data = update_course_advanced_settings(course_module, request.data, request.user)
|
||||||
|
return Response(updated_data)
|
||||||
239
cms/djangoapps/contentstore/rest_api/v0/views/tabs.py
Normal file
239
cms/djangoapps/contentstore/rest_api/v0/views/tabs.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
""" API Views for course tabs """
|
||||||
|
|
||||||
|
import edx_api_doc_tools as apidocs
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
from opaque_keys.edx.keys import CourseKey
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from xmodule.modulestore.django import modulestore
|
||||||
|
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||||
|
|
||||||
|
from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
|
||||||
|
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes
|
||||||
|
from ..serializers import CourseTabSerializer, CourseTabUpdateSerializer, TabIDLocatorSerializer
|
||||||
|
from ....views.tabs import edit_tab_handler, get_course_tabs, reorder_tabs_handler
|
||||||
|
|
||||||
|
|
||||||
|
@view_auth_classes(is_authenticated=True)
|
||||||
|
class CourseTabListView(DeveloperErrorViewMixin, APIView):
|
||||||
|
"""
|
||||||
|
API view to list course tabs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@apidocs.schema(
|
||||||
|
parameters=[apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID")],
|
||||||
|
responses={
|
||||||
|
200: CourseTabSerializer,
|
||||||
|
401: "The requester is not authenticated.",
|
||||||
|
403: "The requester cannot access the specified course.",
|
||||||
|
404: "The requested course does not exist.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
@verify_course_exists()
|
||||||
|
def get(self, request: Request, course_id: str) -> Response:
|
||||||
|
"""
|
||||||
|
Get a list of all the tabs in a course including hidden tabs.
|
||||||
|
|
||||||
|
**Example Request**
|
||||||
|
|
||||||
|
GET /api/contentstore/v0/tabs/{course_id}
|
||||||
|
|
||||||
|
**Response Values**
|
||||||
|
|
||||||
|
If the request is successful, an HTTP 200 "OK" response is returned.
|
||||||
|
|
||||||
|
The HTTP 200 response contains a list of objects that contain info
|
||||||
|
about each tab.
|
||||||
|
|
||||||
|
**Example Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"course_staff_only": false,
|
||||||
|
"is_hidden": false,
|
||||||
|
"is_hideable": false,
|
||||||
|
"is_movable": false,
|
||||||
|
"name": "Home",
|
||||||
|
"settings": {},
|
||||||
|
"tab_id": "info",
|
||||||
|
"title": "Home",
|
||||||
|
"type": "course_info"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"course_staff_only": false,
|
||||||
|
"is_hidden": false,
|
||||||
|
"is_hideable": false,
|
||||||
|
"is_movable": false,
|
||||||
|
"name": "Course",
|
||||||
|
"settings": {},
|
||||||
|
"tab_id": "courseware",
|
||||||
|
"title": "Course",
|
||||||
|
"type": "courseware"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
course_key = CourseKey.from_string(course_id)
|
||||||
|
if not has_studio_read_access(request.user, course_key):
|
||||||
|
self.permission_denied(request)
|
||||||
|
|
||||||
|
course_module = modulestore().get_course(course_key)
|
||||||
|
tabs_to_render = get_course_tabs(course_module, request.user)
|
||||||
|
return Response(CourseTabSerializer(tabs_to_render, many=True).data)
|
||||||
|
|
||||||
|
|
||||||
|
@view_auth_classes(is_authenticated=True)
|
||||||
|
class CourseTabSettingsView(DeveloperErrorViewMixin, APIView):
|
||||||
|
"""
|
||||||
|
API view for course tabs settings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def handle_exception(self, exc):
|
||||||
|
"""Handle NotImplementedError and return a proper response for it."""
|
||||||
|
if isinstance(exc, NotImplementedError):
|
||||||
|
return self._make_error_response(400, str(exc))
|
||||||
|
if isinstance(exc, ItemNotFoundError):
|
||||||
|
return self._make_error_response(400, str(exc))
|
||||||
|
return super().handle_exception(exc)
|
||||||
|
|
||||||
|
@apidocs.schema(
|
||||||
|
body=CourseTabUpdateSerializer(help_text=_("Change the visibility of tabs in a course.")),
|
||||||
|
parameters=[
|
||||||
|
apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"),
|
||||||
|
apidocs.string_parameter("tab_id", apidocs.ParameterLocation.QUERY, description="Tab ID"),
|
||||||
|
apidocs.string_parameter("tab_location", apidocs.ParameterLocation.QUERY, description="Tab usage key"),
|
||||||
|
],
|
||||||
|
responses={
|
||||||
|
204: "In case of success, a 204 is returned with no content.",
|
||||||
|
401: "The requester is not authenticated.",
|
||||||
|
403: "The requester cannot access the specified course.",
|
||||||
|
404: "The requested course does not exist.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
@verify_course_exists()
|
||||||
|
def post(self, request: Request, course_id: str) -> Response:
|
||||||
|
"""
|
||||||
|
Change visibility of tabs in a course.
|
||||||
|
|
||||||
|
**Example Requests**
|
||||||
|
|
||||||
|
You can provide either a tab_id or a tab_location.
|
||||||
|
|
||||||
|
Hide a course tab using ``tab_id``:
|
||||||
|
|
||||||
|
POST /api/contentstore/v0/tabs/{course_id}/settings/?tab_id={tab_id} {
|
||||||
|
"is_hidden": true
|
||||||
|
}
|
||||||
|
|
||||||
|
Hide a course tab using ``tab_location``
|
||||||
|
|
||||||
|
POST /api/contentstore/v0/tabs/{course_id}/settings/?tab_location={tab_location} {
|
||||||
|
"is_hidden": true
|
||||||
|
}
|
||||||
|
|
||||||
|
**Response Values**
|
||||||
|
|
||||||
|
If the request is successful, an HTTP 204 response is returned
|
||||||
|
without any content.
|
||||||
|
"""
|
||||||
|
course_key = CourseKey.from_string(course_id)
|
||||||
|
if not has_studio_write_access(request.user, course_key):
|
||||||
|
self.permission_denied(request)
|
||||||
|
|
||||||
|
tab_id_locator = TabIDLocatorSerializer(data=request.query_params)
|
||||||
|
tab_id_locator.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
course_module = modulestore().get_course(course_key)
|
||||||
|
serializer = CourseTabUpdateSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
edit_tab_handler(
|
||||||
|
course_module,
|
||||||
|
{
|
||||||
|
"tab_id_locator": tab_id_locator.data,
|
||||||
|
**serializer.data,
|
||||||
|
},
|
||||||
|
request.user,
|
||||||
|
)
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@view_auth_classes(is_authenticated=True)
|
||||||
|
class CourseTabReorderView(DeveloperErrorViewMixin, APIView):
|
||||||
|
"""
|
||||||
|
API view for reordering course tabs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def handle_exception(self, exc: Exception) -> Response:
|
||||||
|
"""
|
||||||
|
Handle NotImplementedError and return a proper response for it.
|
||||||
|
"""
|
||||||
|
if isinstance(exc, NotImplementedError):
|
||||||
|
return self._make_error_response(400, str(exc))
|
||||||
|
return super().handle_exception(exc)
|
||||||
|
|
||||||
|
@apidocs.schema(
|
||||||
|
body=[TabIDLocatorSerializer],
|
||||||
|
parameters=[
|
||||||
|
apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"),
|
||||||
|
],
|
||||||
|
responses={
|
||||||
|
204: "In case of success, a 204 is returned with no content.",
|
||||||
|
401: "The requester is not authenticated.",
|
||||||
|
403: "The requester cannot access the specified course.",
|
||||||
|
404: "The requested course does not exist.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
@verify_course_exists()
|
||||||
|
def post(self, request: Request, course_id: str) -> Response:
|
||||||
|
"""
|
||||||
|
Reorder tabs in a course.
|
||||||
|
|
||||||
|
**Example Requests**
|
||||||
|
|
||||||
|
Move course tabs:
|
||||||
|
|
||||||
|
POST /api/contentstore/v0/tabs/{course_id}/reorder [
|
||||||
|
{
|
||||||
|
"tab_id": "info"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tab_id": "courseware"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tab_locator": "block-v1:TstX+DemoX+Demo+type@static_tab+block@d26fcb0e93824fbfa5c9e5f100e2511a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tab_id": "wiki"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tab_id": "discussion"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tab_id": "progress"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
**Response Values**
|
||||||
|
|
||||||
|
If the request is successful, an HTTP 204 response is returned
|
||||||
|
without any content.
|
||||||
|
"""
|
||||||
|
course_key = CourseKey.from_string(course_id)
|
||||||
|
if not has_studio_write_access(request.user, course_key):
|
||||||
|
self.permission_denied(request)
|
||||||
|
|
||||||
|
course_module = modulestore().get_course(course_key)
|
||||||
|
tab_id_locators = TabIDLocatorSerializer(data=request.data, many=True)
|
||||||
|
tab_id_locators.is_valid(raise_exception=True)
|
||||||
|
reorder_tabs_handler(
|
||||||
|
course_module,
|
||||||
|
{"tabs": tab_id_locators.validated_data},
|
||||||
|
request.user,
|
||||||
|
)
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -11,12 +11,14 @@ import random
|
|||||||
import re
|
import re
|
||||||
import string
|
import string
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
import django.utils
|
import django.utils
|
||||||
from ccx_keys.locator import CCXLocator
|
from ccx_keys.locator import CCXLocator
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.exceptions import PermissionDenied, ValidationError
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
|
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -31,6 +33,7 @@ from opaque_keys.edx.keys import CourseKey
|
|||||||
from opaque_keys.edx.locator import BlockUsageLocator
|
from opaque_keys.edx.locator import BlockUsageLocator
|
||||||
from organizations.api import add_organization_course, ensure_organization
|
from organizations.api import add_organization_course, ensure_organization
|
||||||
from organizations.exceptions import InvalidOrganizationException
|
from organizations.exceptions import InvalidOrganizationException
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
from cms.djangoapps.course_creators.views import add_user_with_status_unrequested, get_course_creator_status
|
from cms.djangoapps.course_creators.views import add_user_with_status_unrequested, get_course_creator_status
|
||||||
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
|
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
|
||||||
@@ -74,7 +77,7 @@ from openedx.features.content_type_gating.partitions import CONTENT_TYPE_GATING_
|
|||||||
from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML
|
from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML
|
||||||
from openedx.features.course_experience.waffle import waffle as course_experience_waffle
|
from openedx.features.course_experience.waffle import waffle as course_experience_waffle
|
||||||
from xmodule.contentstore.content import StaticContent
|
from xmodule.contentstore.content import StaticContent
|
||||||
from xmodule.course_module import DEFAULT_START_DATE, CourseFields
|
from xmodule.course_module import CourseBlock, DEFAULT_START_DATE, CourseFields
|
||||||
from xmodule.error_module import ErrorBlock
|
from xmodule.error_module import ErrorBlock
|
||||||
from xmodule.modulestore import EdxJSONEncoder
|
from xmodule.modulestore import EdxJSONEncoder
|
||||||
from xmodule.modulestore.django import modulestore
|
from xmodule.modulestore.django import modulestore
|
||||||
@@ -115,7 +118,7 @@ from .library import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
__all__ = ['course_info_handler', 'course_handler', 'course_listing',
|
__all__ = ['course_info_handler', 'course_handler', 'course_listing',
|
||||||
'course_info_update_handler', 'course_search_index_handler',
|
'course_info_update_handler', 'course_search_index_handler',
|
||||||
@@ -126,7 +129,8 @@ __all__ = ['course_info_handler', 'course_handler', 'course_listing',
|
|||||||
'advanced_settings_handler',
|
'advanced_settings_handler',
|
||||||
'course_notifications_handler',
|
'course_notifications_handler',
|
||||||
'textbooks_list_handler', 'textbooks_detail_handler',
|
'textbooks_list_handler', 'textbooks_detail_handler',
|
||||||
'group_configurations_list_handler', 'group_configurations_detail_handler']
|
'group_configurations_list_handler', 'group_configurations_detail_handler',
|
||||||
|
'get_course_and_check_access']
|
||||||
|
|
||||||
WAFFLE_NAMESPACE = 'studio_home'
|
WAFFLE_NAMESPACE = 'studio_home'
|
||||||
|
|
||||||
@@ -141,7 +145,7 @@ class AccessListFallback(Exception):
|
|||||||
|
|
||||||
def get_course_and_check_access(course_key, user, depth=0):
|
def get_course_and_check_access(course_key, user, depth=0):
|
||||||
"""
|
"""
|
||||||
Internal method used to calculate and return the locator and course module
|
Function used to calculate and return the locator and course module
|
||||||
for the view functions in this file.
|
for the view functions in this file.
|
||||||
"""
|
"""
|
||||||
if not has_studio_read_access(user, course_key):
|
if not has_studio_read_access(user, course_key):
|
||||||
@@ -1315,7 +1319,7 @@ def grading_handler(request, course_key_string, grader_index=None):
|
|||||||
return JsonResponse()
|
return JsonResponse()
|
||||||
|
|
||||||
|
|
||||||
def _refresh_course_tabs(request, course_module):
|
def _refresh_course_tabs(user: User, course_module: CourseBlock):
|
||||||
"""
|
"""
|
||||||
Automatically adds/removes tabs if changes to the course require them.
|
Automatically adds/removes tabs if changes to the course require them.
|
||||||
|
|
||||||
@@ -1341,7 +1345,7 @@ def _refresh_course_tabs(request, course_module):
|
|||||||
# Additionally update any tabs that are provided by non-dynamic course views
|
# Additionally update any tabs that are provided by non-dynamic course views
|
||||||
for tab_type in CourseTabPluginManager.get_tab_types():
|
for tab_type in CourseTabPluginManager.get_tab_types():
|
||||||
if not tab_type.is_dynamic and tab_type.is_default:
|
if not tab_type.is_dynamic and tab_type.is_default:
|
||||||
tab_enabled = tab_type.is_enabled(course_module, user=request.user)
|
tab_enabled = tab_type.is_enabled(course_module, user=user)
|
||||||
update_tab(course_tabs, tab_type, tab_enabled)
|
update_tab(course_tabs, tab_type, tab_enabled)
|
||||||
|
|
||||||
CourseTabList.validate_tabs(course_tabs)
|
CourseTabList.validate_tabs(course_tabs)
|
||||||
@@ -1395,41 +1399,61 @@ def advanced_settings_handler(request, course_key_string):
|
|||||||
return JsonResponse(CourseMetadata.fetch(course_module))
|
return JsonResponse(CourseMetadata.fetch(course_module))
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
# validate data formats and update the course module.
|
return JsonResponse(
|
||||||
# Note: don't update mongo yet, but wait until after any tabs are changed
|
update_course_advanced_settings(course_module, request.json, request.user)
|
||||||
is_valid, errors, updated_data = CourseMetadata.validate_and_update_from_json(
|
|
||||||
course_module,
|
|
||||||
request.json,
|
|
||||||
user=request.user,
|
|
||||||
)
|
)
|
||||||
|
except ValidationError as err:
|
||||||
|
return JsonResponseBadRequest(err.detail)
|
||||||
|
|
||||||
if is_valid:
|
|
||||||
try:
|
|
||||||
# update the course tabs if required by any setting changes
|
|
||||||
_refresh_course_tabs(request, course_module)
|
|
||||||
except InvalidTabsException as err:
|
|
||||||
log.exception(str(err))
|
|
||||||
response_message = [
|
|
||||||
{
|
|
||||||
'message': _('An error occurred while trying to save your tabs'),
|
|
||||||
'model': {'display_name': _('Tabs Exception')}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
return JsonResponseBadRequest(response_message)
|
|
||||||
|
|
||||||
# now update mongo
|
def update_course_advanced_settings(course_module: CourseBlock, data: Dict, user: User) -> Dict:
|
||||||
modulestore().update_item(course_module, request.user.id)
|
"""
|
||||||
|
Helper function to update course advanced settings from API data.
|
||||||
|
|
||||||
return JsonResponse(updated_data)
|
This function takes JSON data returned from the API and applies changes from
|
||||||
else:
|
it to the course advanced settings.
|
||||||
return JsonResponseBadRequest(errors)
|
|
||||||
|
|
||||||
# Handle all errors that validation doesn't catch
|
Args:
|
||||||
except (TypeError, ValueError, InvalidTabsException) as err:
|
course_module (CourseBlock): The course run object on which to operate.
|
||||||
return HttpResponseBadRequest(
|
data (Dict): JSON data as found the ``request.data``
|
||||||
django.utils.html.escape(str(err)),
|
user (User): The user performing the operation
|
||||||
content_type="text/plain"
|
|
||||||
)
|
Returns:
|
||||||
|
Dict: The updated data after applying changes based on supplied data.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# validate data formats and update the course module.
|
||||||
|
# Note: don't update mongo yet, but wait until after any tabs are changed
|
||||||
|
is_valid, errors, updated_data = CourseMetadata.validate_and_update_from_json(
|
||||||
|
course_module,
|
||||||
|
data,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_valid:
|
||||||
|
raise ValidationError(errors)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# update the course tabs if required by any setting changes
|
||||||
|
_refresh_course_tabs(user, course_module)
|
||||||
|
except InvalidTabsException as err:
|
||||||
|
log.exception(str(err))
|
||||||
|
response_message = [
|
||||||
|
{
|
||||||
|
'message': _('An error occurred while trying to save your tabs'),
|
||||||
|
'model': {'display_name': _('Tabs Exception')}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
raise ValidationError(response_message) from err
|
||||||
|
|
||||||
|
# now update mongo
|
||||||
|
modulestore().update_item(course_module, user.id)
|
||||||
|
|
||||||
|
return updated_data
|
||||||
|
|
||||||
|
# Handle all errors that validation doesn't catch
|
||||||
|
except (TypeError, ValueError, InvalidTabsException) as err:
|
||||||
|
raise ValidationError(django.utils.html.escape(str(err))) from err
|
||||||
|
|
||||||
|
|
||||||
class TextbookValidationError(Exception):
|
class TextbookValidationError(Exception):
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
"""
|
"""
|
||||||
Views related to course tabs
|
Views related to course tabs
|
||||||
"""
|
"""
|
||||||
|
from typing import Dict, Iterable, List, Optional
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.http import HttpResponseNotFound
|
from django.http import HttpResponseNotFound
|
||||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
from common.djangoapps.edxmako.shortcuts import render_to_response
|
from xmodule.course_module import CourseBlock
|
||||||
from common.djangoapps.student.auth import has_course_author_access
|
|
||||||
from common.djangoapps.util.json_request import JsonResponse, expect_json
|
|
||||||
from xmodule.modulestore import ModuleStoreEnum
|
from xmodule.modulestore import ModuleStoreEnum
|
||||||
from xmodule.modulestore.django import modulestore
|
from xmodule.modulestore.django import modulestore
|
||||||
from xmodule.tabs import CourseTab, CourseTabList, InvalidTabsException, StaticTab
|
from xmodule.tabs import CourseTab, CourseTabList, InvalidTabsException, StaticTab
|
||||||
|
|
||||||
|
from common.djangoapps.edxmako.shortcuts import render_to_response
|
||||||
|
from common.djangoapps.student.auth import has_course_author_access
|
||||||
|
from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json
|
||||||
from ..utils import get_lms_link_for_item
|
from ..utils import get_lms_link_for_item
|
||||||
__all__ = ['tabs_handler']
|
|
||||||
|
__all__ = ["tabs_handler", "update_tabs_handler"]
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
@expect_json
|
@expect_json
|
||||||
@@ -43,40 +50,74 @@ def tabs_handler(request, course_key_string):
|
|||||||
|
|
||||||
course_item = modulestore().get_course(course_key)
|
course_item = modulestore().get_course(course_key)
|
||||||
|
|
||||||
if 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
|
if "application/json" in request.META.get("HTTP_ACCEPT", "application/json"):
|
||||||
if request.method == 'GET': # lint-amnesty, pylint: disable=no-else-raise
|
if request.method == "GET": # lint-amnesty, pylint: disable=no-else-raise
|
||||||
raise NotImplementedError('coming soon')
|
raise NotImplementedError("coming soon")
|
||||||
else:
|
else:
|
||||||
if 'tabs' in request.json:
|
try:
|
||||||
return reorder_tabs_handler(course_item, request)
|
update_tabs_handler(course_item, request.json, request.user)
|
||||||
elif 'tab_id_locator' in request.json:
|
except ValidationError as err:
|
||||||
return edit_tab_handler(course_item, request)
|
return JsonResponseBadRequest(err.detail)
|
||||||
else:
|
return JsonResponse()
|
||||||
raise NotImplementedError('Creating or changing tab content is not supported.')
|
|
||||||
|
|
||||||
elif request.method == 'GET': # assume html
|
elif request.method == "GET": # assume html
|
||||||
# get all tabs from the tabs list: static tabs (a.k.a. user-created tabs) and built-in tabs
|
# get all tabs from the tabs list: static tabs (a.k.a. user-created tabs) and built-in tabs
|
||||||
# present in the same order they are displayed in LMS
|
# present in the same order they are displayed in LMS
|
||||||
|
|
||||||
tabs_to_render = []
|
tabs_to_render = list(get_course_tabs(course_item, request.user))
|
||||||
for tab in CourseTabList.iterate_displayable(course_item, user=request.user, inline_collections=False,
|
|
||||||
include_hidden=True):
|
|
||||||
if isinstance(tab, StaticTab):
|
|
||||||
# static tab needs its locator information to render itself as an xmodule
|
|
||||||
static_tab_loc = course_key.make_usage_key('static_tab', tab.url_slug)
|
|
||||||
tab.locator = static_tab_loc
|
|
||||||
tabs_to_render.append(tab)
|
|
||||||
|
|
||||||
return render_to_response('edit-tabs.html', {
|
return render_to_response(
|
||||||
'context_course': course_item,
|
"edit-tabs.html",
|
||||||
'tabs_to_render': tabs_to_render,
|
{
|
||||||
'lms_link': get_lms_link_for_item(course_item.location),
|
"context_course": course_item,
|
||||||
})
|
"tabs_to_render": tabs_to_render,
|
||||||
|
"lms_link": get_lms_link_for_item(course_item.location),
|
||||||
|
},
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return HttpResponseNotFound()
|
return HttpResponseNotFound()
|
||||||
|
|
||||||
|
|
||||||
def reorder_tabs_handler(course_item, request):
|
def get_course_tabs(course_item: CourseBlock, user: User) -> Iterable[CourseTab]:
|
||||||
|
"""
|
||||||
|
Yields all the course tabs in a course including hidden tabs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
course_item (CourseBlock): The course object from which to get the tabs
|
||||||
|
user (User): The user fetching the course tabs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Iterable[CourseTab]: An iterable containing course tab objects from the
|
||||||
|
course
|
||||||
|
"""
|
||||||
|
|
||||||
|
for tab in CourseTabList.iterate_displayable(course_item, user=user, inline_collections=False, include_hidden=True):
|
||||||
|
if isinstance(tab, StaticTab):
|
||||||
|
# static tab needs its locator information to render itself as an xmodule
|
||||||
|
static_tab_loc = course_item.id.make_usage_key("static_tab", tab.url_slug)
|
||||||
|
tab.locator = static_tab_loc
|
||||||
|
yield tab
|
||||||
|
|
||||||
|
|
||||||
|
def update_tabs_handler(course_item: CourseBlock, tabs_data: Dict, user: User) -> None:
|
||||||
|
"""
|
||||||
|
Helper to handle updates to course tabs based on API data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
course_item (CourseBlock): Course module whose tabs need to be updated
|
||||||
|
tabs_data (Dict): JSON formatted data for updating or reordering tabs.
|
||||||
|
user (User): The user performing the operation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if "tabs" in tabs_data:
|
||||||
|
reorder_tabs_handler(course_item, tabs_data, user)
|
||||||
|
elif "tab_id_locator" in tabs_data:
|
||||||
|
edit_tab_handler(course_item, tabs_data, user)
|
||||||
|
else:
|
||||||
|
raise NotImplementedError("Creating or changing tab content is not supported.")
|
||||||
|
|
||||||
|
|
||||||
|
def reorder_tabs_handler(course_item, tabs_data, user):
|
||||||
"""
|
"""
|
||||||
Helper function for handling reorder of tabs request
|
Helper function for handling reorder of tabs request
|
||||||
"""
|
"""
|
||||||
@@ -85,7 +126,7 @@ def reorder_tabs_handler(course_item, request):
|
|||||||
# The locators are used to identify static tabs since they are xmodules.
|
# The locators are used to identify static tabs since they are xmodules.
|
||||||
# Although all tabs have tab_ids, newly created static tabs do not know
|
# Although all tabs have tab_ids, newly created static tabs do not know
|
||||||
# their tab_ids since the xmodule editor uses only locators to identify new objects.
|
# their tab_ids since the xmodule editor uses only locators to identify new objects.
|
||||||
requested_tab_id_locators = request.json['tabs']
|
requested_tab_id_locators = tabs_data["tabs"]
|
||||||
|
|
||||||
# original tab list in original order
|
# original tab list in original order
|
||||||
old_tab_list = course_item.tabs
|
old_tab_list = course_item.tabs
|
||||||
@@ -95,9 +136,7 @@ def reorder_tabs_handler(course_item, request):
|
|||||||
for tab_id_locator in requested_tab_id_locators:
|
for tab_id_locator in requested_tab_id_locators:
|
||||||
tab = get_tab_by_tab_id_locator(old_tab_list, tab_id_locator)
|
tab = get_tab_by_tab_id_locator(old_tab_list, tab_id_locator)
|
||||||
if tab is None:
|
if tab is None:
|
||||||
return JsonResponse(
|
raise ValidationError({"error": f"Tab with id_locator '{tab_id_locator}' does not exist."})
|
||||||
{"error": f"Tab with id_locator '{tab_id_locator}' does not exist."}, status=400
|
|
||||||
)
|
|
||||||
new_tab_list.append(tab)
|
new_tab_list.append(tab)
|
||||||
|
|
||||||
# the old_tab_list may contain additional tabs that were not rendered in the UI because of
|
# the old_tab_list may contain additional tabs that were not rendered in the UI because of
|
||||||
@@ -109,54 +148,50 @@ def reorder_tabs_handler(course_item, request):
|
|||||||
try:
|
try:
|
||||||
CourseTabList.validate_tabs(new_tab_list)
|
CourseTabList.validate_tabs(new_tab_list)
|
||||||
except InvalidTabsException as exception:
|
except InvalidTabsException as exception:
|
||||||
return JsonResponse(
|
raise ValidationError({"error": f"New list of tabs is not valid: {str(exception)}."}) from exception
|
||||||
{"error": f"New list of tabs is not valid: {str(exception)}."}, status=400
|
|
||||||
)
|
|
||||||
|
|
||||||
# persist the new order of the tabs
|
# persist the new order of the tabs
|
||||||
course_item.tabs = new_tab_list
|
course_item.tabs = new_tab_list
|
||||||
modulestore().update_item(course_item, request.user.id)
|
modulestore().update_item(course_item, user.id)
|
||||||
|
|
||||||
return JsonResponse()
|
|
||||||
|
|
||||||
|
|
||||||
def edit_tab_handler(course_item, request):
|
def edit_tab_handler(course_item: CourseBlock, tabs_data: Dict, user: User):
|
||||||
"""
|
"""
|
||||||
Helper function for handling requests to edit settings of a single tab
|
Helper function for handling requests to edit settings of a single tab
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Tabs are identified by tab_id or locator
|
# Tabs are identified by tab_id or locator
|
||||||
tab_id_locator = request.json['tab_id_locator']
|
tab_id_locator = tabs_data["tab_id_locator"]
|
||||||
|
|
||||||
# Find the given tab in the course
|
# Find the given tab in the course
|
||||||
tab = get_tab_by_tab_id_locator(course_item.tabs, tab_id_locator)
|
tab = get_tab_by_tab_id_locator(course_item.tabs, tab_id_locator)
|
||||||
if tab is None:
|
if tab is None:
|
||||||
return JsonResponse(
|
raise ValidationError({"error": f"Tab with id_locator '{tab_id_locator}' does not exist."})
|
||||||
{"error": f"Tab with id_locator '{tab_id_locator}' does not exist."}, status=400
|
|
||||||
)
|
|
||||||
|
|
||||||
if 'is_hidden' in request.json:
|
if "is_hidden" in tabs_data:
|
||||||
# set the is_hidden attribute on the requested tab
|
if tab.is_hideable:
|
||||||
tab.is_hidden = request.json['is_hidden']
|
# set the is_hidden attribute on the requested tab
|
||||||
modulestore().update_item(course_item, request.user.id)
|
tab.is_hidden = tabs_data["is_hidden"]
|
||||||
|
modulestore().update_item(course_item, user.id)
|
||||||
|
else:
|
||||||
|
raise ValidationError({"error": f"Tab of type {tab.type} can not be hidden"})
|
||||||
else:
|
else:
|
||||||
raise NotImplementedError(f'Unsupported request to edit tab: {request.json}')
|
raise NotImplementedError(f"Unsupported request to edit tab: {tabs_data}")
|
||||||
|
|
||||||
return JsonResponse()
|
|
||||||
|
|
||||||
|
|
||||||
def get_tab_by_tab_id_locator(tab_list, tab_id_locator):
|
def get_tab_by_tab_id_locator(tab_list: List[CourseTab], tab_id_locator: Dict[str, str]) -> Optional[CourseTab]:
|
||||||
"""
|
"""
|
||||||
Look for a tab with the specified tab_id or locator. Returns the first matching tab.
|
Look for a tab with the specified tab_id or locator. Returns the first matching tab.
|
||||||
"""
|
"""
|
||||||
if 'tab_id' in tab_id_locator:
|
tab = None
|
||||||
tab = CourseTabList.get_tab_by_id(tab_list, tab_id_locator['tab_id'])
|
if "tab_id" in tab_id_locator:
|
||||||
elif 'tab_locator' in tab_id_locator:
|
tab = CourseTabList.get_tab_by_id(tab_list, tab_id_locator["tab_id"])
|
||||||
tab = get_tab_by_locator(tab_list, tab_id_locator['tab_locator'])
|
elif "tab_locator" in tab_id_locator:
|
||||||
|
tab = get_tab_by_locator(tab_list, tab_id_locator["tab_locator"])
|
||||||
return tab
|
return tab
|
||||||
|
|
||||||
|
|
||||||
def get_tab_by_locator(tab_list, usage_key_string):
|
def get_tab_by_locator(tab_list: List[CourseTab], usage_key_string: str) -> Optional[CourseTab]:
|
||||||
"""
|
"""
|
||||||
Look for a tab with the specified locator. Returns the first matching tab.
|
Look for a tab with the specified locator. Returns the first matching tab.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -152,13 +152,13 @@ class CourseMetadata:
|
|||||||
return exclude_list
|
return exclude_list
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def fetch(cls, descriptor):
|
def fetch(cls, descriptor, filter_fields=None):
|
||||||
"""
|
"""
|
||||||
Fetch the key:value editable course details for the given course from
|
Fetch the key:value editable course details for the given course from
|
||||||
persistence and return a CourseMetadata model.
|
persistence and return a CourseMetadata model.
|
||||||
"""
|
"""
|
||||||
result = {}
|
result = {}
|
||||||
metadata = cls.fetch_all(descriptor)
|
metadata = cls.fetch_all(descriptor, filter_fields=filter_fields)
|
||||||
exclude_list_of_fields = cls.get_exclude_list_of_fields(descriptor.id)
|
exclude_list_of_fields = cls.get_exclude_list_of_fields(descriptor.id)
|
||||||
|
|
||||||
for key, value in metadata.items():
|
for key, value in metadata.items():
|
||||||
@@ -168,7 +168,7 @@ class CourseMetadata:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def fetch_all(cls, descriptor):
|
def fetch_all(cls, descriptor, filter_fields=None):
|
||||||
"""
|
"""
|
||||||
Fetches all key:value pairs from persistence and returns a CourseMetadata model.
|
Fetches all key:value pairs from persistence and returns a CourseMetadata model.
|
||||||
"""
|
"""
|
||||||
@@ -177,6 +177,9 @@ class CourseMetadata:
|
|||||||
if field.scope != Scope.settings:
|
if field.scope != Scope.settings:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if filter_fields and field.name not in filter_fields:
|
||||||
|
continue
|
||||||
|
|
||||||
field_help = _(field.help) # lint-amnesty, pylint: disable=translation-of-non-string
|
field_help = _(field.help) # lint-amnesty, pylint: disable=translation-of-non-string
|
||||||
help_args = field.runtime_options.get('help_format_args')
|
help_args = field.runtime_options.get('help_format_args')
|
||||||
if help_args is not None:
|
if help_args is not None:
|
||||||
|
|||||||
@@ -69,4 +69,4 @@ class UsageKeyField(serializers.Field):
|
|||||||
try:
|
try:
|
||||||
return UsageKey.from_string(data)
|
return UsageKey.from_string(data)
|
||||||
except InvalidKeyError as err:
|
except InvalidKeyError as err:
|
||||||
raise serializers.ValidationError("Invalid course key") from err
|
raise serializers.ValidationError("Invalid usage key") from err
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ class DeveloperErrorViewMixin:
|
|||||||
return exc.response
|
return exc.response
|
||||||
elif isinstance(exc, APIException):
|
elif isinstance(exc, APIException):
|
||||||
return self._make_error_response(exc.status_code, exc.detail)
|
return self._make_error_response(exc.status_code, exc.detail)
|
||||||
elif isinstance(exc, Http404) or isinstance(exc, ObjectDoesNotExist): # lint-amnesty, pylint: disable=consider-merging-isinstance
|
elif isinstance(exc, (Http404, ObjectDoesNotExist)):
|
||||||
return self._make_error_response(404, str(exc) or "Not found.")
|
return self._make_error_response(404, str(exc) or "Not found.")
|
||||||
elif isinstance(exc, ValidationError):
|
elif isinstance(exc, ValidationError):
|
||||||
return self._make_validation_error_response(exc)
|
return self._make_validation_error_response(exc)
|
||||||
|
|||||||
Reference in New Issue
Block a user