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 .v0 import urls as v0_urls
|
||||
from .v1 import urls as v1_urls
|
||||
|
||||
app_name = 'cms.djangoapps.contentstore'
|
||||
|
||||
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 string
|
||||
from collections import defaultdict
|
||||
from typing import Dict
|
||||
|
||||
import django.utils
|
||||
from ccx_keys.locator import CCXLocator
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
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.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
@@ -31,6 +33,7 @@ from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys.edx.locator import BlockUsageLocator
|
||||
from organizations.api import add_organization_course, ensure_organization
|
||||
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.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 waffle as course_experience_waffle
|
||||
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.modulestore import EdxJSONEncoder
|
||||
from xmodule.modulestore.django import modulestore
|
||||
@@ -115,7 +118,7 @@ from .library import (
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
__all__ = ['course_info_handler', 'course_handler', 'course_listing',
|
||||
'course_info_update_handler', 'course_search_index_handler',
|
||||
@@ -126,7 +129,8 @@ __all__ = ['course_info_handler', 'course_handler', 'course_listing',
|
||||
'advanced_settings_handler',
|
||||
'course_notifications_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'
|
||||
|
||||
@@ -141,7 +145,7 @@ class AccessListFallback(Exception):
|
||||
|
||||
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.
|
||||
"""
|
||||
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()
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -1341,7 +1345,7 @@ def _refresh_course_tabs(request, course_module):
|
||||
# Additionally update any tabs that are provided by non-dynamic course views
|
||||
for tab_type in CourseTabPluginManager.get_tab_types():
|
||||
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)
|
||||
|
||||
CourseTabList.validate_tabs(course_tabs)
|
||||
@@ -1395,41 +1399,61 @@ def advanced_settings_handler(request, course_key_string):
|
||||
return JsonResponse(CourseMetadata.fetch(course_module))
|
||||
else:
|
||||
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,
|
||||
request.json,
|
||||
user=request.user,
|
||||
return JsonResponse(
|
||||
update_course_advanced_settings(course_module, request.json, 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
|
||||
modulestore().update_item(course_module, request.user.id)
|
||||
def update_course_advanced_settings(course_module: CourseBlock, data: Dict, user: User) -> Dict:
|
||||
"""
|
||||
Helper function to update course advanced settings from API data.
|
||||
|
||||
return JsonResponse(updated_data)
|
||||
else:
|
||||
return JsonResponseBadRequest(errors)
|
||||
This function takes JSON data returned from the API and applies changes from
|
||||
it to the course advanced settings.
|
||||
|
||||
# Handle all errors that validation doesn't catch
|
||||
except (TypeError, ValueError, InvalidTabsException) as err:
|
||||
return HttpResponseBadRequest(
|
||||
django.utils.html.escape(str(err)),
|
||||
content_type="text/plain"
|
||||
)
|
||||
Args:
|
||||
course_module (CourseBlock): The course run object on which to operate.
|
||||
data (Dict): JSON data as found the ``request.data``
|
||||
user (User): The user performing the operation
|
||||
|
||||
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):
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
"""
|
||||
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.core.exceptions import PermissionDenied
|
||||
from django.http import HttpResponseNotFound
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
|
||||
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, expect_json
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from xmodule.course_module import CourseBlock
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
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
|
||||
__all__ = ['tabs_handler']
|
||||
|
||||
__all__ = ["tabs_handler", "update_tabs_handler"]
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@expect_json
|
||||
@@ -43,40 +50,74 @@ def tabs_handler(request, course_key_string):
|
||||
|
||||
course_item = modulestore().get_course(course_key)
|
||||
|
||||
if 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
|
||||
if request.method == 'GET': # lint-amnesty, pylint: disable=no-else-raise
|
||||
raise NotImplementedError('coming soon')
|
||||
if "application/json" in request.META.get("HTTP_ACCEPT", "application/json"):
|
||||
if request.method == "GET": # lint-amnesty, pylint: disable=no-else-raise
|
||||
raise NotImplementedError("coming soon")
|
||||
else:
|
||||
if 'tabs' in request.json:
|
||||
return reorder_tabs_handler(course_item, request)
|
||||
elif 'tab_id_locator' in request.json:
|
||||
return edit_tab_handler(course_item, request)
|
||||
else:
|
||||
raise NotImplementedError('Creating or changing tab content is not supported.')
|
||||
try:
|
||||
update_tabs_handler(course_item, request.json, request.user)
|
||||
except ValidationError as err:
|
||||
return JsonResponseBadRequest(err.detail)
|
||||
return JsonResponse()
|
||||
|
||||
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
|
||||
# present in the same order they are displayed in LMS
|
||||
|
||||
tabs_to_render = []
|
||||
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)
|
||||
tabs_to_render = list(get_course_tabs(course_item, request.user))
|
||||
|
||||
return render_to_response('edit-tabs.html', {
|
||||
'context_course': course_item,
|
||||
'tabs_to_render': tabs_to_render,
|
||||
'lms_link': get_lms_link_for_item(course_item.location),
|
||||
})
|
||||
return render_to_response(
|
||||
"edit-tabs.html",
|
||||
{
|
||||
"context_course": course_item,
|
||||
"tabs_to_render": tabs_to_render,
|
||||
"lms_link": get_lms_link_for_item(course_item.location),
|
||||
},
|
||||
)
|
||||
else:
|
||||
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
|
||||
"""
|
||||
@@ -85,7 +126,7 @@ def reorder_tabs_handler(course_item, request):
|
||||
# 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
|
||||
# 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
|
||||
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:
|
||||
tab = get_tab_by_tab_id_locator(old_tab_list, tab_id_locator)
|
||||
if tab is None:
|
||||
return JsonResponse(
|
||||
{"error": f"Tab with id_locator '{tab_id_locator}' does not exist."}, status=400
|
||||
)
|
||||
raise ValidationError({"error": f"Tab with id_locator '{tab_id_locator}' does not exist."})
|
||||
new_tab_list.append(tab)
|
||||
|
||||
# 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:
|
||||
CourseTabList.validate_tabs(new_tab_list)
|
||||
except InvalidTabsException as exception:
|
||||
return JsonResponse(
|
||||
{"error": f"New list of tabs is not valid: {str(exception)}."}, status=400
|
||||
)
|
||||
raise ValidationError({"error": f"New list of tabs is not valid: {str(exception)}."}) from exception
|
||||
|
||||
# persist the new order of the tabs
|
||||
course_item.tabs = new_tab_list
|
||||
modulestore().update_item(course_item, request.user.id)
|
||||
|
||||
return JsonResponse()
|
||||
modulestore().update_item(course_item, user.id)
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
# 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
|
||||
tab = get_tab_by_tab_id_locator(course_item.tabs, tab_id_locator)
|
||||
if tab is None:
|
||||
return JsonResponse(
|
||||
{"error": f"Tab with id_locator '{tab_id_locator}' does not exist."}, status=400
|
||||
)
|
||||
raise ValidationError({"error": f"Tab with id_locator '{tab_id_locator}' does not exist."})
|
||||
|
||||
if 'is_hidden' in request.json:
|
||||
# set the is_hidden attribute on the requested tab
|
||||
tab.is_hidden = request.json['is_hidden']
|
||||
modulestore().update_item(course_item, request.user.id)
|
||||
if "is_hidden" in tabs_data:
|
||||
if tab.is_hideable:
|
||||
# set the is_hidden attribute on the requested tab
|
||||
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:
|
||||
raise NotImplementedError(f'Unsupported request to edit tab: {request.json}')
|
||||
|
||||
return JsonResponse()
|
||||
raise NotImplementedError(f"Unsupported request to edit tab: {tabs_data}")
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
if 'tab_id' in tab_id_locator:
|
||||
tab = CourseTabList.get_tab_by_id(tab_list, tab_id_locator['tab_id'])
|
||||
elif 'tab_locator' in tab_id_locator:
|
||||
tab = get_tab_by_locator(tab_list, tab_id_locator['tab_locator'])
|
||||
tab = None
|
||||
if "tab_id" in tab_id_locator:
|
||||
tab = CourseTabList.get_tab_by_id(tab_list, tab_id_locator["tab_id"])
|
||||
elif "tab_locator" in tab_id_locator:
|
||||
tab = get_tab_by_locator(tab_list, tab_id_locator["tab_locator"])
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -152,13 +152,13 @@ class CourseMetadata:
|
||||
return exclude_list
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, descriptor):
|
||||
def fetch(cls, descriptor, filter_fields=None):
|
||||
"""
|
||||
Fetch the key:value editable course details for the given course from
|
||||
persistence and return a CourseMetadata model.
|
||||
"""
|
||||
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)
|
||||
|
||||
for key, value in metadata.items():
|
||||
@@ -168,7 +168,7 @@ class CourseMetadata:
|
||||
return result
|
||||
|
||||
@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.
|
||||
"""
|
||||
@@ -177,6 +177,9 @@ class CourseMetadata:
|
||||
if field.scope != Scope.settings:
|
||||
continue
|
||||
|
||||
if filter_fields and field.name not in filter_fields:
|
||||
continue
|
||||
|
||||
field_help = _(field.help) # lint-amnesty, pylint: disable=translation-of-non-string
|
||||
help_args = field.runtime_options.get('help_format_args')
|
||||
if help_args is not None:
|
||||
|
||||
@@ -69,4 +69,4 @@ class UsageKeyField(serializers.Field):
|
||||
try:
|
||||
return UsageKey.from_string(data)
|
||||
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
|
||||
elif isinstance(exc, APIException):
|
||||
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.")
|
||||
elif isinstance(exc, ValidationError):
|
||||
return self._make_validation_error_response(exc)
|
||||
|
||||
Reference in New Issue
Block a user