diff --git a/cms/djangoapps/contentstore/rest_api/urls.py b/cms/djangoapps/contentstore/rest_api/urls.py index 23036e841a..f220133df6 100644 --- a/cms/djangoapps/contentstore/rest_api/urls.py +++ b/cms/djangoapps/contentstore/rest_api/urls.py @@ -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)), ] diff --git a/cms/djangoapps/contentstore/rest_api/v0/__init__.py b/cms/djangoapps/contentstore/rest_api/v0/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cms/djangoapps/contentstore/rest_api/v0/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v0/serializers/__init__.py new file mode 100644 index 0000000000..0e799ab1cc --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v0/serializers/__init__.py @@ -0,0 +1,5 @@ +""" +Serializers for v0 contentstore API. +""" +from .advanced_settings import AdvancedSettingsFieldSerializer, CourseAdvancedSettingsSerializer +from .tabs import CourseTabSerializer, CourseTabUpdateSerializer, TabIDLocatorSerializer diff --git a/cms/djangoapps/contentstore/rest_api/v0/serializers/advanced_settings.py b/cms/djangoapps/contentstore/rest_api/v0/serializers/advanced_settings.py new file mode 100644 index 0000000000..9a61b32afa --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v0/serializers/advanced_settings.py @@ -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 diff --git a/cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py b/cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py new file mode 100644 index 0000000000..9ee0d2e837 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py @@ -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."), + ) diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/__init__.py b/cms/djangoapps/contentstore/rest_api/v0/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_advanced_settings.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_advanced_settings.py new file mode 100644 index 0000000000..70bb851927 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_advanced_settings.py @@ -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() diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_tabs.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_tabs.py new file mode 100644 index 0000000000..9502952803 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_tabs.py @@ -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) diff --git a/cms/djangoapps/contentstore/rest_api/v0/urls.py b/cms/djangoapps/contentstore/rest_api/v0/urls.py new file mode 100644 index 0000000000..eb435ef338 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v0/urls.py @@ -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", + ), +] diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py new file mode 100644 index 0000000000..91f4388cd8 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py @@ -0,0 +1,5 @@ +""" +Views for v0 contentstore API. +""" +from .advanced_settings import AdvancedCourseSettingsView +from .tabs import CourseTabSettingsView, CourseTabListView, CourseTabReorderView diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/advanced_settings.py b/cms/djangoapps/contentstore/rest_api/v0/views/advanced_settings.py new file mode 100644 index 0000000000..320ee9054d --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v0/views/advanced_settings.py @@ -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) diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/tabs.py b/cms/djangoapps/contentstore/rest_api/v0/views/tabs.py new file mode 100644 index 0000000000..0f6ddd778e --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v0/views/tabs.py @@ -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) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 44ea5c58bb..8353fb332c 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -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): diff --git a/cms/djangoapps/contentstore/views/tabs.py b/cms/djangoapps/contentstore/views/tabs.py index ade51e2cd3..356087f51f 100644 --- a/cms/djangoapps/contentstore/views/tabs.py +++ b/cms/djangoapps/contentstore/views/tabs.py @@ -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. """ diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index 988feed9f6..9477bdc7f6 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -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: diff --git a/openedx/core/lib/api/serializers.py b/openedx/core/lib/api/serializers.py index 01be907780..947d5a07f1 100644 --- a/openedx/core/lib/api/serializers.py +++ b/openedx/core/lib/api/serializers.py @@ -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 diff --git a/openedx/core/lib/api/view_utils.py b/openedx/core/lib/api/view_utils.py index 1cedc64039..24ddc83887 100644 --- a/openedx/core/lib/api/view_utils.py +++ b/openedx/core/lib/api/view_utils.py @@ -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)