Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v0/views/advanced_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
class AdvancedCourseSettingsView(DeveloperErrorViewMixin, APIView):
"""
View for getting and setting the advanced settings for a course.

Deprecated: use ``GET`` and ``PATCH /api/authoring/v1/courses/{course_key}/advanced_settings/``
instead. See https://github.com/openedx/openedx-platform/issues/39128.
"""

class FilterQuery(forms.Form):
Expand Down Expand Up @@ -65,6 +68,12 @@ def get(self, request: Request, course_id: str):
"""
Get an object containing all the advanced settings in a course.

Deprecated: use ``GET /api/authoring/v1/courses/{course_key}/advanced_settings/``.
Equivalent v1 reads: without ``view`` = this endpoint with ``fetch_all=0``;
``view=full`` = this endpoint's default; ``fields`` replaces ``filter_fields``
and selects within the chosen view.
See https://github.com/openedx/openedx-platform/issues/39128.

**Example Request**

GET /api/contentstore/v0/advanced_settings/{course_id}
Expand Down Expand Up @@ -148,6 +157,9 @@ def patch(self, request: Request, course_id: str):
"""
Update a course's advanced settings.

Deprecated: use ``PATCH /api/authoring/v1/courses/{course_key}/advanced_settings/``,
which accepts this body unchanged. See https://github.com/openedx/openedx-platform/issues/39128.

**Example Request**

PATCH /api/contentstore/v0/advanced_settings/{course_id} {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Reads and updates of a course's advanced settings."""

import logging

from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.settings import api_settings

from cms.djangoapps.contentstore.views.course import update_course_advanced_settings
from cms.djangoapps.contentstore.views.permissions import COURSE_NOT_FOUND_MESSAGE
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
from xmodule.modulestore.django import modulestore

log = logging.getLogger(__name__)

# Reported under the name of each setting whose value the course refused. The
# validator's own explanation is logged, not returned.
INVALID_SETTING_MESSAGE = "The value is not valid for this setting."
# Reported when the settings could not be saved for a reason tied to no setting.
SETTINGS_NOT_SAVED_MESSAGE = "The settings could not be saved. No setting was changed."


def _course_block(course_key):
"""Return the course block of ``course_key``, or raise ``NotFound`` when the modulestore has none."""
course_block = modulestore().get_course(course_key)
if course_block is None:
raise NotFound(COURSE_NOT_FOUND_MESSAGE)
return course_block


def settings_authors_do_not_edit(course_key):
"""Return the names of the course's settings that are not edited as advanced settings."""
return frozenset(CourseMetadata.get_exclude_list_of_fields(course_key))


def get_advanced_settings(course_key, full=False):
"""
Return the advanced settings of the course, keyed by setting name.

By default only the settings course authors edit in Studio are returned;
with ``full`` every setting of the course is.
"""
course_block = _course_block(course_key)
if full:
return CourseMetadata.fetch_all(course_block)
return CourseMetadata.fetch(course_block)


def update_advanced_settings(course_key, changes, user):
"""
Apply ``changes`` (``{name: {"value": value}}``) to the course as ``user``.

Every value is validated before any is stored; when one is refused,
nothing is stored and ``ValidationError`` maps each refused setting to
``INVALID_SETTING_MESSAGE``.
Returns the settings course authors edit in Studio, as stored.
"""
course_block = _course_block(course_key)
try:
return update_course_advanced_settings(course_block, changes, user)
except ValidationError as error:
raise _errors_by_setting(course_key, error) from error


def _errors_by_setting(course_key, error):
"""
Return a ``ValidationError`` mapping each refused setting to a fixed message.

The settings validator reports one entry per refused setting, carrying the
setting's name and its own explanation, which can be the text of a parsing
exception. The explanation is logged with the course and setting; the
caller gets ``INVALID_SETTING_MESSAGE`` under the setting's name. Any
failure not tied to a setting is logged and reported with
``SETTINGS_NOT_SAVED_MESSAGE``.
"""
detail = error.detail if isinstance(error.detail, list) else [error.detail]
by_setting = {}
unattributed = []
for entry in detail:
name = entry.get("key") if isinstance(entry, dict) else None
if name:
log.warning(
"Advanced setting %s of course %s was refused: %s", name, course_key, entry.get("message", ""),
)
by_setting[str(name)] = [INVALID_SETTING_MESSAGE]
else:
unattributed.append(entry)
if unattributed:
log.warning("Advanced settings of course %s were not saved: %s", course_key, unattributed)
by_setting.setdefault(api_settings.NON_FIELD_ERRORS_KEY, []).append(SETTINGS_NOT_SAVED_MESSAGE)
return ValidationError(by_setting)
6 changes: 6 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v1/authoring_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
YoutubeTranscriptChecksViewSet,
YoutubeTranscriptImportsViewSet,
)
from cms.djangoapps.contentstore.rest_api.v1.views.advanced_settings import CourseAdvancedSettingsViewSet

app_name = "authoring_v1"

Expand Down Expand Up @@ -39,4 +40,9 @@
),
name="xblock_detail",
),
path(
"courses/<course_key:course_key>/advanced_settings/",
CourseAdvancedSettingsViewSet.as_view({"get": "retrieve", "patch": "partial_update"}),
name="course_advanced_settings",
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Serializers for a course's advanced settings."""

from collections.abc import Mapping

from drf_spectacular.extensions import OpenApiSerializerExtension
from drf_spectacular.plumbing import build_object_type, get_doc
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.settings import api_settings

MINIMAL_VIEW = "minimal"
FULL_VIEW = "full"


class AdvancedSettingSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""One advanced setting of a course, as Studio presents it for editing."""

value = serializers.JSONField(
allow_null=True,
help_text="Current value of the setting. Its type depends on the setting.",
)
display_name = serializers.CharField(help_text="Name of the setting as Studio shows it.")
help = serializers.CharField(
allow_null=True,
help_text="Text explaining what the setting does, or null when the setting has none.",
)
deprecated = serializers.BooleanField(help_text="Whether the setting is deprecated.")
hide_on_enabled_publisher = serializers.BooleanField(
help_text="Whether Studio hides the setting when the course is managed by a publishing service.",
)

class Meta:
ref_name = "AdvancedSetting.v1"


class AdvancedSettingUpdateSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""The new value of one advanced setting."""

value = serializers.JSONField(
allow_null=True,
help_text="New value of the setting. Its type depends on the setting.",
)

class Meta:
ref_name = "AdvancedSettingUpdate.v1"


class _SettingsByNameSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""
An object whose members are settings keyed by setting name.

The set of names depends on the course, so the members cannot be declared
as fields; each one is handled by ``setting_serializer_class``. Names given
in the ``ignored_settings`` context entry are dropped before any member is
validated.
"""

setting_serializer_class = None

def to_representation(self, instance):
return {
name: self.setting_serializer_class(setting).data
for name, setting in instance.items()
}

def to_internal_value(self, data):
if not isinstance(data, Mapping):
message = self.error_messages["invalid"].format(datatype=type(data).__name__)
raise ValidationError({api_settings.NON_FIELD_ERRORS_KEY: [message]}, code="invalid")
ignored = self.context.get("ignored_settings", frozenset())
validated, errors = {}, {}
for name, item in data.items():
if name in ignored:
continue
setting = self.setting_serializer_class(data=item)
if setting.is_valid():
validated[name] = dict(setting.validated_data)
else:
errors[name] = [str(message) for messages in setting.errors.values() for message in messages]
if errors:
raise ValidationError(errors)
return validated


class CourseAdvancedSettingsSerializer(_SettingsByNameSerializer): # pylint: disable=abstract-method
"""A course's advanced settings, keyed by setting name in the order the course declares them."""

setting_serializer_class = AdvancedSettingSerializer

class Meta:
ref_name = "CourseAdvancedSettings.v1"


class CourseAdvancedSettingsUpdateSerializer(_SettingsByNameSerializer): # pylint: disable=abstract-method
"""
New values for some of a course's advanced settings, keyed by setting name.

Settings that authors do not edit are ignored, whatever their entry holds;
settings the course does not have are ignored once their entry is valid.
"""

setting_serializer_class = AdvancedSettingUpdateSerializer

class Meta:
ref_name = "CourseAdvancedSettingsUpdate.v1"


class AdvancedSettingsQuerySerializer(serializers.Serializer): # pylint: disable=abstract-method
"""Query parameters of a read of a course's advanced settings."""

view = serializers.ChoiceField(
choices=[MINIMAL_VIEW, FULL_VIEW],
default=MINIMAL_VIEW,
help_text=(
"Which settings to return. 'minimal' returns the settings course authors edit in Studio; "
"'full' returns every setting of the course, including those Studio manages on other pages "
"and those of features that are turned off."
),
)
fields = serializers.CharField(
required=False,
allow_blank=True,
help_text=(
"Comma-separated names of the settings to return, out of those the view returns. "
"Names the view does not return are ignored."
),
)

class Meta:
ref_name = "AdvancedSettingsQuery.v1"


class SettingsByNameSerializerExtension(OpenApiSerializerExtension):
"""Publish a settings-by-name serializer as an object whose members are its setting component."""

target_class = _SettingsByNameSerializer
match_subclasses = True

def map_serializer(self, auto_schema, direction):
setting = auto_schema.resolve_serializer(self.target.setting_serializer_class, direction)
return build_object_type(
description=get_doc(self.target.__class__),
additionalProperties=setting.ref,
)
Loading
Loading