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:
Kshitij Sobti
2021-05-10 19:24:17 +05:30
committed by Awais Jibran
parent c2ba5e7975
commit 8cf751a405
17 changed files with 1145 additions and 100 deletions

View File

@@ -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)),
]

View File

@@ -0,0 +1,5 @@
"""
Serializers for v0 contentstore API.
"""
from .advanced_settings import AdvancedSettingsFieldSerializer, CourseAdvancedSettingsSerializer
from .tabs import CourseTabSerializer, CourseTabUpdateSerializer, TabIDLocatorSerializer

View File

@@ -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

View 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."),
)

View File

@@ -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()

View 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)

View 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",
),
]

View File

@@ -0,0 +1,5 @@
"""
Views for v0 contentstore API.
"""
from .advanced_settings import AdvancedCourseSettingsView
from .tabs import CourseTabSettingsView, CourseTabListView, CourseTabReorderView

View File

@@ -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)

View 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)

View File

@@ -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):

View File

@@ -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.
"""

View File

@@ -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:

View File

@@ -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

View File

@@ -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)