diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/advanced_settings.py b/cms/djangoapps/contentstore/rest_api/v0/views/advanced_settings.py index c66663cd208d..ea119ea895e0 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/advanced_settings.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/advanced_settings.py @@ -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): @@ -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} @@ -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} { diff --git a/cms/djangoapps/contentstore/rest_api/v1/advanced_settings_service.py b/cms/djangoapps/contentstore/rest_api/v1/advanced_settings_service.py new file mode 100644 index 000000000000..3a641e488000 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/advanced_settings_service.py @@ -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) diff --git a/cms/djangoapps/contentstore/rest_api/v1/authoring_urls.py b/cms/djangoapps/contentstore/rest_api/v1/authoring_urls.py index 5a53ae650413..afea706611ba 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/authoring_urls.py +++ b/cms/djangoapps/contentstore/rest_api/v1/authoring_urls.py @@ -7,6 +7,7 @@ YoutubeTranscriptChecksViewSet, YoutubeTranscriptImportsViewSet, ) +from cms.djangoapps.contentstore.rest_api.v1.views.advanced_settings import CourseAdvancedSettingsViewSet app_name = "authoring_v1" @@ -39,4 +40,9 @@ ), name="xblock_detail", ), + path( + "courses//advanced_settings/", + CourseAdvancedSettingsViewSet.as_view({"get": "retrieve", "patch": "partial_update"}), + name="course_advanced_settings", + ), ] diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/advanced_settings.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/advanced_settings.py new file mode 100644 index 000000000000..7090efd864b1 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/advanced_settings.py @@ -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, + ) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/advanced_settings.py b/cms/djangoapps/contentstore/rest_api/v1/views/advanced_settings.py new file mode 100644 index 000000000000..3fba8c7740bd --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/advanced_settings.py @@ -0,0 +1,203 @@ +"""API views for a course's advanced settings.""" + +from django.core.exceptions import ImproperlyConfigured +from drf_spectacular.utils import OpenApiExample, OpenApiParameter, OpenApiResponse, extend_schema +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from edx_rest_framework_extensions.errors import ErrorResponseSerializer +from edx_rest_framework_extensions.mixins import StandardizedErrorMixin +from edx_rest_framework_extensions.shaping import project +from edx_rest_framework_extensions.url_converters import CourseKeyConverter +from rest_framework import viewsets +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from cms.djangoapps.contentstore.rest_api.v1.advanced_settings_service import ( + INVALID_SETTING_MESSAGE, + get_advanced_settings, + settings_authors_do_not_edit, + update_advanced_settings, +) +from cms.djangoapps.contentstore.rest_api.v1.serializers.advanced_settings import ( + FULL_VIEW, + MINIMAL_VIEW, + AdvancedSettingsQuerySerializer, + CourseAdvancedSettingsSerializer, + CourseAdvancedSettingsUpdateSerializer, +) +from cms.djangoapps.contentstore.views.permissions import ( + CanManageAdvancedSettings, + CanViewAdvancedSettings, + IsExistingCourse, +) + +_COURSE_KEY_PARAMETER = OpenApiParameter( + name="course_key", + description="Key of the course, such as course-v1:edX+DemoX+2026.", + location=OpenApiParameter.PATH, + type=str, + pattern=f"^{CourseKeyConverter.regex}$", +) + + +def _query_parameter(name, **kwargs): + return OpenApiParameter( + name=name, + description=AdvancedSettingsQuerySerializer().fields[name].help_text, + required=False, + location=OpenApiParameter.QUERY, + **kwargs, + ) + + +def _error(description): + return OpenApiResponse(response=ErrorResponseSerializer, description=description) + + +_SETTINGS_EXAMPLE = OpenApiExample( + "Advanced settings", + summary="Two of a course's settings", + response_only=True, + status_codes=["200"], + value={ + "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, + }, + "days_early_for_beta": { + "value": None, + "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, + }, + }, +) + +_UPDATE_EXAMPLE = OpenApiExample( + "New values", + summary="Turn on the calculator and set the advanced modules", + request_only=True, + value={ + "show_calculator": {"value": True}, + "advanced_modules": {"value": ["poll", "survey"]}, + }, +) + + +@extend_schema(tags=["openedx-platform-sdk"]) +class CourseAdvancedSettingsViewSet(StandardizedErrorMixin, viewsets.ViewSet): + """ + The advanced settings of one course: its policy settings keyed by name. + + By default (``view=minimal``) the settings course authors edit in Studio + are returned; with ``view=full`` every setting of the course is. A partial + update changes the values of the settings it names, all of them or none. + + Anyone who may view the course's advanced settings may read them; changing + them needs the permission to manage them. The course is looked up first, + so a signed-in caller learns that a course does not exist (404) before + being refused (403). + + A plain ``ViewSet``: the settings are fields of the course block in the + modulestore, not database rows. + """ + + # Kept from the legacy endpoint: an inactive account's browser session stays authenticated and + # the course permission decides (inactive course staff are refused, inactive global staff are not). + authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) + permission_classes = (IsAuthenticated, IsExistingCourse) + action_permission_classes = { + "retrieve": (CanViewAdvancedSettings,), + "partial_update": (CanManageAdvancedSettings,), + } + # A method with no route (answered 405) and OPTIONS serve no settings, so + # only the course is checked for them. + unrouted_actions = (None, "metadata") + serializer_class = CourseAdvancedSettingsSerializer + + def get_permissions(self): + """ + Check the course, then the permission the requested action needs. + + An action with no permission declared in ``action_permission_classes`` + is refused with a server error instead of being served unchecked. + """ + if self.action in self.unrouted_actions: + return super().get_permissions() + if self.action not in self.action_permission_classes: + raise ImproperlyConfigured(f"No permission is declared for the {self.action!r} action.") + specific = self.action_permission_classes[self.action] + return [*super().get_permissions(), *(permission() for permission in specific)] + + def get_serializer(self, *args, **kwargs): + """Instantiate the settings serializer declared on this viewset.""" + return self.serializer_class(*args, **kwargs) + + @extend_schema( + summary="Get a course's advanced settings", + description=( + "Returns the course's advanced settings keyed by setting name, each with its value, its name " + "as Studio shows it, its help text and whether it is deprecated. By default (view=minimal) the " + "settings course authors edit in Studio are returned; view=full returns every setting of the " + "course. fields narrows the response to the named settings." + ), + parameters=[ + _COURSE_KEY_PARAMETER, + _query_parameter("view", type=str, enum=[MINIMAL_VIEW, FULL_VIEW], default=MINIMAL_VIEW), + _query_parameter("fields", type=str), + ], + responses={ + 200: OpenApiResponse( + response=CourseAdvancedSettingsSerializer, + description="The course's advanced settings.", + ), + 400: _error("A query parameter has an invalid value."), + 401: _error("The requester is not authenticated."), + 403: _error("The requester may not view this course's advanced settings."), + 404: _error("The course does not exist."), + }, + examples=[_SETTINGS_EXAMPLE], + ) + def retrieve(self, request, course_key): + """Return the course's advanced settings, narrowed to ``fields`` when given.""" + query = AdvancedSettingsQuerySerializer(data=request.query_params) + query.is_valid(raise_exception=True) + params = query.validated_data + current = get_advanced_settings(course_key, full=params["view"] == FULL_VIEW) + return Response(project(self.get_serializer(current).data, params.get("fields"))) + + @extend_schema( + summary="Change some of a course's advanced settings", + description=( + "Sets the value of each setting named in the body. Every value is validated before any is " + "stored: when one is refused nothing is changed, and the error's errors member maps the name " + f"of each refused setting to the message '{INVALID_SETTING_MESSAGE}'. Settings that authors do " + "not edit are ignored, and so are settings the course does not have. Returns the settings " + "course authors edit in Studio, as stored." + ), + parameters=[_COURSE_KEY_PARAMETER], + request={"application/json": CourseAdvancedSettingsUpdateSerializer}, + responses={ + 200: OpenApiResponse( + response=CourseAdvancedSettingsSerializer, + description="The settings course authors edit in Studio, after the change.", + ), + 400: _error("The body is not an object of settings, or a value was refused."), + 401: _error("The requester is not authenticated."), + 403: _error("The requester may not change this course's advanced settings."), + 404: _error("The course does not exist."), + }, + examples=[_SETTINGS_EXAMPLE, _UPDATE_EXAMPLE], + ) + def partial_update(self, request, course_key): + """Store the new values and return the editable settings as stored.""" + changes = CourseAdvancedSettingsUpdateSerializer( + data=request.data, context={"ignored_settings": settings_authors_do_not_edit(course_key)}, + ) + changes.is_valid(raise_exception=True) + stored = update_advanced_settings(course_key, changes.validated_data, request.user) + return Response(self.get_serializer(stored).data) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py index aa39f5371329..7db093039e99 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py @@ -32,7 +32,12 @@ @view_auth_classes(is_authenticated=True) class CourseIndexView(DeveloperErrorViewMixin, APIView): - """View for Course Index""" + """ + View for Course Index + + Deprecated: use ``GET /api/authoring/v2/courses/{course_key}/outline/?view=full`` + instead. See https://github.com/openedx/openedx-platform/issues/39128. + """ @extend_schema( parameters=[ @@ -55,6 +60,11 @@ def get(self, request: Request, course_id: str): """ Get an object containing course index for outline. + Deprecated: use ``GET /api/authoring/v2/courses/{course_key}/outline/?view=full``, + which returns these fields except ``initial_user_clipboard``. The clipboard is read + from ``GET /api/content-staging/v1/clipboard/``. + See https://github.com/openedx/openedx-platform/issues/39128. + **Example Request** GET /api/contentstore/v1/course_index/{course_id}?show=block-v1:edx+101+y+type@course+block@course diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_rerun.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_rerun.py index 8e2a9cddeca8..78816808500a 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/course_rerun.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_rerun.py @@ -18,6 +18,9 @@ class CourseRerunView(DeveloperErrorViewMixin, APIView): """ View for course rerun. + + Deprecated: use ``GET /api/authoring/v2/courses/{course_key}/rerun_defaults/`` + instead. See https://github.com/openedx/openedx-platform/issues/39128. """ @extend_schema( @@ -36,6 +39,9 @@ def get(self, request: Request, course_id: str): """ Get an object containing course rerun. + Deprecated: use ``GET /api/authoring/v2/courses/{course_key}/rerun_defaults/``, + which returns the same fields. See https://github.com/openedx/openedx-platform/issues/39128. + **Example Request** GET /api/contentstore/v1/course_rerun/{course_id} diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/settings.py b/cms/djangoapps/contentstore/rest_api/v1/views/settings.py index a8258dd94f74..fe79f1632aa5 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/settings.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/settings.py @@ -23,6 +23,9 @@ class CourseSettingsView(DeveloperErrorViewMixin, APIView): """ View for getting the settings for a course. + + Deprecated: use ``GET /api/authoring/v2/courses/{course_key}/settings/`` + instead. See https://github.com/openedx/openedx-platform/issues/39128. """ @extend_schema( @@ -41,6 +44,13 @@ def get(self, request: Request, course_id: str): """ Get an object containing all the course settings. + Deprecated: use ``GET /api/authoring/v2/courses/{course_key}/settings/?view=full``, + which returns these fields and ``content_experiments_enabled``. Without ``view=full`` + it leaves out ``language_options``, ``possible_pre_requisite_courses`` and + ``credit_requirements``. Its prerequisite list ignores the course list's query + parameters (``search``, ``ordering``, ``active_only`` and the others) that narrow + this endpoint's. See https://github.com/openedx/openedx-platform/issues/39128. + **Example Request** GET /api/contentstore/v1/course_settings/{course_id} diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/helpers.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/helpers.py new file mode 100644 index 000000000000..08e7c5748e43 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/helpers.py @@ -0,0 +1,254 @@ +"""Shared helpers for the authoring v1 endpoint tests.""" + +from contextlib import contextmanager +from functools import cache +from unittest.mock import patch + +import jsonschema +import pymongo +from crum import signals as crum_signals +from django.core import signals as core_signals +from django.db import connection +from django.db.models import signals as model_signals +from django.dispatch import Signal +from django.test.utils import CaptureQueriesContext +from edx_rest_framework_extensions.testing import assert_error_envelope +from openedx_authz.models.engine import PolicyCacheControl +from openedx_events.tooling import OpenEdxPublicSignal +from rest_framework.test import APIClient + +from cms.djangoapps.contentstore.rest_api.v2.views.tests.helpers import generate_cms_schema +from xmodule.modulestore.mixed import MixedModuleStore + +COURSE_NOT_FOUND = "The requested course does not exist." +UNKNOWN_COURSE_KEY = "course-v1:NoSuchOrg+NoSuchCourse+NoSuchRun" +VERSION_ONLY_KEY = "course-v1:version@0123456789abcdef01234567" + + +# --------------------------------------------------------------------------- +# Clients and refusals +# --------------------------------------------------------------------------- + +def api_client(user=None): + """An API client that returns server errors as responses, authenticated as ``user`` if given.""" + client = APIClient() + client.raise_request_exception = False + if user is not None: + client.force_authenticate(user=user) + return client + + +def session_client(user): + """A client signed in to a browser session as ``user``.""" + user.set_password("password") + user.save() + client = APIClient() + assert client.login(username=user.username, password="password") + return client + + +def assert_refused(response, status): + """Assert ``response`` is the error envelope for ``status``; a 404 names the missing course.""" + slug = {401: "authn", 403: "authz", 404: "not-found"}[status] + envelope = assert_error_envelope(response, expected_status=status, expected_type_slug=slug) + if status == 404: + assert envelope["detail"] == COURSE_NOT_FOUND + return envelope + + +# --------------------------------------------------------------------------- +# The published schema +# --------------------------------------------------------------------------- + +@cache +def published_schema(): + """The CMS authoring schema, generated once per test process.""" + return generate_cms_schema() + + +def as_json_schema(node): + """An OpenAPI 3.0 schema as JSON Schema: ``nullable`` becomes a null alternative.""" + if isinstance(node, list): + return [as_json_schema(item) for item in node] + if not isinstance(node, dict): + return node + converted = {key: as_json_schema(value) for key, value in node.items() if key != "nullable"} + if node.get("nullable"): + return {"anyOf": [converted, {"type": "null"}]} + return converted + + +def assert_matches_published_schema(body, component): + """Assert ``body`` is valid against the published schema component ``component``.""" + components = as_json_schema(published_schema()["components"]["schemas"]) + jsonschema.validate(body, {"$ref": f"#/components/schemas/{component}", "components": {"schemas": components}}) + + +# --------------------------------------------------------------------------- +# Comparing two response bodies +# --------------------------------------------------------------------------- + +VOLATILE_FIELDS = {"instance"} +_ABSENT = object() + + +def flatten_json(obj, prefix=""): + """ + A ``{json_path: value}`` map of every leaf of ``obj``, without volatile + members. An empty object or array is a leaf, so a container that empties + or changes kind is seen. + """ + flat = {} + if isinstance(obj, dict) and obj: + for key in sorted(obj): + if key in VOLATILE_FIELDS: + continue + flat.update(flatten_json(obj[key], f"{prefix}.{key}" if prefix else key)) + elif isinstance(obj, list) and obj: + for index, item in enumerate(obj): + flat.update(flatten_json(item, f"{prefix}[{index}]")) + else: + flat[prefix] = obj + return flat + + +def same_json(a, b): + """Whether two leaves are equal and of the same JSON type: true is not 1, 1.0 is not 1, {} is not [].""" + return type(a) is type(b) and a == b + + +def changed_paths(legacy, new): + """The json paths whose leaf differs between the two bodies.""" + a, b = flatten_json(legacy), flatten_json(new) + return {key for key in set(a) | set(b) if not same_json(a.get(key, _ABSENT), b.get(key, _ABSENT))} + + +def undeclared_paths(changed, intentional_differences): + """The ``changed`` paths that no ``(path, reason)`` entry of ``intentional_differences`` covers.""" + declared = [path for path, _ in intentional_differences] + return { + key for key in changed + if not any(key == path or key.startswith(f"{path}.") or key.startswith(f"{path}[") for path in declared) + } + + +# --------------------------------------------------------------------------- +# Side effects and costs +# --------------------------------------------------------------------------- + +# The one write the permission library makes on a database's first role or +# permission lookup, and the model signals its save sends. +FIRST_LOOKUP_WRITES = [("INSERT", PolicyCacheControl._meta.db_table)] # pylint: disable=protected-access + + +FIRST_LOOKUP_SIGNALS = [("pre_save", "PolicyCacheControl"), ("post_save", "PolicyCacheControl")] + + +# Signals every request sends without writing anything. +NEUTRAL_SIGNALS = ( + core_signals.request_started, + core_signals.request_finished, + model_signals.pre_init, + model_signals.post_init, + crum_signals.current_user_getter, + crum_signals.current_user_setter, +) + + +MODEL_SIGNAL_NAMES = { + model_signals.pre_save: "pre_save", + model_signals.post_save: "post_save", + model_signals.pre_delete: "pre_delete", + model_signals.post_delete: "post_delete", + model_signals.m2m_changed: "m2m_changed", +} + + +@contextmanager +def count_mongo_calls(): + """Count Mongo reads and writes made inside the block.""" + reads = ("find", "find_one", "count_documents", "aggregate") + writes = ("insert_one", "insert_many", "replace_one", "update_one", "update_many", "bulk_write", + "delete_one", "delete_many", "find_one_and_update", "find_one_and_replace", "find_one_and_delete") + counts = {"reads": 0, "writes": 0} + + def counting(kind, original): + def wrapper(self, *args, **kwargs): + counts[kind] += 1 + return original(self, *args, **kwargs) + return wrapper + + collection = pymongo.collection.Collection + with patch.multiple(collection, **{ + name: counting("reads" if name in reads else "writes", getattr(collection, name)) for name in reads + writes + }): + yield counts + + +def sql_writes(queries): + """``(statement, table)`` for each INSERT, UPDATE and DELETE among the captured ``queries``.""" + found = [] + for query in queries.captured_queries: + words = query["sql"].split() + statement = words[0].upper() + if statement in ("INSERT", "DELETE"): + found.append((statement, words[2].strip('"`'))) + elif statement == "UPDATE": + found.append((statement, words[1].strip('"`'))) + return found + + +@contextmanager +def record_effects(test_case): + """ + Record, inside the block, SQL writes, Mongo writes, modulestore updates, + every Django signal other than the neutral ones (as ``(name, sender)``) and + every openedx-event. Commit callbacks registered inside the block are run, + so anything sent on commit is seen. + """ + effects = {"signals": [], "events": [], "update_item": []} + send, send_robust, send_event = Signal.send, Signal.send_robust, OpenEdxPublicSignal.send_event + update_item = MixedModuleStore.update_item + + def record(signal, sender): + if any(signal is neutral for neutral in NEUTRAL_SIGNALS): + return + name = MODEL_SIGNAL_NAMES.get(signal, repr(signal)) + effects["signals"].append((name, getattr(sender, "__name__", repr(sender)))) + + def spy_send(signal, sender, **named): + record(signal, sender) + return send(signal, sender, **named) + + def spy_send_robust(signal, sender, **named): + record(signal, sender) + return send_robust(signal, sender, **named) + + def spy_send_event(signal, *args, **kwargs): + effects["events"].append(signal.event_type) + return send_event(signal, *args, **kwargs) + + def spy_update_item(store, xblock, *args, **kwargs): + effects["update_item"].append(str(xblock.location)) + return update_item(store, xblock, *args, **kwargs) + + with CaptureQueriesContext(connection) as queries, \ + count_mongo_calls() as mongo, \ + patch.object(Signal, "send", spy_send), \ + patch.object(Signal, "send_robust", spy_send_robust), \ + patch.object(OpenEdxPublicSignal, "send_event", spy_send_event), \ + patch.object(MixedModuleStore, "update_item", spy_update_item): + # Innermost, so the callbacks run on its exit while every spy above is installed. + with test_case.captureOnCommitCallbacks(execute=True): + yield effects + effects["sql_writes"] = sql_writes(queries) + effects["mongo_writes"] = mongo["writes"] + + +def measure(request): + """SQL queries, Mongo reads and Mongo writes of the second of two identical requests.""" + request() + with CaptureQueriesContext(connection) as queries, count_mongo_calls() as mongo: + response = request() + assert response.status_code == 200, response.content + return len(queries.captured_queries), mongo["reads"], mongo["writes"] diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_advanced_settings.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_advanced_settings.py new file mode 100644 index 000000000000..27dded1e7e28 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_advanced_settings.py @@ -0,0 +1,1392 @@ +"""Tests for the v1 course advanced settings API.""" + +import json +import uuid +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from unittest.mock import patch + +import ddt +import pytest +from django.core.exceptions import ImproperlyConfigured +from django.db import transaction +from django.dispatch import Signal +from django.test import SimpleTestCase, TestCase +from django.test.utils import override_settings +from django.urls import Resolver404, resolve, reverse +from edx_rest_framework_extensions.permissions import NotJwtRestrictedApplication +from edx_rest_framework_extensions.testing import assert_error_envelope +from opaque_keys.edx.keys import CourseKey +from openedx_authz.constants.roles import ( + COURSE_ADMIN, + COURSE_AUDITOR, + COURSE_BETA_TESTER, + COURSE_DATA_RESEARCHER, + COURSE_EDITOR, + COURSE_LIMITED_STAFF, + COURSE_STAFF, +) +from openedx_authz.models.engine import PolicyCacheControl +from rest_framework.permissions import IsAuthenticated +from rest_framework.test import APIClient, APIRequestFactory, force_authenticate +from rest_framework.utils.encoders import JSONEncoder + +from cms.djangoapps.contentstore.rest_api.v1.advanced_settings_service import ( + INVALID_SETTING_MESSAGE, + SETTINGS_NOT_SAVED_MESSAGE, +) +from cms.djangoapps.contentstore.rest_api.v1.views.advanced_settings import CourseAdvancedSettingsViewSet +from cms.djangoapps.contentstore.rest_api.v1.views.tests.helpers import ( + FIRST_LOOKUP_SIGNALS, + FIRST_LOOKUP_WRITES, + UNKNOWN_COURSE_KEY, + VERSION_ONLY_KEY, + api_client, + assert_matches_published_schema, + assert_refused, + changed_paths, + measure, + published_schema, + record_effects, + session_client, + undeclared_paths, +) +from cms.djangoapps.contentstore.tests.utils import routed_actions +from cms.djangoapps.contentstore.views.permissions import ( + CanManageAdvancedSettings, + CanViewAdvancedSettings, + IsExistingCourse, +) +from cms.djangoapps.models.settings.course_metadata import CourseMetadata +from cms.lib.spectacular import LEGACY_MIGRATED_PATH_PREFIXES, SUPERSEDED_PATHS +from common.djangoapps.student.auth import check_course_advanced_settings_access, has_studio_advanced_settings_access +from common.djangoapps.student.models import CourseAccessRole +from common.djangoapps.student.roles import ( + CourseInstructorRole, + CourseLimitedStaffRole, + CourseStaffRole, + OrgInstructorRole, + OrgStaffRole, +) +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.mixed import MixedModuleStore +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory +from xmodule.tabs import InvalidTabsException + +URL_NAME = "authoring_v1:course_advanced_settings" +LEGACY_URL_NAME = "cms.djangoapps.contentstore:v0:course_advanced_settings" +LEGACY_VIEW = ("cms.djangoapps.contentstore.rest_api.v0.views.advanced_settings", "AdvancedCourseSettingsView") +SERVICE_LOGGER = "cms.djangoapps.contentstore.rest_api.v1.advanced_settings_service" +FULL = {"view": "full"} +LEGACY_DEFAULT = {"fetch_all": "0"} +# An integer setting authors edit, so a non-numeric value is refused by its field. +INTEGER_SETTING = "max_student_enrollments_allowed" +SETTING_MEMBERS = {"value", "display_name", "help", "deprecated", "hide_on_enabled_publisher"} +ACTIONS = ("read", "update") + + +def url(course_key): + """Return the address of the advanced settings of ``course_key``.""" + return reverse(URL_NAME, kwargs={"course_key": str(course_key)}) + + +def legacy_url(course_key): + """Return the deprecated advanced settings address of ``course_key``.""" + return reverse(LEGACY_URL_NAME, kwargs={"course_id": str(course_key)}) + + +def stored_course(course_key): + """The stored course block of ``course_key`` (a key or its string form), or None when there is none.""" + return modulestore().get_course(CourseKey.from_string(str(course_key))) + + +def stored_display_name(course_key): + """The display name stored for the course.""" + return stored_course(course_key).display_name + + +def editable_settings(course_key): + """The settings authors edit, as the settings model returns them for the stored course.""" + return CourseMetadata.fetch(stored_course(course_key)) + + +def every_setting(course_key): + """Every setting of the stored course, as the settings model returns them.""" + return CourseMetadata.fetch_all(stored_course(course_key)) + + +# --------------------------------------------------------------------------- +# The published schema +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# Who may call the endpoint +# --------------------------------------------------------------------------- + +class _AccessMixin: + """ + Checks a caller's status on a read or an update against the expected one, + and that v0 decides the same for the same caller. + """ + + def assert_read(self, user, expected, course_key=None): + """Assert the status of a read by ``user``, its body or envelope, and agreement with v0.""" + course_key = course_key or self.course.id + response = api_client(user).get(url(course_key)) + legacy = api_client(user).get(legacy_url(course_key), LEGACY_DEFAULT) + assert response.status_code == expected, response.content + if expected == 200: + assert list(response.json()) == list(editable_settings(course_key)) + else: + assert_refused(response, expected) + assert legacy.status_code == expected, f"v0 decided {legacy.status_code} for the same caller" + + def assert_update(self, user, expected, course_key=None): + """Assert the status of an update by ``user``, what it stored, and agreement with v0.""" + course_key = course_key or self.course.id + course = stored_course(course_key) + before = course.display_name if course is not None else None + new_name = f"Renamed {uuid.uuid4().hex[:8]}" + response = api_client(user).patch(url(course_key), {"display_name": {"value": new_name}}, format="json") + assert response.status_code == expected, response.content + if expected == 200: + assert response.json()["display_name"]["value"] == new_name + assert stored_display_name(course_key) == new_name + else: + assert_refused(response, expected) + if course is not None: + assert stored_display_name(course_key) == before + legacy_name = f"Legacy {uuid.uuid4().hex[:8]}" + legacy = api_client(user).patch(legacy_url(course_key), {"display_name": {"value": legacy_name}}, format="json") + assert legacy.status_code == expected, f"v0 decided {legacy.status_code} for the same caller" + + def assert_access(self, user, action, expected, course_key=None): + """Assert ``expected`` for ``action`` (``read`` or ``update``) by ``user``.""" + getattr(self, f"assert_{action}")(user, expected, course_key) + + +@ddt.ddt +class AdvancedSettingsAccessLegacyPermissionsTest(_AccessMixin, SharedModuleStoreTestCase): + """Who may read and change the settings when policy-based authoring permissions are off.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + cls.other_course = CourseFactory.create() + + def _user_with(self, role): + user = UserFactory() + role.add_users(user) + return user + + @ddt.data(*ACTIONS) + def test_anonymous_caller_is_refused_with_401(self, action): + self.assert_access(None, action, 401) + + @ddt.data(*ACTIONS) + def test_anonymous_caller_of_an_unknown_course_is_refused_with_401(self, action): + self.assert_access(None, action, 401, course_key=UNKNOWN_COURSE_KEY) + + @ddt.data(*ACTIONS) + def test_user_without_a_role_is_refused_with_403(self, action): + self.assert_access(UserFactory(), action, 403) + + @ddt.data(*ACTIONS) + def test_unknown_course_is_404_before_permission_is_checked(self, action): + self.assert_access(UserFactory(), action, 404, course_key=UNKNOWN_COURSE_KEY) + + @ddt.data(*ACTIONS) + def test_limited_staff_is_refused(self, action): + self.assert_access(self._user_with(CourseLimitedStaffRole(self.course.id)), action, 403) + + @ddt.data(*ACTIONS) + def test_staff_and_instructors_of_another_course_are_refused(self, action): + self.assert_access(self._user_with(CourseStaffRole(self.other_course.id)), action, 403) + self.assert_access(self._user_with(CourseInstructorRole(self.other_course.id)), action, 403) + + @ddt.data(*ACTIONS) + def test_course_staff_is_allowed(self, action): + self.assert_access(self._user_with(CourseStaffRole(self.course.id)), action, 200) + + @ddt.data(*ACTIONS) + def test_course_instructor_is_allowed(self, action): + self.assert_access(self._user_with(CourseInstructorRole(self.course.id)), action, 200) + + @ddt.data(*ACTIONS) + def test_org_staff_and_org_instructor_are_allowed(self, action): + self.assert_access(self._user_with(OrgStaffRole(self.course.id.org)), action, 200) + self.assert_access(self._user_with(OrgInstructorRole(self.course.id.org)), action, 200) + + @ddt.data(*ACTIONS) + def test_global_staff_is_allowed(self, action): + self.assert_access(UserFactory(is_staff=True), action, 200) + + @ddt.data(*ACTIONS) + def test_a_superuser_who_is_not_global_staff_is_allowed(self, action): + """The role checks consult the policy engine even here, and it admits superusers, as on v0.""" + self.assert_access(UserFactory(is_superuser=True, is_staff=False), action, 200) + + def test_a_version_only_course_key_is_404(self): + """The key parses but names no course run; v0 has no address that takes it.""" + user = UserFactory(is_staff=True) + assert_refused(api_client(user).get(url(VERSION_ONLY_KEY)), 404) + assert_refused( + api_client(user).patch(url(VERSION_ONLY_KEY), {"display_name": {"value": "x"}}, format="json"), 404, + ) + + def test_a_library_is_not_a_course(self): + library_key = LibraryFactory.create().location.library_key + user = UserFactory(is_staff=True) + assert_refused(api_client(user).get(url(library_key)), 404) + assert_refused(api_client(user).patch(url(library_key), {}, format="json"), 404) + + +@ddt.ddt +class AdvancedSettingsAccessAuthzTest(CourseAuthoringAuthzTestMixin, _AccessMixin, SharedModuleStoreTestCase): + """ + Who may read and change the settings when policy-based authoring + permissions are on: viewing needs the permission to view advanced + settings, changing them the permission to manage them. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + cls.other_course = CourseFactory.create() + + def _user_with(self, role, course_key=None): + user = UserFactory() + self.add_user_to_role_in_course(user, role, course_key or self.course.id) + return user + + @ddt.data(*ACTIONS) + def test_anonymous_caller_is_refused_with_401(self, action): + self.assert_access(None, action, 401) + + @ddt.data(*ACTIONS) + def test_user_without_a_role_is_refused_with_403(self, action): + self.assert_access(self.unauthorized_user, action, 403) + + @ddt.data(*ACTIONS) + def test_unknown_course_is_404_before_permission_is_checked(self, action): + self.assert_access(self.unauthorized_user, action, 404, course_key=UNKNOWN_COURSE_KEY) + + @ddt.data(COURSE_AUDITOR.external_key, COURSE_EDITOR.external_key) + def test_roles_that_may_only_view_may_read_but_not_update(self, role): + user = self._user_with(role) + self.assert_read(user, 200) + self.assert_update(user, 403) + + @ddt.data(*[(action, role) for action in ACTIONS for role in ( + COURSE_STAFF.external_key, COURSE_ADMIN.external_key, + )]) + @ddt.unpack + def test_roles_that_may_manage_may_read_and_update(self, action, role): + self.assert_access(self._user_with(role), action, 200) + + @ddt.data(*[(action, role) for action in ACTIONS for role in ( + COURSE_LIMITED_STAFF.external_key, COURSE_DATA_RESEARCHER.external_key, COURSE_BETA_TESTER.external_key, + )]) + @ddt.unpack + def test_other_roles_are_refused(self, action, role): + self.assert_access(self._user_with(role), action, 403) + + @ddt.data(*ACTIONS) + def test_a_role_on_another_course_is_refused(self, action): + user = self._user_with(COURSE_ADMIN.external_key, self.other_course.id) + self.assert_access(user, action, 200, course_key=self.other_course.id) + self.assert_access(user, action, 403) + + @ddt.data(*ACTIONS) + def test_legacy_course_staff_without_a_policy_role_is_refused(self, action): + user = UserFactory() + CourseAccessRole.objects.create(user=user, role="staff", course_id=self.course.id, org=self.course.id.org) + self.assert_access(user, action, 403) + + @ddt.data(*ACTIONS) + def test_staff_and_superuser_are_allowed_without_a_role(self, action): + self.assert_access(self.staff_user, action, 200) + self.assert_access(self.super_user, action, 200) + + def test_an_inactive_session_with_a_role_is_accepted(self): + """The policy does not look at whether the account is activated, on v1 as on v0.""" + user = self._user_with(COURSE_AUDITOR.external_key) + client = session_client(user) + user.is_active = False + user.save() + assert client.get(url(self.course.id)).status_code == 200 + assert client.get(legacy_url(self.course.id)).status_code == 200 + + +@override_settings(DISABLE_ADVANCED_SETTINGS=True) +class AdvancedSettingsSiteRestrictionLegacyPermissionsTest(_AccessMixin, SharedModuleStoreTestCase): + """ + With advanced settings restricted to staff site-wide and policy-based + permissions off, course staff still read and change them here, as on v0. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + + def setUp(self): + super().setUp() + self.course_staff = UserFactory() + CourseStaffRole(self.course.id).add_users(self.course_staff) + + def test_the_site_restriction_is_on(self): + """The Studio advanced settings page would refuse this caller.""" + assert has_studio_advanced_settings_access(self.course_staff) is False + assert check_course_advanced_settings_access(self.course_staff, self.course.id, "feature_restricted") is False + + def test_course_staff_may_still_read_and_update(self): + self.assert_read(self.course_staff, 200) + self.assert_update(self.course_staff, 200) + + def test_a_caller_without_a_role_is_still_refused(self): + self.assert_read(UserFactory(), 403) + self.assert_update(UserFactory(), 403) + + +@override_settings(DISABLE_ADVANCED_SETTINGS=True) +class AdvancedSettingsSiteRestrictionAuthzTest(CourseAuthoringAuthzTestMixin, _AccessMixin, SharedModuleStoreTestCase): + """ + With advanced settings restricted to staff site-wide and policy-based + permissions on, the policy alone still decides here, as on v0. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + + def _user_with(self, role): + user = UserFactory() + self.add_user_to_role_in_course(user, role, self.course.id) + return user + + def test_the_site_restriction_is_on(self): + """The restricted check that Studio pages use would refuse a course staff role.""" + user = self._user_with(COURSE_STAFF.external_key) + assert check_course_advanced_settings_access(user, self.course.id, "feature_restricted") is False + + def test_a_role_that_may_manage_may_still_read_and_update(self): + user = self._user_with(COURSE_STAFF.external_key) + self.assert_read(user, 200) + self.assert_update(user, 200) + + def test_a_role_that_may_only_view_still_may_not_update(self): + user = self._user_with(COURSE_EDITOR.external_key) + self.assert_read(user, 200) + self.assert_update(user, 403) + + +class AdvancedSettingsPermissionWiringTest(SimpleTestCase): + """Which permission each action checks, and which access each settings permission asks for.""" + + def test_each_action_checks_the_course_then_its_own_permission(self): + view = CourseAdvancedSettingsViewSet() + for action, expected in ( + ("retrieve", [IsAuthenticated, IsExistingCourse, CanViewAdvancedSettings]), + ("partial_update", [IsAuthenticated, IsExistingCourse, CanManageAdvancedSettings]), + (None, [IsAuthenticated, IsExistingCourse]), + ("metadata", [IsAuthenticated, IsExistingCourse]), + ): + view.action = action + # The JWT middleware appends NotJwtRestrictedApplication to views that take JWTs. + declared = [ + type(permission) for permission in view.get_permissions() + if not isinstance(permission, NotJwtRestrictedApplication) + ] + assert declared == expected, action + + def test_an_action_without_a_declared_permission_is_refused(self): + view = CourseAdvancedSettingsViewSet() + for action in ("destroy", "update", "list", "create"): + view.action = action + with pytest.raises(ImproperlyConfigured): + view.get_permissions() + + def test_a_routed_action_without_a_declared_permission_is_never_served(self): + """A request reaching such an action is answered with a server error, not the handler's body.""" + + class Unchecked(CourseAdvancedSettingsViewSet): + permission_classes = CourseAdvancedSettingsViewSet.permission_classes + + def destroy(self, request, course_key): # pylint: disable=unused-argument + raise AssertionError("the handler ran without a permission check") + + view = Unchecked.as_view({"delete": "destroy"}) + request = APIRequestFactory().delete(url(COURSE_KEY)) + force_authenticate(request, user=SimpleNamespace(is_authenticated=True, is_active=True)) + response = view(request, course_key=CourseKey.from_string(COURSE_KEY)) + assert_error_envelope(response, expected_status=500, expected_type_slug="internal") + + def test_the_settings_permissions_ask_for_read_and_write_access(self): + course_key = "course-v1:edX+Wiring+Run" + request = SimpleNamespace(user=object()) + view = SimpleNamespace(kwargs={"course_key": course_key}) + target = "cms.djangoapps.contentstore.views.permissions.check_course_advanced_settings_access" + for permission, access_type in ((CanViewAdvancedSettings, "read"), (CanManageAdvancedSettings, "write")): + for decision in (True, False): + with patch(target, return_value=decision) as check: + assert permission().has_permission(request, view) is decision + check.assert_called_once_with(request.user, course_key, access_type=access_type) + + def test_the_settings_permissions_refuse_a_view_without_a_course(self): + request = SimpleNamespace(user=object()) + view = SimpleNamespace(kwargs={}) + for permission in (CanViewAdvancedSettings, CanManageAdvancedSettings, IsExistingCourse): + assert permission().has_permission(request, view) is False + + +class AdvancedSettingsAuthenticationTest(SharedModuleStoreTestCase): + """How callers authenticate, with policy-based authoring permissions off.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + + def _course_staff(self): + user = UserFactory() + CourseStaffRole(self.course.id).add_users(user) + return user + + def test_a_jwt_caller_may_read_and_update(self): + header = {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(self._course_staff())}"} + assert APIClient().get(url(self.course.id), **header).status_code == 200 + response = APIClient().patch( + url(self.course.id), {"display_name": {"value": "By JWT"}}, format="json", **header, + ) + assert response.status_code == 200 + assert stored_display_name(self.course.id) == "By JWT" + + def test_a_session_caller_may_read_and_update(self): + client = session_client(self._course_staff()) + assert client.get(url(self.course.id)).status_code == 200 + response = client.patch(url(self.course.id), {"display_name": {"value": "By session"}}, format="json") + assert response.status_code == 200 + assert stored_display_name(self.course.id) == "By session" + + def test_an_inactive_session_is_authenticated(self): + """ + A session whose account is not activated is accepted, as on v0. Global + staff then pass; course staff are refused (403, not 401) because a + course role does not count while the account is inactive. + """ + for user, expected in ((UserFactory(is_staff=True), 200), (self._course_staff(), 403)): + client = session_client(user) + user.is_active = False + user.save() + response = client.get(url(self.course.id)) + assert response.status_code == expected, response.content + if expected == 403: + assert_refused(response, 403) + assert client.get(legacy_url(self.course.id)).status_code == expected + + def test_a_bearer_token_is_refused(self): + """Bearer tokens are not accepted here, though v0 takes them.""" + user = UserFactory(is_staff=True) + token = AccessTokenFactory(user=user, application=ApplicationFactory(user=user)).token + header = {"HTTP_AUTHORIZATION": f"Bearer {token}"} + assert_refused(APIClient().get(url(self.course.id), **header), 401) + assert APIClient().get(legacy_url(self.course.id), **header).status_code == 200 + + +# --------------------------------------------------------------------------- +# Reads +# --------------------------------------------------------------------------- + +@ddt.ddt +class AdvancedSettingsReadTest(SharedModuleStoreTestCase): + """What a read returns, by view and by named settings.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + + def setUp(self): + super().setUp() + reader = UserFactory() + CourseStaffRole(self.course.id).add_users(reader) + self.client = api_client(reader) + + def get(self, **params): + response = self.client.get(url(self.course.id), params) + assert response.status_code == 200, response.content + return response.json() + + def test_the_default_view_is_the_settings_authors_edit_in_course_order(self): + body = self.get() + assert list(body) == list(editable_settings(self.course.id)) + assert "display_name" in body + assert "tabs" not in body + + def test_the_full_view_is_every_setting_in_course_order(self): + body = self.get(**FULL) + assert list(body) == list(every_setting(self.course.id)) + assert "tabs" in body + + def test_the_default_view_leaves_out_exactly_the_excluded_settings(self): + default, full = self.get(), self.get(**FULL) + excluded = set(CourseMetadata.get_exclude_list_of_fields(self.course.id)) + assert set(full) - set(default) == set(full) & excluded + assert set(full) - set(default) != set() + + @ddt.data({}, FULL) + def test_every_setting_carries_the_five_members(self, params): + body = self.get(**params) + assert {name: set(setting) for name, setting in body.items()} == {name: SETTING_MEMBERS for name in body} + + @ddt.data({}, FULL) + def test_the_body_matches_the_published_schema(self, params): + assert_matches_published_schema(self.get(**params), "CourseAdvancedSettings.v1") + + def test_the_values_are_those_the_settings_model_reads(self): + body = self.get(**FULL) + assert {name: setting["value"] for name, setting in body.items()} == { + name: json.loads(json.dumps(setting["value"], cls=JSONEncoder)) + for name, setting in every_setting(self.course.id).items() + } + + def test_fields_narrows_to_the_named_settings_in_course_order(self): + named = {"days_early_for_beta", "display_name"} + body = self.get(fields="days_early_for_beta,display_name") + assert list(body) == [name for name in editable_settings(self.course.id) if name in named] + + def test_fields_ignores_names_outside_the_view(self): + assert self.get(fields="tabs,no_such_setting") == {} + assert list(self.get(fields="tabs,no_such_setting", **FULL)) == ["tabs"] + + def test_fields_ignores_spaces_around_names(self): + assert set(self.get(fields=" display_name , days_early_for_beta ")) == {"display_name", "days_early_for_beta"} + + def test_empty_fields_returns_the_whole_view(self): + assert list(self.get(fields="")) == list(editable_settings(self.course.id)) + + def test_the_minimal_view_is_the_default(self): + assert self.get(view="minimal") == self.get() + assert list(self.get(view="minimal")) == list(editable_settings(self.course.id)) + + def test_an_unknown_view_is_refused(self): + response = self.client.get(url(self.course.id), {"view": "default"}) + envelope = assert_error_envelope(response, expected_status=400, expected_type_slug="validation") + assert envelope["errors"] == {"view": ['"default" is not a valid choice.']} + + def test_the_v0_query_parameters_are_ignored(self): + """fetch_all and filter_fields have no meaning here; view and fields replace them.""" + for params in ({}, FULL): + expected = self.get(**params) + assert self.get(**params, fetch_all="0", filter_fields="display_name") == expected + assert self.get(**params, fetch_all="1") == expected + + def test_head_answers_without_a_body(self): + for params in ({}, FULL): + response = self.client.head(url(self.course.id), params) + assert response.status_code == 200 + assert response.content == b"" + + +# --------------------------------------------------------------------------- +# Updates +# --------------------------------------------------------------------------- + +class AdvancedSettingsUpdateTest(ModuleStoreTestCase): + """What an update stores, returns and refuses.""" + + def setUp(self): + super().setUp() + self.course = CourseFactory.create(display_name="Original") + writer = UserFactory() + CourseStaffRole(self.course.id).add_users(writer) + self.writer = writer + self.client = api_client(writer) + + def patch(self, body): + return self.client.patch(url(self.course.id), body, format="json") + + def test_named_settings_are_stored_and_the_editable_settings_returned(self): + response = self.patch({"display_name": {"value": "Renamed"}, "days_early_for_beta": {"value": 3.0}}) + assert response.status_code == 200, response.content + course = modulestore().get_course(self.course.id) + assert (course.display_name, course.days_early_for_beta) == ("Renamed", 3.0) + body = response.json() + assert list(body) == list(editable_settings(self.course.id)) + assert body["display_name"]["value"] == "Renamed" + assert body["days_early_for_beta"]["value"] == 3.0 + assert_matches_published_schema(body, "CourseAdvancedSettings.v1") + + def test_members_other_than_value_are_ignored(self): + response = self.patch({"display_name": {"value": "Renamed", "display_name": "Not stored", "deprecated": True}}) + assert response.status_code == 200, response.content + setting = response.json()["display_name"] + assert (setting["value"], setting["deprecated"]) == ("Renamed", False) + assert setting["display_name"] == str(editable_settings(self.course.id)["display_name"]["display_name"]) + + def test_unknown_settings_and_settings_authors_do_not_edit_are_ignored(self): + start = modulestore().get_course(self.course.id).start + response = self.patch({"no_such_setting": {"value": 1}, "start": {"value": "2030-01-01T00:00:00Z"}}) + assert response.status_code == 200, response.content + assert "no_such_setting" not in response.json() + assert "start" not in response.json() + assert modulestore().get_course(self.course.id).start == start + + def test_a_null_value_is_stored(self): + self.patch({"days_early_for_beta": {"value": 3.0}}) + response = self.patch({"days_early_for_beta": {"value": None}}) + assert response.status_code == 200, response.content + assert modulestore().get_course(self.course.id).days_early_for_beta is None + + def test_a_refused_value_is_reported_with_a_fixed_message_and_nothing_is_stored(self): + body = {INTEGER_SETTING: {"value": "lots"}, "display_name": {"value": "Not stored"}} + with self.assertLogs(SERVICE_LOGGER, "WARNING") as logs: + response = self.patch(body) + envelope = assert_error_envelope(response, expected_status=400, expected_type_slug="validation") + assert envelope["errors"] == {INTEGER_SETTING: [INVALID_SETTING_MESSAGE]} + assert stored_display_name(self.course.id) == "Original" + legacy = self.client.patch(legacy_url(self.course.id), body, format="json") + assert legacy.status_code == 400 + (validator_text,) = [ + entry["message"] for entry in legacy.json()["developer_message"] if entry["key"] == INTEGER_SETTING + ] + assert validator_text != INVALID_SETTING_MESSAGE + assert validator_text not in response.content.decode() + (record,) = logs.output + assert validator_text in record + assert INTEGER_SETTING in record + assert str(self.course.id) in record + + def test_several_refused_settings_are_each_named(self): + body = {INTEGER_SETTING: {"value": "lots"}, "days_early_for_beta": {"value": "soon"}} + with self.assertLogs(SERVICE_LOGGER, "WARNING") as logs: + response = self.patch(body) + envelope = assert_error_envelope(response, expected_status=400, expected_type_slug="validation") + assert envelope["errors"] == { + INTEGER_SETTING: [INVALID_SETTING_MESSAGE], "days_early_for_beta": [INVALID_SETTING_MESSAGE], + } + assert len(logs.output) == 2 + + def test_a_body_that_is_not_an_object_is_refused(self): + envelope = assert_error_envelope(self.patch([1, 2]), expected_status=400, expected_type_slug="validation") + assert envelope["errors"] == {"non_field_errors": ["Invalid data. Expected a dictionary, but got list."]} + + def test_each_entry_must_be_an_object_with_a_value(self): + response = self.patch({"display_name": "Renamed", "days_early_for_beta": {}}) + envelope = assert_error_envelope(response, expected_status=400, expected_type_slug="validation") + assert envelope["errors"] == { + "display_name": ["Invalid data. Expected a dictionary, but got str."], + "days_early_for_beta": ["This field is required."], + } + assert stored_display_name(self.course.id) == "Original" + + def test_an_entry_that_is_not_an_object_is_refused_by_v0_too(self): + body = {"display_name": "Renamed"} + assert self.patch(body).status_code == 400 + assert self.client.patch(legacy_url(self.course.id), body, format="json").status_code == 400 + assert stored_display_name(self.course.id) == "Original" + + def test_a_malformed_entry_of_a_setting_authors_do_not_edit_is_ignored(self): + start = stored_course(self.course.id).start + response = self.patch({"tabs": "not tabs", "start": {}, "display_name": {"value": "Renamed"}}) + assert response.status_code == 200, response.content + assert stored_display_name(self.course.id) == "Renamed" + assert stored_course(self.course.id).start == start + + def test_a_failure_saving_the_tabs_is_reported_without_its_text(self): + target = "cms.djangoapps.contentstore.views.course._refresh_course_tabs" + with ( + patch(target, side_effect=InvalidTabsException("tab internals")), + self.assertLogs(SERVICE_LOGGER, "WARNING"), + ): + response = self.patch({"display_name": {"value": "Not stored"}}) + envelope = assert_error_envelope(response, expected_status=400, expected_type_slug="validation") + assert envelope["errors"] == {"non_field_errors": [SETTINGS_NOT_SAVED_MESSAGE]} + assert b"tab internals" not in response.content + assert b"Tabs Exception" not in response.content + assert stored_display_name(self.course.id) == "Original" + with patch(target, side_effect=InvalidTabsException("tab internals")): + legacy = self.client.patch(legacy_url(self.course.id), {"display_name": {"value": "x"}}, format="json") + assert legacy.status_code == 400 + + def test_an_unexpected_validation_failure_is_reported_without_its_text(self): + target = "cms.djangoapps.models.settings.course_metadata.CourseMetadata.validate_and_update_from_json" + with patch(target, side_effect=TypeError("validator internals")), self.assertLogs(SERVICE_LOGGER, "WARNING"): + response = self.patch({"display_name": {"value": "Not stored"}}) + envelope = assert_error_envelope(response, expected_status=400, expected_type_slug="validation") + assert envelope["errors"] == {"non_field_errors": [SETTINGS_NOT_SAVED_MESSAGE]} + assert b"validator internals" not in response.content + assert stored_display_name(self.course.id) == "Original" + with patch(target, side_effect=TypeError("validator internals")): + legacy = self.client.patch(legacy_url(self.course.id), {"display_name": {"value": "x"}}, format="json") + assert legacy.status_code == 400 + assert b"validator internals" in legacy.content + + +# Requests v0 and v1 answer differently beyond the error body, as +# (request, v0 answer, v1 answer, test in AdvancedSettingsResponseDifferencesTest). +RESPONSE_DIFFERENCES = [ + ("read or update of a course the modulestore returns nothing for", "500", "404", + "test_a_course_the_modulestore_returns_nothing_for"), + ("update whose body is not an object", "500", "400", "test_an_update_whose_body_is_not_an_object"), + ("update entry without a value", "500", "400", "test_an_update_entry_without_a_value"), + ("read with view=default", "200", "400", "test_an_unknown_view"), + ("read or update with a Bearer token", "200", "401", "test_a_bearer_token"), + ("slash-separated course key", "404 JSON", "404 HTML", "test_a_slash_separated_course_key"), +] + + +class AdvancedSettingsResponseDifferencesTest(ModuleStoreTestCase): + """Each request v0 and v1 answer differently, both answers observed.""" + + def setUp(self): + super().setUp() + self.course = CourseFactory.create(display_name="Original") + writer = UserFactory() + CourseStaffRole(self.course.id).add_users(writer) + self.client = api_client(writer) + + def both(self, method, body=None, **params): + """The v0 and v1 responses to the same request.""" + call = getattr(self.client, method) + kwargs = {"format": "json"} if body is not None else {} + data = body if body is not None else params + return call(legacy_url(self.course.id), data, **kwargs), call(url(self.course.id), data, **kwargs) + + def test_every_listed_difference_has_its_test(self): + assert [name for *_, name in RESPONSE_DIFFERENCES if not hasattr(self, name)] == [] + + def test_a_course_the_modulestore_returns_nothing_for(self): + with patch.object(MixedModuleStore, "get_course", return_value=None): + for method, body in (("get", None), ("patch", {"display_name": {"value": "x"}})): + legacy, new = self.both(method, body) + assert legacy.status_code == 500, method + assert_refused(new, 404) + + def test_an_update_whose_body_is_not_an_object(self): + legacy, new = self.both("patch", [1, 2]) + assert legacy.status_code == 500 + envelope = assert_error_envelope(new, expected_status=400, expected_type_slug="validation") + assert envelope["errors"] == {"non_field_errors": ["Invalid data. Expected a dictionary, but got list."]} + + def test_an_update_entry_without_a_value(self): + legacy, new = self.both("patch", {"display_name": {}}) + assert legacy.status_code == 500 + envelope = assert_error_envelope(new, expected_status=400, expected_type_slug="validation") + assert envelope["errors"] == {"display_name": ["This field is required."]} + assert stored_display_name(self.course.id) == "Original" + + def test_an_unknown_view(self): + legacy, new = self.both("get", view="default") + assert legacy.status_code == 200 + assert_error_envelope(new, expected_status=400, expected_type_slug="validation") + + def test_a_bearer_token(self): + user = UserFactory(is_staff=True) + token = AccessTokenFactory(user=user, application=ApplicationFactory(user=user)).token + header = {"HTTP_AUTHORIZATION": f"Bearer {token}"} + assert APIClient().get(legacy_url(self.course.id), **header).status_code == 200 + assert_refused(APIClient().get(url(self.course.id), **header), 401) + body = {"display_name": {"value": "By Bearer"}} + assert APIClient().patch(legacy_url(self.course.id), body, format="json", **header).status_code == 200 + assert_refused(APIClient().patch(url(self.course.id), body, format="json", **header), 401) + + def test_a_slash_separated_course_key(self): + """v0 routes the slash form and reports the course unknown in JSON; v1 has no route for it.""" + legacy = self.client.get("/api/contentstore/v0/advanced_settings/edX/NoSuch/Course") + new = self.client.get("/api/authoring/v1/courses/edX/NoSuch/Course/advanced_settings/") + assert legacy.status_code == new.status_code == 404 + assert legacy.json()["error_code"] == "course_does_not_exist" + assert new["Content-Type"].startswith("text/html") + + +@ddt.ddt +@override_settings( + ENABLE_PROCTORED_EXAMS=True, + PROCTORING_BACKENDS={"DEFAULT": "test_proctoring_provider", "valid_provider": {}}, + PARTNER_SUPPORT_EMAIL="support@example.com", +) +class AdvancedSettingsProctoringProviderTest(ModuleStoreTestCase): + """ + Once a course has started, only global staff may change its proctoring + provider, on v1 as on v0. Before it starts, course staff may. + """ + + CHANGE = {"proctoring_provider": {"value": "valid_provider"}} + + def _course(self, started): + offset = timedelta(days=-1 if started else 30) + return CourseFactory.create(start=datetime.now(UTC) + offset) + + def _course_staff(self, course): + user = UserFactory() + CourseStaffRole(course.id).add_users(user) + return user + + def _patch(self, version, user, course): + address = url(course.id) if version == "v1" else legacy_url(course.id) + return api_client(user).patch(address, self.CHANGE, format="json") + + @ddt.data("v1", "v0") + def test_course_staff_may_not_change_it_after_the_course_starts(self, version): + course = self._course(started=True) + before = stored_course(course.id).proctoring_provider + assert before != "valid_provider" + response = self._patch(version, self._course_staff(course), course) + assert response.status_code == 400, response.content + if version == "v1": + envelope = assert_error_envelope(response, expected_status=400, expected_type_slug="validation") + assert list(envelope["errors"]) == ["proctoring_provider"] + else: + assert [entry["key"] for entry in response.json()["developer_message"]] == ["proctoring_provider"] + assert stored_course(course.id).proctoring_provider == before + + @ddt.data("v1", "v0") + def test_global_staff_may_change_it_after_the_course_starts(self, version): + course = self._course(started=True) + response = self._patch(version, UserFactory(is_staff=True), course) + assert response.status_code == 200, response.content + assert response.json()["proctoring_provider"]["value"] == "valid_provider" + assert stored_course(course.id).proctoring_provider == "valid_provider" + + @ddt.data("v1", "v0") + def test_course_staff_may_change_it_before_the_course_starts(self, version): + course = self._course(started=False) + response = self._patch(version, self._course_staff(course), course) + assert response.status_code == 200, response.content + assert stored_course(course.id).proctoring_provider == "valid_provider" + + +# --------------------------------------------------------------------------- +# Parity with v0 +# --------------------------------------------------------------------------- + +# Members of a response body that differ between v0 and v1 for the same +# request. The comparison asserts nothing else differs, and that each of these +# occurs in the error cases below. +INTENTIONAL_DIFFERENCES = [ + ("type", "errors use the standardized envelope"), + ("title", "errors use the standardized envelope"), + ("status", "errors use the standardized envelope"), + ("detail", "errors use the standardized envelope"), + ("errors", "a refused update maps the name of each refused setting to INVALID_SETTING_MESSAGE, where v0 " + "lists the validator's own text under developer_message"), + ("developer_message", "the v0 error body carries developer_message; the envelope does not"), + ("error_code", "the v0 not-found body carries error_code; the envelope does not"), +] + +# Reads of the two addresses that return the same settings. Their parameters +# differ by design: the default view is v0's fetch_all=0 response, view=full +# is v0's default, and fields replaces filter_fields. +EQUIVALENT_READS = [ + ({}, LEGACY_DEFAULT), + (FULL, {}), + ( + {"fields": "display_name,days_early_for_beta"}, + {**LEGACY_DEFAULT, "filter_fields": "display_name,days_early_for_beta"}, + ), + ({**FULL, "fields": "tabs,display_name"}, {"filter_fields": "tabs,display_name"}), +] + + +def _undeclared(changed): + """The changed paths no declared intentional difference covers.""" + return undeclared_paths(changed, INTENTIONAL_DIFFERENCES) + + +def test_the_comparison_sees_types_order_free_members_and_empty_containers(): + assert changed_paths({"a": {"b": []}}, {"a": {}}) == {"a", "a.b"} + assert changed_paths({"x": True}, {"x": 1}) == {"x"} + assert changed_paths({"x": 1.0}, {"x": 1}) == {"x"} + assert changed_paths({"x": {}}, {"x": []}) == {"x"} + assert changed_paths({"x": None, "y": 1}, {"y": 1}) == {"x"} + assert changed_paths({"instance": "/a"}, {"instance": "/b"}) == set() + assert changed_paths({"x": [1, {"y": False}]}, {"x": [1, {"y": False}]}) == set() + + +def test_only_declared_differences_are_tolerated(): + assert _undeclared({"type", "errors.display_name[0]", "developer_message[0].key"}) == set() + assert _undeclared({"display_name.value", "typed"}) == {"display_name.value", "typed"} + + +class _ParityMixin: + """ + v1 against v0 for the same course and caller: reads and updates return + identical bodies, and errors differ only by the declared differences. + """ + + def reader_for(self, course): + """Return a user who may read and change the course's settings.""" + raise NotImplementedError + + def outsider(self): + """Return a signed-in user with no access to the course.""" + raise NotImplementedError + + def setUp(self): + super().setUp() + self.course = CourseFactory.create(display_name="Parity") + self.reader = self.reader_for(self.course) + self.observed = set() + + def compare(self, legacy, new, expected_status): + """Assert both statuses and that the bodies differ only as declared; return both bodies.""" + assert legacy.status_code == expected_status, legacy.content + assert new.status_code == expected_status, new.content + legacy_body, new_body = json.loads(legacy.content or b"null"), json.loads(new.content or b"null") + changed = changed_paths(legacy_body, new_body) + assert _undeclared(changed) == set(), f"undeclared differences: legacy={legacy_body} new={new_body}" + self.observed |= changed + return legacy_body, new_body + + def test_equivalent_reads_return_identical_bodies(self): + client = api_client(self.reader) + for new_params, legacy_params in EQUIVALENT_READS: + legacy_body, new_body = self.compare( + client.get(legacy_url(self.course.id), legacy_params), + client.get(url(self.course.id), new_params), + 200, + ) + assert changed_paths(legacy_body, new_body) == set(), (new_params, legacy_params) + assert list(new_body) == list(legacy_body) + assert new_body != {} + + def test_the_same_parameters_return_different_settings(self): + """The default view is not v0's default: the difference is exactly the excluded settings.""" + client = api_client(self.reader) + legacy_body = client.get(legacy_url(self.course.id)).json() + new_body = client.get(url(self.course.id)).json() + assert set(legacy_body) - set(new_body) == set(every_setting(self.course.id)) - set( + editable_settings(self.course.id) + ) + assert set(new_body) < set(legacy_body) + assert {name: legacy_body[name] for name in new_body} == new_body + + def test_an_update_returns_an_identical_body(self): + body = {"display_name": {"value": "Changed by both"}, "days_early_for_beta": {"value": 2.0}} + client = api_client(self.reader) + new = client.patch(url(self.course.id), body, format="json") + legacy = client.patch(legacy_url(self.course.id), body, format="json") + legacy_body, new_body = self.compare(legacy, new, 200) + assert changed_paths(legacy_body, new_body) == set() + assert new_body["display_name"]["value"] == "Changed by both" + + def _error_pairs(self): + """``(legacy, new, status)`` for each error both addresses produce.""" + refused = {INTEGER_SETTING: {"value": "lots"}} + cases = [ + (api_client(), self.course.id, "get", None, 401), + (api_client(self.outsider()), self.course.id, "get", None, 403), + (api_client(self.outsider()), UNKNOWN_COURSE_KEY, "get", None, 404), + (api_client(self.reader), self.course.id, "patch", refused, 400), + ] + for client, course_key, method, body, status in cases: + call = getattr(client, method) + kwargs = {"format": "json"} if body is not None else {} + legacy = call(legacy_url(course_key), body, **kwargs) + new = call(url(course_key), body, **kwargs) + yield legacy, new, status + + def test_errors_differ_only_by_the_envelope(self): + for legacy, new, status in self._error_pairs(): + self.compare(legacy, new, status) + + def test_a_refused_update_names_the_settings_v0_names_with_a_fixed_message(self): + *_, (legacy, new, status) = self._error_pairs() + legacy_body, new_body = self.compare(legacy, new, status) + validator_texts = {entry["key"]: entry["message"] for entry in legacy_body["developer_message"]} + assert new_body["errors"] == {name: [INVALID_SETTING_MESSAGE] for name in validator_texts} + assert INVALID_SETTING_MESSAGE not in validator_texts.values() + assert [text for text in validator_texts.values() if text in new.content.decode()] == [] + + def test_a_malformed_entry_of_a_setting_authors_do_not_edit_is_ignored_by_both(self): + client = api_client(self.reader) + for body in ({"tabs": "not tabs"}, {"start": {}}, {"tabs": "not tabs", "display_name": {"value": "Both"}}): + new = client.patch(url(self.course.id), body, format="json") + legacy = client.patch(legacy_url(self.course.id), body, format="json") + legacy_body, new_body = self.compare(legacy, new, 200) + assert changed_paths(legacy_body, new_body) == set(), body + + def test_every_declared_difference_occurs(self): + for legacy, new, status in self._error_pairs(): + self.compare(legacy, new, status) + missing = [ + path for path, _ in INTENTIONAL_DIFFERENCES + if not any(key == path or key.startswith(f"{path}.") or key.startswith(f"{path}[") for key in self.observed) + ] + assert missing == [] + + +class AdvancedSettingsParityLegacyPermissionsTest(_ParityMixin, ModuleStoreTestCase): + """Parity read and written by course staff, with policy-based authoring permissions off.""" + + def reader_for(self, course): + reader = UserFactory() + CourseStaffRole(course.id).add_users(reader) + return reader + + def outsider(self): + return UserFactory() + + +class AdvancedSettingsParityAuthzTest(_ParityMixin, CourseAuthoringAuthzTestMixin, ModuleStoreTestCase): + """Parity read and written by a course staff role, with policy-based authoring permissions on.""" + + def reader_for(self, course): + reader = UserFactory() + self.add_user_to_role_in_course(reader, COURSE_STAFF.external_key, course.id) + return reader + + def outsider(self): + return self.unauthorized_user + + +# --------------------------------------------------------------------------- +# Side effects of reads +# --------------------------------------------------------------------------- + + +READS = [("get", {}), ("get", FULL), ("get", {"fields": "display_name"}), ("head", {}), ("head", FULL)] + + +class _ReadsAreSideEffectFreeMixin: + """ + Every read (default, full, named settings, and HEAD on both views) writes + no row and no Mongo document, stores nothing through the modulestore and + sends no signal or event. The reads start on a database that has never + served a role or permission lookup, so the first read may insert the + permission library's one policy version row and nothing else; v0 inserts + the same row. + """ + + def reader_for(self, course): + """Return a user who may read ``course``, leaving the database as no lookup had touched it.""" + raise NotImplementedError + + def setUp(self): + super().setUp() + self.course = CourseFactory.create() + + def test_reads_have_no_side_effects(self): + client = api_client(self.reader_for(self.course)) + assert not PolicyCacheControl.objects.exists() + for index, (method, params) in enumerate(READS): + with record_effects(self) as effects: + response = getattr(client, method)(url(self.course.id), params) + read = (index, method, params) + first = index == 0 + assert response.status_code == 200, (read, response.content) + assert effects["sql_writes"] == (FIRST_LOOKUP_WRITES if first else []), read + assert effects["signals"] == (FIRST_LOOKUP_SIGNALS if first else []), read + assert effects["events"] == [], read + assert effects["mongo_writes"] == 0, read + assert effects["update_item"] == [], read + + def test_the_v0_read_makes_the_same_first_lookup_write(self): + client = api_client(self.reader_for(self.course)) + with record_effects(self) as effects: + response = client.get(legacy_url(self.course.id), LEGACY_DEFAULT) + assert response.status_code == 200, response.content + assert effects["sql_writes"] == FIRST_LOOKUP_WRITES + assert effects["signals"] == FIRST_LOOKUP_SIGNALS + assert effects["mongo_writes"] == 0 + + def test_the_recorder_sees_writes_signals_and_modulestore_updates(self): + """The checks above would notice a write: one made inside the recorder is seen.""" + with record_effects(self) as effects: + UserFactory() + course = modulestore().get_course(self.course.id) + modulestore().update_item(course, self.user.id) + assert ("INSERT", "auth_user") in effects["sql_writes"] + assert ("post_save", "User") in effects["signals"] + assert effects["mongo_writes"] > 0 + assert effects["update_item"] == [str(course.location)] + + def test_the_recorder_sees_what_commit_callbacks_do(self): + """Work deferred to the end of the transaction is recorded too.""" + probe = Signal() + + class Sender: + pass + + with record_effects(self) as effects: + transaction.on_commit(lambda: probe.send(sender=Sender)) + transaction.on_commit(lambda: UserFactory(username="made_on_commit")) + assert (repr(probe), "Sender") in effects["signals"] + assert ("INSERT", "auth_user") in effects["sql_writes"] + assert ("post_save", "User") in effects["signals"] + + +class AdvancedSettingsReadSideEffectsLegacyPermissionsTest(_ReadsAreSideEffectFreeMixin, ModuleStoreTestCase): + """Reads by course staff with policy-based authoring permissions off.""" + + def reader_for(self, course): + reader = UserFactory() + CourseStaffRole(course.id).add_users(reader) + PolicyCacheControl.objects.all().delete() + return reader + + +class AdvancedSettingsReadSideEffectsAuthzTest( + _ReadsAreSideEffectFreeMixin, CourseAuthoringAuthzTestMixin, ModuleStoreTestCase, +): + """ + Reads by a course auditor with policy-based authoring permissions on. The + role is assigned through the permission library, which creates the policy + version row; it is deleted before the reads. + """ + + def reader_for(self, course): + reader = UserFactory() + self.add_user_to_role_in_course(reader, COURSE_AUDITOR.external_key, course.id) + PolicyCacheControl.objects.all().delete() + return reader + + +# --------------------------------------------------------------------------- +# Query counts +# --------------------------------------------------------------------------- + + +class _QueryCountMixin: + """Each read costs no more SQL queries or Mongo reads than the v0 read returning the same settings.""" + + def reader_for(self, course): + raise NotImplementedError + + def test_reads_cost_no_more_than_the_equivalent_v0_reads(self): + course = CourseFactory.create() + client = api_client(self.reader_for(course)) + for new_params, legacy_params in EQUIVALENT_READS[:2]: + legacy = measure(lambda params=legacy_params: client.get(legacy_url(course.id), params)) + new = measure(lambda params=new_params: client.get(url(course.id), params)) + assert new[0] <= legacy[0], (new_params, new, legacy) + assert new[1] <= legacy[1], (new_params, new, legacy) + assert new[2] == legacy[2] == 0, (new_params, new, legacy) + + +class AdvancedSettingsQueryCountLegacyPermissionsTest(_QueryCountMixin, ModuleStoreTestCase): + """Costs read by course staff, with policy-based authoring permissions off.""" + + def reader_for(self, course): + reader = UserFactory() + CourseStaffRole(course.id).add_users(reader) + return reader + + +class AdvancedSettingsQueryCountAuthzTest(CourseAuthoringAuthzTestMixin, _QueryCountMixin, ModuleStoreTestCase): + """Costs read by a course auditor, with policy-based authoring permissions on.""" + + def reader_for(self, course): + reader = UserFactory() + self.add_user_to_role_in_course(reader, COURSE_AUDITOR.external_key, course.id) + return reader + + +# --------------------------------------------------------------------------- +# Addresses and methods +# --------------------------------------------------------------------------- + +COURSE_KEY = "course-v1:edX+DemoX+Demo_Course" + + +@ddt.ddt +class AdvancedSettingsRoutingTest(SharedModuleStoreTestCase): + """The address, the v0 address, and requests no route or method serves.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + + def test_the_address(self): + assert url(COURSE_KEY) == f"/api/authoring/v1/courses/{COURSE_KEY}/advanced_settings/" + match = resolve(url(COURSE_KEY)) + assert match.func.cls is CourseAdvancedSettingsViewSet + assert routed_actions(match.func) == {"get": "retrieve", "patch": "partial_update"} + + def test_the_v0_address_is_unchanged(self): + assert legacy_url(COURSE_KEY) == f"/api/contentstore/v0/advanced_settings/{COURSE_KEY}" + view = resolve(legacy_url(COURSE_KEY)).func.cls + assert (view.__module__, view.__name__) == LEGACY_VIEW + + def test_v0_still_takes_a_slash_separated_key_that_v1_does_not(self): + view = resolve("/api/contentstore/v0/advanced_settings/edX/DemoX/Demo_Course").func.cls + assert (view.__module__, view.__name__) == LEGACY_VIEW + with pytest.raises(Resolver404): + resolve("/api/authoring/v1/courses/edX/DemoX/Demo_Course/advanced_settings/") + + def test_the_url_names_are_unique_in_the_namespace(self): + # Imported here: at module import the URL converters are not registered yet. + from cms.djangoapps.contentstore.rest_api.v1 import authoring_urls # noqa: PLC0415 + names = [pattern.name for pattern in authoring_urls.urlpatterns] + assert len(names) == len(set(names)) + assert "course_advanced_settings" in names + + # The error library does not catalogue MethodNotAllowed or NotAcceptable, so + # these answer with the envelope typed internal. Change the expected type + # here once it does. + + @ddt.data("post", "put", "delete") + def test_other_methods_are_not_allowed(self, method): + response = getattr(api_client(UserFactory(is_staff=True)), method)(url(self.course.id), {}, format="json") + assert_error_envelope(response, expected_status=405, expected_type_slug="internal") + + def test_unsupported_accept_is_refused_with_406(self): + response = api_client(UserFactory(is_staff=True)).get(url(self.course.id), HTTP_ACCEPT="application/xml") + assert_error_envelope(response, expected_status=406, expected_type_slug="internal") + + # Nor ParseError or UnsupportedMediaType: a malformed or unsupported body is + # answered with its own status but typed internal. Change the expected type + # here once the library catalogues them. + + def test_a_malformed_json_body_is_a_400_typed_internal(self): + response = api_client(UserFactory(is_staff=True)).patch( + url(self.course.id), data="{", content_type="application/json", + ) + envelope = assert_error_envelope(response, expected_status=400, expected_type_slug="internal") + assert envelope["title"] == "Internal Server Error" + + def test_an_unsupported_media_type_is_a_415_typed_internal(self): + response = api_client(UserFactory(is_staff=True)).patch( + url(self.course.id), data="display_name=Renamed", content_type="text/plain", + ) + envelope = assert_error_envelope(response, expected_status=415, expected_type_slug="internal") + assert envelope["title"] == "Internal Server Error" + assert stored_display_name(self.course.id) != "Renamed" + + # These addresses match no route, so the service's HTML 404 page answers + # them. Change these to the error envelope once a catch-all route under the + # mount answers unknown addresses. + + @ddt.data("not-a-course-key", "edX/DemoX/Demo_Course", "edX+DemoX+Demo_Course") + def test_an_unparsable_or_slash_form_key_is_an_html_404(self, key): + response = api_client(UserFactory(is_staff=True)).get(f"/api/authoring/v1/courses/{key}/advanced_settings/") + assert response.status_code == 404 + assert response["Content-Type"].startswith("text/html") + + def test_the_address_without_its_trailing_slash_redirects_to_it(self): + """No route answers the address without its slash; the service redirects a read to the slashed one.""" + address = f"/api/authoring/v1/courses/{self.course.id}/advanced_settings" + response = api_client(UserFactory(is_staff=True)).get(address) + assert response.status_code == 301 + assert response["Location"] == url(self.course.id) + with pytest.raises(Resolver404): + resolve(address) + + +# --------------------------------------------------------------------------- +# The published schema +# --------------------------------------------------------------------------- + +class AdvancedSettingsSchemaTest(TestCase): + """The read and the update as published in the CMS authoring schema.""" + + PATH = "/api/authoring/v1/courses/{course_key}/advanced_settings/" + LEGACY = "/api/contentstore/v0/advanced_settings/{course_id}" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.schema = published_schema() + cls.components = cls.schema["components"]["schemas"] + + @staticmethod + def _ref(node): + return node["content"]["application/json"]["schema"]["$ref"].rsplit("/", 1)[-1] + + def test_the_read_and_the_update_are_published_for_the_sdk(self): + operations = self.schema["paths"][self.PATH] + assert sorted(operations) == ["get", "patch"] + assert operations["get"]["operationId"] == "v1_courses_advanced_settings_retrieve" + assert operations["patch"]["operationId"] == "v1_courses_advanced_settings_partial_update" + for operation in operations.values(): + assert operation["tags"] == ["openedx-platform-sdk"] + assert "deprecated" not in operation + assert self._ref(operation["responses"]["200"]) == "CourseAdvancedSettings.v1" + for status in ("400", "401", "403", "404"): + assert self._ref(operation["responses"][status]) == "ErrorResponse", status + + def test_the_read_parameters(self): + parameters = {p["name"]: p for p in self.schema["paths"][self.PATH]["get"]["parameters"]} + assert set(parameters) == {"course_key", "view", "fields"} + assert parameters["course_key"]["in"] == "path" + assert parameters["course_key"]["schema"] == {"type": "string", "pattern": "^[^/]+$"} + assert parameters["view"]["in"] == parameters["fields"]["in"] == "query" + assert not parameters["view"].get("required") + assert not parameters["fields"].get("required") + assert parameters["view"]["schema"] == {"type": "string", "enum": ["full", "minimal"], "default": "minimal"} + assert parameters["fields"]["schema"] == {"type": "string"} + + def test_the_settings_are_an_object_keyed_by_setting_name(self): + settings = self.components["CourseAdvancedSettings.v1"] + assert settings["type"] == "object" + assert settings["additionalProperties"] == {"$ref": "#/components/schemas/AdvancedSetting.v1"} + assert "properties" not in settings + setting = self.components["AdvancedSetting.v1"] + assert set(setting["properties"]) == SETTING_MEMBERS + assert set(setting["required"]) == SETTING_MEMBERS + nullable = {name for name, field in setting["properties"].items() if field.get("nullable")} + assert nullable == {"value", "help"} + + def test_the_update_body_is_json_settings_each_with_a_value(self): + body = self.schema["paths"][self.PATH]["patch"]["requestBody"] + assert list(body["content"]) == ["application/json"] + assert self._ref(body) == "PatchedCourseAdvancedSettingsUpdate.v1" + update = self.components[self._ref(body)] + assert update["type"] == "object" + assert update["additionalProperties"] == {"$ref": "#/components/schemas/AdvancedSettingUpdate.v1"} + entry = self.components["AdvancedSettingUpdate.v1"] + assert set(entry["properties"]) == {"value"} + assert entry["required"] == ["value"] + assert entry["properties"]["value"]["nullable"] is True + + def test_every_member_is_described(self): + for name in ("CourseAdvancedSettings.v1", "AdvancedSetting.v1", "AdvancedSettingUpdate.v1"): + component = self.components[name] + assert component.get("description"), name + properties = component.get("properties", {}) + assert [field for field, spec in properties.items() if not spec.get("description")] == [], name + + def test_the_examples_are_valid_bodies(self): + get = self.schema["paths"][self.PATH]["get"] + patch_ = self.schema["paths"][self.PATH]["patch"] + response_example = get["responses"]["200"]["content"]["application/json"]["examples"]["AdvancedSettings"] + assert_matches_published_schema(response_example["value"], "CourseAdvancedSettings.v1") + request_example = patch_["requestBody"]["content"]["application/json"]["examples"]["NewValues"] + assert_matches_published_schema(request_example["value"], self._ref(patch_["requestBody"])) + + def test_the_v0_operations_are_deprecated_and_left_out_of_the_sdk(self): + operations = self.schema["paths"][self.LEGACY] + assert sorted(operations) == ["get", "patch"] + assert operations["get"]["operationId"] == "v0_advanced_settings_retrieve" + assert operations["patch"]["operationId"] == "v0_advanced_settings_partial_update" + for operation in operations.values(): + assert operation["deprecated"] is True + assert "openedx-platform-sdk" not in operation.get("tags", []) + + def test_only_superseded_or_migrated_addresses_are_deprecated(self): + """No operation is marked deprecated except by the legacy-address lists, so a stray mark is caught.""" + deprecated = { + (path, method) + for path, operations in self.schema["paths"].items() + for method, operation in operations.items() + if isinstance(operation, dict) and operation.get("deprecated") + } + assert {(self.LEGACY, "get"), (self.LEGACY, "patch")} <= deprecated + assert {(self.PATH, "get"), (self.PATH, "patch")} & deprecated == set() + stray = sorted( + (path, method) for path, method in deprecated + if path not in SUPERSEDED_PATHS and not path.startswith(LEGACY_MIGRATED_PATH_PREFIXES) + ) + assert stray == [] diff --git a/cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py b/cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py index 1c0d693dec4f..5a18c5f2ac6a 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py +++ b/cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py @@ -2,6 +2,9 @@ from django.urls import path +from cms.djangoapps.contentstore.rest_api.v2.views.course_outline import CourseOutlineViewSet +from cms.djangoapps.contentstore.rest_api.v2.views.course_settings import CourseSettingsViewSet +from cms.djangoapps.contentstore.rest_api.v2.views.rerun_defaults import CourseRerunDefaultsViewSet from cms.djangoapps.contentstore.rest_api.v2.views.textbooks import CourseTextbooksViewSet from cms.djangoapps.contentstore.rest_api.v2.views.video_settings import CourseVideoSettingsViewSet @@ -18,4 +21,19 @@ CourseVideoSettingsViewSet.as_view({"get": "retrieve"}), name="course_video_settings", ), + path( + "courses//outline/", + CourseOutlineViewSet.as_view({"get": "retrieve"}), + name="course_outline", + ), + path( + "courses//rerun_defaults/", + CourseRerunDefaultsViewSet.as_view({"get": "retrieve"}), + name="course_rerun_defaults", + ), + path( + "courses//settings/", + CourseSettingsViewSet.as_view({"get": "retrieve"}), + name="course_settings", + ), ] diff --git a/cms/djangoapps/contentstore/rest_api/v2/course_outline_service.py b/cms/djangoapps/contentstore/rest_api/v2/course_outline_service.py new file mode 100644 index 000000000000..b46bfd1b91f5 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/course_outline_service.py @@ -0,0 +1,198 @@ +"""Service layer for the v2 course outline endpoint.""" + +import logging +from urllib.parse import urlencode + +from django.conf import settings +from django.urls import reverse +from django.utils.translation import gettext as _ +from rest_framework.exceptions import NotFound, ValidationError + +from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES +from cms.djangoapps.contentstore.rest_api.v2.serializers.course_outline import NOT_AN_OUTLINE_BLOCK_MESSAGE +from cms.djangoapps.contentstore.utils import ( + get_lms_link_for_item, + get_pages_and_resources_url, + get_proctored_exam_settings_url, + reverse_course_url, +) +from cms.djangoapps.contentstore.views.course import ( + _deprecated_blocks_info, + course_outline_initial_state, + user_can_reindex_course, +) +from cms.djangoapps.contentstore.views.permissions import COURSE_NOT_FOUND_MESSAGE +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import create_xblock_info +from cms.djangoapps.models.settings.course_metadata import CourseMetadata +from common.djangoapps.course_action_state.managers import CourseActionStateItemNotFoundError +from common.djangoapps.course_action_state.models import CourseRerunState +from common.djangoapps.util.date_utils import get_default_time_display +from common.djangoapps.xblock_django.api import deprecated_xblocks +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from xmodule.course_block import DEFAULT_START_DATE # pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.exceptions import ItemNotFoundError # pylint: disable=wrong-import-order + +log = logging.getLogger(__name__) + + +def _has_outline_children(block): + """Whether the outline lists children under ``block``: every container except a unit does.""" + return block.has_children and block.category != "vertical" + + +def _is_outline_container(store, block): + """ + Whether ``block`` is a container, other than the course, that the outline reaches. + + The outline opens a block's children only when ``_has_outline_children`` + holds for it, so a block is reached exactly when that holds for every + ancestor up to the course. A block no parent links to is not reached. + These are the blocks a ``children_url`` can name. + """ + if not block.has_children or block.category == "course": + return False + parent_location = store.get_parent_location(block.location) + while parent_location is not None: + parent = store.get_item(parent_location) + if not _has_outline_children(parent): + return False + if parent.category == "course": + return True + parent_location = store.get_parent_location(parent_location) + return False + + +def _load_course(course_key): + """Return the course block of ``course_key``, or raise ``NotFound`` when the modulestore lacks it.""" + course = modulestore().get_course(course_key) + if course is None: + log.info("Outline requested for course %s, which is not in the modulestore.", course_key) + raise NotFound(COURSE_NOT_FOUND_MESSAGE) + return course + + +def _outline_node(block, levels_left, children_url): + """ + Return the outline node of ``block`` with its descendants ``levels_left`` levels deep. + + A container cut at the depth limit carries ``children_url`` in place of + ``children`` when it has children. A unit, or any block the outline does + not open, carries neither. + """ + node = { + "id": str(block.location), + "category": block.category, + "display_name": block.display_name_with_default, + } + if not _has_outline_children(block): + return node + if levels_left > 0: + node["children"] = [ + _outline_node(child, levels_left - 1, children_url) for child in block.get_children() + ] + elif block.children: + node["children_url"] = children_url(block) + else: + node["children"] = [] + return node + + +def get_outline_tree(request, course_key, depth, usage_key=None): + """ + Return the outline of ``course_key`` as a tree of nodes, ``depth`` levels below its root. + + The root is the course, or the container ``usage_key`` when given: any + block of the outline that has children, such as a section, subsection, + unit or a split test placed in a subsection. ``usage_key`` must already be + known to name a block of the course; ``ValidationError`` is raised when + the block does not exist or is not such a container. Nothing is written. + """ + outline_url = request.build_absolute_uri( + reverse("authoring_v2:course_outline", kwargs={"course_key": course_key}) + ) + + def children_url(block): + return f"{outline_url}?{urlencode({'usage_key': str(block.location), 'depth': 1})}" + + store = modulestore() + with store.bulk_operations(course_key): + root = _load_course(course_key) + if usage_key is not None: + try: + root = store.get_item(usage_key) + except ItemNotFoundError as error: + log.info("Outline of %s requested under %s, which does not exist: %s", course_key, usage_key, error) + raise ValidationError({"usage_key": [NOT_AN_OUTLINE_BLOCK_MESSAGE]}) from error + if not _is_outline_container(store, root): + log.info("Outline of %s requested under %s, which is not a container in it.", course_key, usage_key) + raise ValidationError({"usage_key": [NOT_AN_OUTLINE_BLOCK_MESSAGE]}) + return _outline_node(root, depth, children_url) + + +def _rerun_notification(course_key): + """Return the course's rerun status still shown to authors, or None.""" + try: + return CourseRerunState.objects.find_first(course_key=course_key, should_display=True) + except (ItemNotFoundError, CourseActionStateItemNotFoundError): + return None + + +def get_full_outline(request, course_key, show=None): + """ + Return the course outline page's data for ``course_key``. + + ``course_structure`` is the whole outline down to units, in the shape the + Studio outline page reads, and ``initial_state`` expands the block ``show`` + when it is in the outline. ``reindex_link`` is set only for callers allowed + to reindex the course. + + Nothing is written: release dates before 1900 are reported as stored, and + the course's discussion settings are copied before the configuration + address is added to them. + """ + store = modulestore() + with store.bulk_operations(course_key): + course = _load_course(course_key) + course_structure = create_xblock_info( + course, + include_child_info=True, + course_outline=True, + include_children_predicate=lambda block: block.category != "vertical", + ) + reindex_link = None + if settings.ENABLE_COURSEWARE_INDEX and user_can_reindex_course(course_key, request.user): + reindex_link = f"/course/{course_key}/search_reindex" + rerun = _rerun_notification(course_key) + deprecated_blocks_info = _deprecated_blocks_info(course, [block.name for block in deprecated_xblocks()]) + proctoring_errors = CourseMetadata.validate_proctoring_settings( + course, CourseMetadata.fetch(course), request.user, + ) + discussions_settings = { + **course.discussions_settings, + "discussion_configuration_url": f"{get_pages_and_resources_url(course.id)}/discussion/settings", + } + overview = CourseOverview.objects.filter(id=course.id).values("created").first() + return { + "course_release_date": ( + get_default_time_display(course.start) if course.start != DEFAULT_START_DATE else _("Set Date") + ), + "course_structure": course_structure, + "deprecated_blocks_info": deprecated_blocks_info, + "discussions_incontext_feedback_url": settings.DISCUSSIONS_INCONTEXT_FEEDBACK_URL, + "discussions_incontext_learnmore_url": settings.DISCUSSIONS_INCONTEXT_LEARNMORE_URL, + "discussions_settings": discussions_settings, + "initial_state": course_outline_initial_state(show, course_structure) if show else None, + "language_code": request.LANGUAGE_CODE, + "lms_link": get_lms_link_for_item(course.location), + "mfe_proctored_exam_settings_url": get_proctored_exam_settings_url(course.id), + "notification_dismiss_url": reverse_course_url( + "course_notifications_handler", rerun.course_key, kwargs={"action_state_id": rerun.id}, + ) if rerun else None, + "proctoring_errors": proctoring_errors, + "reindex_link": reindex_link, + "rerun_notification_id": rerun.id if rerun else None, + "advance_settings_url": reverse_course_url("advanced_settings_handler", course.id), + "is_custom_relative_dates_active": CUSTOM_RELATIVE_DATES.is_enabled(course_key), + "created_on": overview["created"] if overview else None, + } diff --git a/cms/djangoapps/contentstore/rest_api/v2/course_settings_service.py b/cms/djangoapps/contentstore/rest_api/v2/course_settings_service.py new file mode 100644 index 000000000000..beadb8a8e094 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/course_settings_service.py @@ -0,0 +1,65 @@ +"""Service layer for the v2 course settings endpoint.""" + +import logging + +from django.conf import settings +from django.http import HttpRequest +from rest_framework.exceptions import NotFound +from rest_framework.request import Request + +from cms.djangoapps.contentstore.utils import get_course_settings +from cms.djangoapps.contentstore.views.course import are_content_experiments_enabled +from cms.djangoapps.contentstore.views.permissions import COURSE_NOT_FOUND_MESSAGE +from lms.djangoapps.certificates.api import can_show_certificate_available_date_field +from xmodule.modulestore.django import modulestore + +log = logging.getLogger(__name__) + +# Members returned only by the full view. +FULL_VIEW_FIELDS = ("credit_requirements", "language_options", "possible_pre_requisite_courses") + + +def _request_without_query(request): + """ + Return a request by the caller of ``request`` that carries no query parameters. + + The courses that may be prerequisites are listed by the Studio course + listing, which searches, filters and orders courses by the query parameters + of the request it is given, and refuses some malformed ones. + """ + bare = Request(HttpRequest()) + bare.user = request.user + return bare + + +def get_course_settings_data(request, course_key, full=False): + """ + Return the Studio settings of the course ``course_key`` as the caller of ``request`` sees them. + + The members in ``FULL_VIEW_FIELDS`` are left out unless ``full`` is true; + even then the prerequisite courses are present only while prerequisite + courses are on, and the credit requirements only for a credit course while + credit eligibility is on. The query parameters of ``request`` are not read. + + Raises ``NotFound`` when the modulestore has no course ``course_key``. + """ + store = modulestore() + with store.bulk_operations(course_key): + course = store.get_course(course_key) + if course is None: + log.info("Settings requested for course %s, which is not in the modulestore.", course_key) + raise NotFound(COURSE_NOT_FOUND_MESSAGE) + data = get_course_settings(_request_without_query(request), course_key, course) + data.update({ + "can_show_certificate_available_date_field": can_show_certificate_available_date_field(course), + "content_experiments_enabled": are_content_experiments_enabled(course), + "course_display_name": course.display_name, + "course_display_name_with_default": course.display_name_with_default, + "licensing_enabled": settings.LICENSING, + "platform_name": settings.PLATFORM_NAME, + }) + if not full: + # The shared settings helper builds these members either way; leaving them out saves payload, not work. + for name in FULL_VIEW_FIELDS: + data.pop(name, None) + return data diff --git a/cms/djangoapps/contentstore/rest_api/v2/rerun_defaults_service.py b/cms/djangoapps/contentstore/rest_api/v2/rerun_defaults_service.py new file mode 100644 index 000000000000..03e931030013 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/rerun_defaults_service.py @@ -0,0 +1,31 @@ +"""Service layer for the v2 course rerun defaults endpoint.""" + +import logging + +from rest_framework.exceptions import NotFound + +from cms.djangoapps.contentstore.utils import get_course_rerun_context +from cms.djangoapps.contentstore.views.permissions import COURSE_NOT_FOUND_MESSAGE +from xmodule.modulestore.django import modulestore + +log = logging.getLogger(__name__) + + +def get_rerun_defaults(course_key, user): + """ + Return the values a rerun of the course ``course_key`` starts from, for ``user``. + + ``user`` must be global staff. For anyone else, looking up whether they may + create courses can record a course creator request for them, which a read + must not do. + + Raises ``NotFound`` when the modulestore has no course ``course_key``. + """ + store = modulestore() + with store.bulk_operations(course_key): + course = store.get_course(course_key) + if course is None: + log.info("Rerun defaults requested for course %s, which is not in the modulestore.", course_key) + raise NotFound(COURSE_NOT_FOUND_MESSAGE) + context = get_course_rerun_context(course_key, course, user) + return {**context, "org": course_key.org, "number": course_key.course, "run": course_key.run} diff --git a/cms/djangoapps/contentstore/rest_api/v2/serializers/course_outline.py b/cms/djangoapps/contentstore/rest_api/v2/serializers/course_outline.py new file mode 100644 index 000000000000..74284b501a84 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/serializers/course_outline.py @@ -0,0 +1,291 @@ +"""Serializers for a course's outline.""" + +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import UsageKey +from opaque_keys.edx.locator import BlockUsageLocator +from rest_framework import serializers + +OUTLINE_MINIMAL_VIEW = "minimal" +OUTLINE_FULL_VIEW = "full" +OUTLINE_DEFAULT_DEPTH = 2 +OUTLINE_MAX_DEPTH = 3 +NOT_AN_OUTLINE_BLOCK_MESSAGE = "No container in this course's outline has this key." + + +def _run_of(course_key): + """The course run ``course_key`` names, whatever branch or version it pins.""" + return course_key.for_branch(None).version_agnostic() + + +class CourseOutlineNodeSerializer(serializers.Serializer): # pylint: disable=abstract-method + """One block of the course outline, with its children down to the requested depth.""" + + id = serializers.CharField(help_text="Usage key of the block.") + category = serializers.CharField( + help_text=( + "Block type: course, chapter (section), sequential (subsection), vertical (unit), another " + "container such as split_test, or a component such as problem placed directly in a section " + "or subsection." + ), + ) + display_name = serializers.CharField(help_text="Name of the block shown to authors.") + children_url = serializers.URLField( + required=False, + help_text=( + "Address that returns this block with its children, one level deep. Present instead of " + "children on a container the outline opens whose children lie below the requested depth." + ), + ) + + def get_fields(self): + fields = super().get_fields() + fields["children"] = CourseOutlineNodeSerializer( + many=True, + required=False, + help_text=( + "Child blocks in course order. Present on a container the outline opens when its " + "children lie within the requested depth, and empty when it has none. Units and " + "components carry neither children nor children_url." + ), + ) + return fields + + class Meta: + ref_name = "CourseOutline" + + +class CourseOutlineInitialStateSerializer(serializers.Serializer): # pylint: disable=abstract-method + """The outline block to scroll to, and the blocks to show expanded with it.""" + + locator_to_show = serializers.CharField(help_text="Usage key of the block requested with show.") + expanded_locators = serializers.ListField( + child=serializers.CharField(), + help_text="Usage keys of that block and every block below it, in outline order.", + ) + + class Meta: + ref_name = "CourseOutlineInitialState" + + +class CourseOutlineDeprecatedBlocksSerializer(serializers.Serializer): # pylint: disable=abstract-method + """Blocks of deprecated types that the course still uses.""" + + deprecated_enabled_block_types = serializers.ListField( + child=serializers.CharField(), + help_text="Deprecated block types enabled in the course's advanced settings.", + ) + blocks = serializers.ListField( + child=serializers.ListField(child=serializers.CharField(allow_null=True)), + help_text=( + "One pair per block of a deprecated type: the Studio address of the unit holding it, " + "then the block's name, null when it has none." + ), + ) + advance_settings_url = serializers.CharField(help_text="Studio address of the course's advanced settings.") + + class Meta: + ref_name = "CourseOutlineDeprecatedBlocks" + + +class CourseOutlineProctoringSettingSerializer(serializers.Serializer): # pylint: disable=abstract-method + """The stored advanced setting a proctoring problem was found in.""" + + deprecated = serializers.BooleanField(help_text="Whether the setting is deprecated.") + display_name = serializers.CharField(help_text="Name of the setting shown to authors.") + help = serializers.CharField(help_text="Help text shown to authors for the setting.") + hide_on_enabled_publisher = serializers.BooleanField( + help_text="Whether the setting is hidden when an external course publisher is enabled.", + ) + value = serializers.CharField(help_text="The setting's stored value.") + + class Meta: + ref_name = "CourseOutlineProctoringSetting" + + +class CourseOutlineProctoringErrorSerializer(serializers.Serializer): # pylint: disable=abstract-method + """A problem found in the course's stored proctoring settings.""" + + key = serializers.CharField(help_text="Name of the advanced setting at fault, such as proctoring_provider.") + message = serializers.CharField(help_text="Description of the problem, for authors.") + model = CourseOutlineProctoringSettingSerializer(help_text="The setting at fault as stored.") + + class Meta: + ref_name = "CourseOutlineProctoringError" + + +class CourseOutlineFullSerializer(serializers.Serializer): # pylint: disable=abstract-method + """The course outline with everything the Studio outline page shows beside it.""" + + course_release_date = serializers.CharField( + help_text='Course start date formatted for display, or "Set Date" when no start date is set.', + ) + course_structure = serializers.DictField( + help_text=( + "The whole outline down to units, as the Studio outline page reads it: the course block, " + "whose child_info.children holds its sections, and so on down to units and to components " + "placed directly in a section or subsection. child_info is an object with category, " + "display_name and children on the course, sections and subsections, with children only on " + "other containers, and is absent on units and components. " + "Every block carries actions, ancestor_has_staff_lock, category, course_authoring_url, " + "course_graders, course_tags_count, display_name, due, due_date, edited_on, edited_on_raw, " + "embed_lms_url, enable_copy_paste_units, enable_hide_from_toc_ui, explanatory_message, format, " + "graded, group_access, has_changes, has_children, has_explicit_staff_lock, " + "has_partition_group_components, hide_from_toc, hide_from_toc_message, id, " + "is_tagging_feature_disabled, lms_url, published, published_on, relative_weeks_due, " + "release_date, released_to_students, show_correctness, staff_only_message, start, studio_url, " + "tag_counts_by_block, taxonomy_tags_widget_url, upstream_info, user_partition_info, " + "user_partitions, visibility_state and xblock_type. " + "The course also carries end, highlights_enabled_for_messaging and unit_level_discussions; the " + "course and sections highlights_doc_url, highlights_enabled and highlights_preview_only; " + "sections highlights; subsections hide_after_due; units discussion_enabled. " + "Some keys depend on configuration: course_authoring_url, course_tags_count, " + "tag_counts_by_block and taxonomy_tags_widget_url are absent when tagging is disabled; " + "video_sharing_enabled, video_sharing_options and video_sharing_doc_url appear when public " + "video sharing is on; with special exams enabled the course carries enable_proctored_exams, " + "create_zendesk_tickets and enable_timed_exams, and subsections is_proctored_exam, " + "was_exam_ever_linked_with_external, online_proctoring_rules, is_practice_exam, " + "is_onboarding_exam, is_time_limited, exam_review_rules, default_time_limit_minutes, " + "proctoring_exam_configuration_link, supports_onboarding and show_review_rules; with " + "subsection gating on, subsections carry is_prereq, prereqs, prereq, prereq_min_score and " + "prereq_min_completion; a subsection of an entrance exam carries is_header_visible; units " + "carry summary_configuration_enabled when content summaries are configured." + ), + ) + deprecated_blocks_info = CourseOutlineDeprecatedBlocksSerializer( + help_text="Blocks of deprecated types used in the course.", + ) + discussions_incontext_feedback_url = serializers.CharField( + help_text="Address where authors give feedback on in-context discussions. Empty when not configured.", + ) + discussions_incontext_learnmore_url = serializers.CharField( + help_text="Address of the in-context discussions documentation. Empty when not configured.", + ) + discussions_settings = serializers.DictField( + help_text=( + "The course's discussion settings as stored, plus discussion_configuration_url, the address " + "of the discussion configuration page. A course that was never configured carries " + "enable_in_context, enable_graded_units and unit_level_visibility. A configured course may " + "also carry provider_type, provider, posting_restrictions and an object of settings named " + "after its provider." + ), + ) + initial_state = CourseOutlineInitialStateSerializer( + allow_null=True, + help_text="The block requested with show and the blocks to expand. Null without show or when no " + "outline block has that key.", + ) + language_code = serializers.CharField(help_text="Language the response is written in.") + lms_link = serializers.CharField( + allow_null=True, + help_text="Address of the course in the LMS. Null when no LMS address is configured.", + ) + mfe_proctored_exam_settings_url = serializers.CharField( + help_text="Address of the course's proctored exam settings. Empty when proctoring is not available.", + ) + notification_dismiss_url = serializers.CharField( + allow_null=True, + help_text="Studio address that dismisses the rerun notification. Null when there is none.", + ) + proctoring_errors = CourseOutlineProctoringErrorSerializer( + many=True, + help_text="Problems found in the course's stored proctoring settings.", + ) + reindex_link = serializers.CharField( + allow_null=True, + help_text="Studio address that reindexes the course for search. Null when search indexing is off or " + "the requester may not reindex the course.", + ) + rerun_notification_id = serializers.IntegerField( + allow_null=True, + help_text="Identifier of the course rerun status still shown to authors. Null when there is none.", + ) + advance_settings_url = serializers.CharField(help_text="Studio address of the course's advanced settings.") + is_custom_relative_dates_active = serializers.BooleanField( + help_text="Whether custom relative due dates are enabled for the course.", + ) + created_on = serializers.DateTimeField( + allow_null=True, + help_text="When the course was created. Null when the course has no overview record.", + ) + + class Meta: + ref_name = "CourseOutlineFull" + + +class CourseOutlineQuerySerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Query parameters of a course outline read. + + A parameter sent with an empty value counts as omitted. Needs the course in + the path as ``course_key`` in its context, to refuse a ``usage_key`` from + another course. + """ + + view = serializers.ChoiceField( + choices=[OUTLINE_MINIMAL_VIEW, OUTLINE_FULL_VIEW], + required=False, + help_text=( + "minimal (the default) returns the outline tree to the requested depth. full returns the whole " + "outline with everything the Studio outline page shows beside it." + ), + ) + depth = serializers.IntegerField( + required=False, + min_value=1, + max_value=OUTLINE_MAX_DEPTH, + help_text=( + f"Levels of blocks below the root to include, from 1 to {OUTLINE_MAX_DEPTH}. Defaults to " + f"{OUTLINE_DEFAULT_DEPTH}: sections and subsections. Minimal view only." + ), + ) + usage_key = serializers.CharField( + required=False, + help_text=( + "Usage key of a container in this course's outline, such as a section, subsection or unit, " + "to return in place of the whole course. Minimal view only." + ), + ) + show = serializers.CharField( + required=False, + help_text="Usage key of a block to report in initial_state with the blocks to expand. Full view only.", + ) + + def validate_usage_key(self, value): + """ + Parse the key and accept only a key of a block in the course run in the path. + + Whether the block is in the course's outline is checked when the outline + is read. + """ + try: + usage_key = UsageKey.from_string(value) + except InvalidKeyError as error: + raise serializers.ValidationError(NOT_AN_OUTLINE_BLOCK_MESSAGE) from error + if ( + not isinstance(usage_key, BlockUsageLocator) + or usage_key.deprecated + # A key naming only a version has no course run to compare. + or usage_key.course_key.org is None + or _run_of(usage_key.course_key) != _run_of(self.context["course_key"]) + ): + raise serializers.ValidationError(NOT_AN_OUTLINE_BLOCK_MESSAGE) + return usage_key + + def validate(self, attrs): + view = attrs.get("view") or OUTLINE_MINIMAL_VIEW + if view == OUTLINE_FULL_VIEW: + errors = { + name: ["Applies to the minimal view only."] + for name in ("depth", "usage_key") if name in attrs + } + else: + errors = {"show": ["Applies to the full view only."]} if "show" in attrs else {} + if errors: + raise serializers.ValidationError(errors) + attrs["view"] = view + if view == OUTLINE_MINIMAL_VIEW: + attrs.setdefault("depth", OUTLINE_DEFAULT_DEPTH) + return attrs + + class Meta: + ref_name = "CourseOutlineQuery" diff --git a/cms/djangoapps/contentstore/rest_api/v2/serializers/course_settings.py b/cms/djangoapps/contentstore/rest_api/v2/serializers/course_settings.py new file mode 100644 index 000000000000..560cd96e9af5 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/serializers/course_settings.py @@ -0,0 +1,171 @@ +"""Serializers for a course's Studio settings.""" + +from rest_framework import serializers + +MINIMAL_VIEW = "minimal" +FULL_VIEW = "full" + + +class PrerequisiteCourseSerializer(serializers.Serializer): # pylint: disable=abstract-method + """A course the caller may set as a prerequisite of this course.""" + + course_key = serializers.CharField(help_text="Key of the course.") + display_name = serializers.CharField( + allow_null=True, + help_text="Name of the course, or null when it has none.", + ) + lms_link = serializers.CharField( + allow_null=True, + help_text=( + "Address of the course in the LMS, or null when no LMS address is configured or the course " + "key is in the deprecated slash-separated form." + ), + ) + number = serializers.CharField(help_text="Course number as shown to learners.") + org = serializers.CharField(help_text="Organization as shown to learners.") + rerun_link = serializers.CharField( + allow_null=True, + help_text="Studio path of the page that starts a rerun of the course, or null for a slash-separated key.", + ) + run = serializers.CharField(help_text="Run of the course.") + url = serializers.CharField( + allow_null=True, + help_text="Studio path of the course, or null for a slash-separated key.", + ) + + class Meta: + ref_name = "PrerequisiteCourse.v2" + + +class CreditRequirementSerializer(serializers.Serializer): # pylint: disable=abstract-method + """One requirement a learner meets to become eligible for credit in the course.""" + + name = serializers.CharField(help_text="Name of the requirement, unique within its namespace.") + display_name = serializers.CharField(help_text="Name of the requirement as shown to authors.") + criteria = serializers.DictField( + help_text="What meeting the requirement takes, such as {\"min_grade\": 0.8} for a grade requirement.", + ) + + class Meta: + ref_name = "CreditRequirement.v2" + + +class CourseSettingsSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + What Studio's course settings pages need to know about a course and the site. + + ``credit_requirements``, ``language_options`` and + ``possible_pre_requisite_courses`` are returned only with ``view=full``. + """ + + about_page_editable = serializers.BooleanField( + help_text="Whether the course about page is edited in Studio, which it is not when a publishing service " + "manages the course's organization.", + ) + can_show_certificate_available_date_field = serializers.BooleanField( + help_text="Whether authors may set the date certificates become available: automatic certificate " + "generation is on and the course is instructor-paced.", + ) + content_experiments_enabled = serializers.BooleanField( + help_text="Whether content experiments are turned on in the course's advanced settings and installed on " + "the site, so experiment group configurations apply to it. Read this instead of the " + "should_show_experiment_groups flag of the course's group configurations listing, which " + "its successor does not return.", + ) + course_display_name = serializers.CharField( + allow_null=True, + help_text="Name of the course, or null when it has none.", + ) + course_display_name_with_default = serializers.CharField( + help_text="Name of the course, or a name made from its key when it has none.", + ) + credit_eligibility_enabled = serializers.BooleanField( + help_text="Whether credit eligibility is turned on for the site.", + ) + credit_requirements = serializers.DictField( + child=serializers.ListField(child=CreditRequirementSerializer()), + required=False, + help_text="The course's credit requirements grouped by namespace, such as grade or proctored_exam. " + "Returned only with view=full, and then only when credit eligibility is on and the course is " + "a credit course.", + ) + enable_extended_course_details = serializers.BooleanField( + help_text="Whether the extended course details, such as instructors and learning outcomes, are edited " + "for courses of this organization.", + ) + enrollment_end_editable = serializers.BooleanField( + help_text="Whether the caller may change the enrollment end date: global staff always may, others only " + "when no publishing service manages the course's organization.", + ) + is_credit_course = serializers.BooleanField( + help_text="Whether the course offers credit. Always false while credit eligibility is off.", + ) + is_entrance_exams_enabled = serializers.BooleanField( + help_text="Whether entrance exams are turned on for the site.", + ) + is_prerequisite_courses_enabled = serializers.BooleanField( + help_text="Whether prerequisite courses are turned on for the site.", + ) + language_options = serializers.ListField( + child=serializers.ListField(child=serializers.CharField(), min_length=2, max_length=2), + required=False, + help_text="Languages a course may be taught in, as [code, name] pairs. Returned only with view=full.", + ) + licensing_enabled = serializers.BooleanField( + help_text="Whether authors may choose the license of course content.", + ) + lms_link_for_about_page = serializers.CharField( + help_text="Address learners use to reach the course about page.", + ) + marketing_enabled = serializers.BooleanField( + help_text="Whether course marketing settings are shown. Always true.", + ) + mfe_proctored_exam_settings_url = serializers.CharField( + allow_blank=True, + help_text="Path, within the authoring app, of the course's proctored exam settings page, or an empty " + "string when that page is not served by the authoring app. Read this instead of the " + "mfe_proctored_exam_settings_url link of the course's group configurations listing, which " + "its successor does not return.", + ) + platform_name = serializers.CharField(help_text="Name of the site.") + possible_pre_requisite_courses = PrerequisiteCourseSerializer( + many=True, + required=False, + help_text="Courses the caller may set as prerequisites: every course the caller may view in Studio " + "except this one and courses still being created as reruns, oldest first. Returned only " + "with view=full, and then only when prerequisite courses are on.", + ) + short_description_editable = serializers.BooleanField( + help_text="Whether the course's short description is edited in Studio for courses of this organization.", + ) + show_min_grade_warning = serializers.BooleanField( + help_text="Whether to warn that the course is a credit course with no minimum grade for credit set.", + ) + sidebar_html_enabled = serializers.BooleanField( + help_text="Whether authors may add HTML to the sidebar of the course about page.", + ) + upgrade_deadline = serializers.DateTimeField( + allow_null=True, + help_text="When the course's verified mode stops being sold, or null when it has no such mode or " + "no deadline.", + ) + + class Meta: + ref_name = "CourseSettings.v2" + + +class CourseSettingsQuerySerializer(serializers.Serializer): # pylint: disable=abstract-method + """Query parameters of a read of a course's settings.""" + + view = serializers.ChoiceField( + choices=[MINIMAL_VIEW, FULL_VIEW], + default=MINIMAL_VIEW, + help_text=( + "How much to return. 'minimal' returns the course's settings and the site's switches; " + "'full' also returns the language list, the courses that may be prerequisites and the " + "course's credit requirements." + ), + ) + + class Meta: + ref_name = "CourseSettingsQuery.v2" diff --git a/cms/djangoapps/contentstore/rest_api/v2/serializers/rerun_defaults.py b/cms/djangoapps/contentstore/rest_api/v2/serializers/rerun_defaults.py new file mode 100644 index 000000000000..0f8aa8a6e07c --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/serializers/rerun_defaults.py @@ -0,0 +1,30 @@ +"""Serializers for the values a rerun of a course starts from.""" + +from rest_framework import serializers + + +class CourseRerunDefaultsSerializer(serializers.Serializer): # pylint: disable=abstract-method + """The values Studio fills in when an author starts a rerun of a course.""" + + allow_unicode_course_id = serializers.BooleanField( + help_text=( + "Whether this site accepts characters outside ASCII in the organization, number and run of " + "a new course." + ), + ) + course_creator_status = serializers.CharField( + help_text=( + "Whether the caller may create courses. Only global staff may read these values, and they " + "always may, so the value is 'granted'." + ), + ) + display_name = serializers.CharField( + allow_null=True, + help_text="Name of the course, which the rerun starts with, or null when the course has none.", + ) + number = serializers.CharField(help_text="Course number of the course, which the rerun keeps.") + org = serializers.CharField(help_text="Organization of the course, which the rerun keeps.") + run = serializers.CharField(help_text="Run of the course. The rerun needs a different one.") + + class Meta: + ref_name = "CourseRerunDefaults.v2" diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/course_outline.py b/cms/djangoapps/contentstore/rest_api/v2/views/course_outline.py new file mode 100644 index 000000000000..cc5331a6338f --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/views/course_outline.py @@ -0,0 +1,220 @@ +"""API views for a course's outline.""" + +from drf_spectacular.utils import ( + OpenApiExample, + OpenApiParameter, + OpenApiResponse, + PolymorphicProxySerializer, + extend_schema, +) +from edx_rest_framework_extensions.errors import ErrorResponseSerializer +from edx_rest_framework_extensions.mixins import StandardizedErrorMixin +from edx_rest_framework_extensions.url_converters import CourseKeyConverter +from rest_framework import viewsets +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from cms.djangoapps.contentstore.rest_api.v2.course_outline_service import get_full_outline, get_outline_tree +from cms.djangoapps.contentstore.rest_api.v2.serializers.course_outline import ( + OUTLINE_DEFAULT_DEPTH, + OUTLINE_FULL_VIEW, + OUTLINE_MAX_DEPTH, + OUTLINE_MINIMAL_VIEW, + CourseOutlineFullSerializer, + CourseOutlineNodeSerializer, + CourseOutlineQuerySerializer, +) +from cms.djangoapps.contentstore.views.permissions import CanViewCourse + + +def _query_parameter(name, **kwargs): + return OpenApiParameter( + name=name, + description=CourseOutlineQuerySerializer().fields[name].help_text, + required=False, + location=OpenApiParameter.QUERY, + **kwargs, + ) + + +_COURSE_KEY_PARAMETER = OpenApiParameter( + name="course_key", + description="Key of the course, such as course-v1:edX+DemoX+2026.", + location=OpenApiParameter.PATH, + type=str, + pattern=f"^{CourseKeyConverter.regex}$", +) + + +def _error(description): + return OpenApiResponse(response=ErrorResponseSerializer, description=description) + + +_EXAMPLE_COURSE = "course-v1:edX+DemoX+2026" +_EXAMPLE_BLOCK = "block-v1:edX+DemoX+2026+type@{}+block@{}" +_EXAMPLE_COURSE_BLOCK = _EXAMPLE_BLOCK.format("course", "course") + +MINIMAL_VIEW_EXAMPLE = OpenApiExample( + "Outline tree", + summary="Default view, two levels deep", + description="A subsection with units carries children_url instead of children.", + response_only=True, + status_codes=["200"], + value={ + "id": _EXAMPLE_COURSE_BLOCK, + "category": "course", + "display_name": "Demonstration Course", + "children": [ + { + "id": _EXAMPLE_BLOCK.format("chapter", "intro"), + "category": "chapter", + "display_name": "Introduction", + "children": [ + { + "id": _EXAMPLE_BLOCK.format("sequential", "welcome"), + "category": "sequential", + "display_name": "Welcome", + "children_url": ( + f"https://studio.example.com/api/authoring/v2/courses/{_EXAMPLE_COURSE}/outline/" + "?usage_key=block-v1%3AedX%2BDemoX%2B2026%2Btype%40sequential%2Bblock%40welcome&depth=1" + ), + }, + { + "id": _EXAMPLE_BLOCK.format("sequential", "coming_soon"), + "category": "sequential", + "display_name": "Coming soon", + "children": [], + }, + ], + }, + ], + }, +) + +FULL_VIEW_EXAMPLE = OpenApiExample( + "Outline page data", + summary="view=full, abridged", + description="course_structure is cut to the course block and shows a few of each block's keys.", + response_only=True, + status_codes=["200"], + value={ + "course_release_date": "Jan 15, 2026 at 00:00 UTC", + "course_structure": { + "id": _EXAMPLE_COURSE_BLOCK, + "display_name": "Demonstration Course", + "category": "course", + "has_children": True, + "published": True, + "visibility_state": "live", + "start": "2026-01-15T00:00:00Z", + "child_info": {"category": "chapter", "display_name": "Section", "children": []}, + }, + "deprecated_blocks_info": { + "deprecated_enabled_block_types": [], + "blocks": [], + "advance_settings_url": f"/settings/advanced/{_EXAMPLE_COURSE}", + }, + "discussions_incontext_feedback_url": "", + "discussions_incontext_learnmore_url": "", + "discussions_settings": { + "enable_in_context": True, + "enable_graded_units": False, + "unit_level_visibility": True, + "discussion_configuration_url": ( + f"https://apps.example.com/authoring/course/{_EXAMPLE_COURSE}/pages-and-resources/discussion/settings" + ), + }, + "initial_state": None, + "language_code": "en", + "lms_link": f"https://lms.example.com/courses/{_EXAMPLE_COURSE}/jump_to/{_EXAMPLE_COURSE_BLOCK}", + "mfe_proctored_exam_settings_url": "", + "notification_dismiss_url": None, + "proctoring_errors": [], + "reindex_link": None, + "rerun_notification_id": None, + "advance_settings_url": f"/settings/advanced/{_EXAMPLE_COURSE}", + "is_custom_relative_dates_active": False, + "created_on": "2025-11-02T09:30:00Z", + }, +) + +_READ_RESPONSE = PolymorphicProxySerializer( + component_name="CourseOutlineRead", + serializers=[CourseOutlineNodeSerializer, CourseOutlineFullSerializer], + resource_type_field_name=None, +) + + +@extend_schema(tags=["openedx-platform-sdk"]) +class CourseOutlineViewSet(StandardizedErrorMixin, viewsets.ViewSet): + """ + The outline of one course: its sections, subsections and units. + + By default the outline is a tree cut at a requested depth, where a block + with deeper children carries the address that returns them. With + ``view=full`` the whole outline is returned with everything the Studio + outline page shows beside it. + + Callable by anyone who may view the course in Studio. The course is looked + up first, so a signed-in caller learns that a course does not exist (404) + before being refused (403). + + A plain ``ViewSet``: the outline is read from the modulestore, not from + database rows. + """ + + permission_classes = (IsAuthenticated, CanViewCourse) + serializer_class = CourseOutlineNodeSerializer + + def get_serializer(self, *args, **kwargs): + """Instantiate the outline node serializer declared on this viewset.""" + return self.serializer_class(*args, **kwargs) + + @extend_schema( + summary="Get a course's outline", + description=( + "Returns the course outline as a tree of blocks, by default two levels deep: sections and " + "their subsections. A block whose children lie below the requested depth carries " + "children_url, which returns them one level deep. usage_key returns the tree under any " + "container in the course outline, such as a section, subsection or unit. With view=full the " + "response is the whole outline down to units with the course's release date, discussion and " + "proctoring settings, rerun notification and the Studio addresses the outline page links to." + ), + parameters=[ + _COURSE_KEY_PARAMETER, + _query_parameter( + "view", type=str, enum=[OUTLINE_MINIMAL_VIEW, OUTLINE_FULL_VIEW], default=OUTLINE_MINIMAL_VIEW, + ), + _query_parameter( + "depth", + type={"type": "integer", "minimum": 1, "maximum": OUTLINE_MAX_DEPTH}, + default=OUTLINE_DEFAULT_DEPTH, + ), + _query_parameter("usage_key", type=str), + _query_parameter("show", type=str), + ], + responses={ + 200: OpenApiResponse( + response=_READ_RESPONSE, + description="The outline tree, or with view=full the outline page's data.", + ), + 400: _error( + "A query parameter has an invalid value, does not apply to the requested view, or " + "usage_key is not a container in this course's outline." + ), + 401: _error("The requester is not authenticated."), + 403: _error("The requester may not view this course in Studio."), + 404: _error("The course does not exist."), + }, + examples=[MINIMAL_VIEW_EXAMPLE, FULL_VIEW_EXAMPLE], + ) + def retrieve(self, request, course_key): + """Return the course outline, as a tree or with ``view=full`` as the outline page's data.""" + query = CourseOutlineQuerySerializer(data=request.query_params, context={"course_key": course_key}) + query.is_valid(raise_exception=True) + params = query.validated_data + if params["view"] == OUTLINE_FULL_VIEW: + outline = get_full_outline(request, course_key, show=params.get("show")) + return Response(CourseOutlineFullSerializer(outline).data) + tree = get_outline_tree(request, course_key, params["depth"], usage_key=params.get("usage_key")) + return Response(self.get_serializer(tree).data) diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/course_settings.py b/cms/djangoapps/contentstore/rest_api/v2/views/course_settings.py new file mode 100644 index 000000000000..b373dae15309 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/views/course_settings.py @@ -0,0 +1,157 @@ +"""API views for a course's Studio settings.""" + +from drf_spectacular.utils import OpenApiExample, OpenApiParameter, OpenApiResponse, extend_schema +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from edx_rest_framework_extensions.errors import ErrorResponseSerializer +from edx_rest_framework_extensions.mixins import StandardizedErrorMixin +from edx_rest_framework_extensions.url_converters import CourseKeyConverter +from rest_framework import viewsets +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from cms.djangoapps.contentstore.rest_api.v2.course_settings_service import get_course_settings_data +from cms.djangoapps.contentstore.rest_api.v2.serializers.course_settings import ( + FULL_VIEW, + MINIMAL_VIEW, + CourseSettingsQuerySerializer, + CourseSettingsSerializer, +) +from cms.djangoapps.contentstore.views.permissions import CanViewCourse + +_COURSE_KEY_PARAMETER = OpenApiParameter( + name="course_key", + description="Key of the course, such as course-v1:edX+DemoX+2026.", + location=OpenApiParameter.PATH, + type=str, + pattern=f"^{CourseKeyConverter.regex}$", +) + +_VIEW_PARAMETER = OpenApiParameter( + name="view", + description=CourseSettingsQuerySerializer().fields["view"].help_text, + required=False, + location=OpenApiParameter.QUERY, + type=str, + enum=[MINIMAL_VIEW, FULL_VIEW], + default=MINIMAL_VIEW, +) + + +def _error(description): + return OpenApiResponse(response=ErrorResponseSerializer, description=description) + + +_SETTINGS = { + "about_page_editable": True, + "can_show_certificate_available_date_field": False, + "content_experiments_enabled": False, + "course_display_name": "Demonstration Course", + "course_display_name_with_default": "Demonstration Course", + "credit_eligibility_enabled": True, + "enable_extended_course_details": False, + "enrollment_end_editable": True, + "is_credit_course": False, + "is_entrance_exams_enabled": True, + "is_prerequisite_courses_enabled": True, + "licensing_enabled": False, + "lms_link_for_about_page": "https://lms.example.com/courses/course-v1:edX+DemoX+2026/about", + "marketing_enabled": True, + "mfe_proctored_exam_settings_url": "", + "platform_name": "Example Site", + "short_description_editable": True, + "show_min_grade_warning": False, + "sidebar_html_enabled": False, + "upgrade_deadline": None, +} + +_MINIMAL_EXAMPLE = OpenApiExample( + "Course settings", + summary="The default view", + response_only=True, + status_codes=["200"], + value=_SETTINGS, +) + +_FULL_EXAMPLE = OpenApiExample( + "Course settings with lists", + summary="view=full, the language list cut to two entries", + response_only=True, + status_codes=["200"], + value={ + **_SETTINGS, + "language_options": [["en", "English"], ["fr", "French"]], + "possible_pre_requisite_courses": [ + { + "course_key": "course-v1:edX+Intro+2025", + "display_name": "Introduction", + "lms_link": "https://lms.example.com/courses/course-v1:edX+Intro+2025/jump_to/" + "block-v1:edX+Intro+2025+type@course+block@course", + "number": "Intro", + "org": "edX", + "rerun_link": "/course_rerun/course-v1:edX+Intro+2025", + "run": "2025", + "url": "/course/course-v1:edX+Intro+2025", + }, + ], + }, +) + + +@extend_schema(tags=["openedx-platform-sdk"]) +class CourseSettingsViewSet(StandardizedErrorMixin, viewsets.ViewSet): + """ + What Studio's course settings pages need to know about one course and the + site: which settings may be edited, which features are on, and the links + and names shown beside them. + + By default (``view=minimal``) the switches and values are returned; with + ``view=full`` the language list, the courses that may be set as + prerequisites and the course's credit requirements are added. The + prerequisite list does not depend on any query parameter. + + Callable by anyone who may view the course in Studio. The course is looked + up first, so a signed-in caller learns that a course does not exist (404) + before being refused (403). + + A plain ``ViewSet``: the settings come from the course block in the + modulestore, site configuration and several feature modules, not from + database rows of one model. + """ + + # An inactive account's browser session stays authenticated and the course permission decides: + # staff status admits it; its roles count only on courses where policy-based permissions are on. + authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) + permission_classes = (IsAuthenticated, CanViewCourse) + serializer_class = CourseSettingsSerializer + + def get_serializer(self, *args, **kwargs): + """Instantiate the settings serializer declared on this viewset.""" + return self.serializer_class(*args, **kwargs) + + @extend_schema( + summary="Get a course's Studio settings", + description=( + "Returns what the Studio settings pages need: whether the about page, enrollment end date and " + "short description are edited in Studio, which site features are on (credit, entrance exams, " + "prerequisite courses, licensing, content experiments), the course's name, about page address, " + "proctored exam settings path and upgrade deadline. view=full adds the language list, the " + "courses the caller may set as prerequisites (while prerequisite courses are on) and the " + "course's credit requirements (for a credit course while credit eligibility is on)." + ), + parameters=[_COURSE_KEY_PARAMETER, _VIEW_PARAMETER], + responses={ + 200: OpenApiResponse(response=CourseSettingsSerializer, description="The course's Studio settings."), + 400: _error("A query parameter has an invalid value."), + 401: _error("The requester is not authenticated."), + 403: _error("The requester may not view this course in Studio."), + 404: _error("The course does not exist."), + }, + examples=[_MINIMAL_EXAMPLE, _FULL_EXAMPLE], + ) + def retrieve(self, request, course_key): + """Return the course's settings, with the lists and credit requirements when ``view=full``.""" + query = CourseSettingsQuerySerializer(data=request.query_params) + query.is_valid(raise_exception=True) + full = query.validated_data["view"] == FULL_VIEW + return Response(self.get_serializer(get_course_settings_data(request, course_key, full=full)).data) diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/rerun_defaults.py b/cms/djangoapps/contentstore/rest_api/v2/views/rerun_defaults.py new file mode 100644 index 000000000000..df92ac1ccd0e --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/views/rerun_defaults.py @@ -0,0 +1,93 @@ +"""API views for the values a rerun of a course starts from.""" + +from drf_spectacular.utils import OpenApiExample, OpenApiParameter, OpenApiResponse, extend_schema +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from edx_rest_framework_extensions.errors import ErrorResponseSerializer +from edx_rest_framework_extensions.mixins import StandardizedErrorMixin +from edx_rest_framework_extensions.url_converters import CourseKeyConverter +from rest_framework import viewsets +from rest_framework.permissions import IsAdminUser, IsAuthenticated +from rest_framework.response import Response + +from cms.djangoapps.contentstore.rest_api.v2.rerun_defaults_service import get_rerun_defaults +from cms.djangoapps.contentstore.rest_api.v2.serializers.rerun_defaults import CourseRerunDefaultsSerializer +from cms.djangoapps.contentstore.views.permissions import IsExistingCourse + +_COURSE_KEY_PARAMETER = OpenApiParameter( + name="course_key", + description="Key of the course, such as course-v1:edX+DemoX+2026.", + location=OpenApiParameter.PATH, + type=str, + pattern=f"^{CourseKeyConverter.regex}$", +) + + +def _error(description): + return OpenApiResponse(response=ErrorResponseSerializer, description=description) + + +_DEFAULTS_EXAMPLE = OpenApiExample( + "Rerun defaults", + summary="The values a rerun of a course starts from", + response_only=True, + status_codes=["200"], + value={ + "allow_unicode_course_id": False, + "course_creator_status": "granted", + "display_name": "Demonstration Course", + "number": "DemoX", + "org": "edX", + "run": "2026", + }, +) + + +@extend_schema(tags=["openedx-platform-sdk"]) +class CourseRerunDefaultsViewSet(StandardizedErrorMixin, viewsets.ViewSet): + """ + The values Studio fills in when an author starts a rerun of one course: + the course's organization, number, run and name, and whether the site and + the caller allow a course to be created. + + Only accounts with staff status may read them, which every saved superuser + has; course and organization roles do not count. The course is looked up + first, so a signed-in caller learns that a course does not exist (404) + before being refused (403). + + A plain ``ViewSet``: the values come from the course block in the + modulestore and from site settings, not from database rows. + """ + + # An inactive account's browser session stays authenticated, so global staff whose account is + # inactive may still read the values. + authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) + permission_classes = (IsAuthenticated, IsExistingCourse, IsAdminUser) + serializer_class = CourseRerunDefaultsSerializer + + def get_serializer(self, *args, **kwargs): + """Instantiate the rerun defaults serializer declared on this viewset.""" + return self.serializer_class(*args, **kwargs) + + @extend_schema( + summary="Get the values a rerun of a course starts from", + description=( + "Returns the organization, number, run and name of the course, which a rerun starts from, " + "with whether this site accepts characters outside ASCII in a course key and whether the " + "caller may create courses. Only global staff may call it." + ), + parameters=[_COURSE_KEY_PARAMETER], + responses={ + 200: OpenApiResponse( + response=CourseRerunDefaultsSerializer, + description="The values a rerun of the course starts from.", + ), + 401: _error("The requester is not authenticated."), + 403: _error("The requester is not global staff."), + 404: _error("The course does not exist."), + }, + examples=[_DEFAULTS_EXAMPLE], + ) + def retrieve(self, request, course_key): + """Return the values a rerun of the course starts from.""" + return Response(self.get_serializer(get_rerun_defaults(course_key, request.user)).data) diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_course_outline.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_course_outline.py new file mode 100644 index 000000000000..ef7f8c580492 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_course_outline.py @@ -0,0 +1,1765 @@ +"""Tests for the v2 course outline API.""" + +import re +from contextlib import contextmanager +from datetime import UTC, datetime +from functools import cache +from importlib import import_module +from unittest.mock import patch +from urllib.parse import parse_qs, urlencode, urlsplit + +import ddt +import jsonschema +import pymongo +import pytest +from django.conf import settings +from django.db import connection +from django.db.models.signals import post_save +from django.test import RequestFactory +from django.test.utils import CaptureQueriesContext, override_settings +from django.urls import Resolver404, include, path, resolve, reverse +from drf_spectacular.generators import SchemaGenerator +from edx_rest_framework_extensions.testing import assert_error_envelope +from edx_toggles.toggles.testutils import override_waffle_flag +from opaque_keys.edx.keys import UsageKey +from openedx_authz.constants.roles import ( + COURSE_ADMIN, + COURSE_AUDITOR, + COURSE_BETA_TESTER, + COURSE_DATA_RESEARCHER, + COURSE_EDITOR, + COURSE_LIMITED_STAFF, + COURSE_STAFF, +) +from openedx_authz.models.engine import PolicyCacheControl +from openedx_events.content_authoring.signals import XBLOCK_UPDATED +from rest_framework.renderers import JSONRenderer +from rest_framework.test import APIClient + +from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES +from cms.djangoapps.contentstore.rest_api.serializers.common import ProctoringErrorListSerializer +from cms.djangoapps.contentstore.rest_api.v1.views.course_index import CourseIndexView +from cms.djangoapps.contentstore.rest_api.v2.course_outline_service import get_full_outline +from cms.djangoapps.contentstore.rest_api.v2.serializers.course_outline import CourseOutlineProctoringErrorSerializer +from cms.djangoapps.contentstore.rest_api.v2.views.course_outline import CourseOutlineViewSet +from cms.djangoapps.contentstore.tests.utils import routed_actions +from cms.djangoapps.contentstore.utils import get_pages_and_resources_url +from cms.djangoapps.models.settings.course_metadata import CourseMetadata +from common.djangoapps.course_action_state.models import CourseRerunState +from common.djangoapps.student.models import CourseAccessRole +from common.djangoapps.student.roles import ( + CourseInstructorRole, + CourseLimitedStaffRole, + CourseStaffRole, + OrgInstructorRole, + OrgStaffRole, +) +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core import toggles as core_toggles +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.content_staging import api as content_staging_api +from openedx.core.djangoapps.content_tagging import api as tagging_api +from openedx.core.djangoapps.content_tagging.toggles import DISABLE_TAGGING_FEATURE +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory +from xmodule.course_block import DEFAULT_START_DATE +from xmodule.modulestore.django import SignalHandler, modulestore +from xmodule.modulestore.mixed import MixedModuleStore +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, LibraryFactory + +UNKNOWN_COURSE_KEY = "course-v1:NoSuchOrg+NoSuchCourse+NoSuchRun" +VERSION_GUID = "0123456789abcdef01234567" +COURSE_NOT_FOUND = "The requested course does not exist." +NOT_AN_OUTLINE_CONTAINER = "No container in this course's outline has this key." +VIEWS = ("default", "full") +PRE_1900 = datetime(1800, 1, 1, tzinfo=UTC) + + +def outline_url(course_key): + """Return the address of the outline of ``course_key``.""" + return reverse("authoring_v2:course_outline", kwargs={"course_key": course_key}) + + +def v1_url(course_key): + """Return the deprecated course index address of ``course_key``.""" + return reverse("cms.djangoapps.contentstore:v1:course_index", kwargs={"course_id": str(course_key)}) + + +def _client(user=None): + """An API client that returns server errors as responses, authenticated as ``user`` if given.""" + client = APIClient() + client.raise_request_exception = False + if user is not None: + client.force_authenticate(user=user) + return client + + +def _session_client(user): + """A client signed in to a browser session as ``user``.""" + user.set_password("password") + user.save() + client = APIClient() + assert client.login(username=user.username, password="password") + return client + + +def _params(view): + return {"view": "full"} if view == "full" else {} + + +def build_course(sections=2, subsections=2, units=2, **course_kwargs): + """ + Create a course holding ``sections`` sections of ``subsections`` subsections of + ``units`` units, each unit holding one problem. Return the course as stored. + """ + course = CourseFactory.create(**course_kwargs) + # Each block is created under its parent's location, so the factory reads the + # parent afresh rather than writing back a copy that misses earlier siblings. + for s in range(sections): + section = BlockFactory.create( + parent_location=course.location, category="chapter", display_name=f"Section {s}", + ).location + for ss in range(subsections): + subsection = BlockFactory.create( + parent_location=section, category="sequential", display_name=f"Subsection {s}.{ss}", + ).location + for u in range(units): + unit = BlockFactory.create( + parent_location=subsection, category="vertical", display_name=f"Unit {s}.{ss}.{u}", + ).location + BlockFactory.create(parent_location=unit, category="problem", display_name=f"Problem {s}.{ss}.{u}") + return modulestore().get_course(course.id) + + +def outline_blocks(course_key): + """``(id, category)`` of every outline block of the course in pre-order, down to units.""" + store = modulestore() + blocks = [] + + def walk(block): + blocks.append((str(block.location), block.category)) + if block.has_children and block.category != "vertical": + for child in block.get_children(): + walk(child) + + walk(store.get_course(course_key)) + return blocks + + +def full_structure_ids(node): + """Pre-order ids of a full-view ``course_structure`` node and its descendants.""" + ids = [node["id"]] + for child in node.get("child_info", {}).get("children", []): + ids.extend(full_structure_ids(child)) + return ids + + +def tree_ids(node): + """Pre-order ids of a default-view node and the descendants it holds.""" + ids = [node["id"]] + for child in node.get("children", []): + ids.extend(tree_ids(child)) + return ids + + +def tree_nodes(node): + """Every default-view node of ``node``'s tree, in pre-order.""" + nodes = [node] + for child in node.get("children", []): + nodes.extend(tree_nodes(child)) + return nodes + + +def structure_nodes(node): + """Every full-view ``course_structure`` node, in pre-order.""" + nodes = [node] + for child in node.get("child_info", {}).get("children", []): + nodes.extend(structure_nodes(child)) + return nodes + + +# --------------------------------------------------------------------------- +# Schema +# --------------------------------------------------------------------------- + +def _as_json_schema(node): + """An OpenAPI 3.0 schema as JSON Schema: ``nullable`` becomes a null alternative.""" + if isinstance(node, list): + return [_as_json_schema(item) for item in node] + if not isinstance(node, dict): + return node + converted = {key: _as_json_schema(value) for key, value in node.items() if key != "nullable"} + if node.get("nullable"): + return {"anyOf": [converted, {"type": "null"}]} + return converted + + +@cache +def _published_schema(): + """The schema the outline routes publish, generated from the real URLconfs.""" + # Importing the service URLconf registers the course key converter the authoring routes use. + import_module(settings.ROOT_URLCONF) + patterns = [ + path("api/contentstore/", include("cms.djangoapps.contentstore.rest_api.urls")), + path("api/authoring/v2/", include("cms.djangoapps.contentstore.rest_api.v2.authoring_urls")), + ] + return SchemaGenerator(patterns=patterns).get_schema(request=None, public=True) + + +DEFAULT_SHAPE = "CourseOutline" +FULL_SHAPE = "CourseOutlineFull" +READ_SHAPE = "CourseOutlineRead" + + +def assert_matches_published_schema(body, component): + """Assert ``body`` is valid against the published schema component ``component``.""" + components = _as_json_schema(_published_schema()["components"]["schemas"]) + jsonschema.validate(body, {"$ref": f"#/components/schemas/{component}", "components": {"schemas": components}}) + + +def assert_matches_one_read_shape(body, shape): + """Assert ``body`` matches the read response, and of its two shapes exactly ``shape``.""" + assert_matches_published_schema(body, READ_SHAPE) + assert_matches_published_schema(body, shape) + other = FULL_SHAPE if shape == DEFAULT_SHAPE else DEFAULT_SHAPE + with pytest.raises(jsonschema.ValidationError): + assert_matches_published_schema(body, other) + + +# --------------------------------------------------------------------------- +# Side-effect recording +# --------------------------------------------------------------------------- + +@contextmanager +def count_mongo_calls(): + """Count Mongo reads and writes made inside the block.""" + reads = ("find", "find_one", "count_documents", "aggregate") + writes = ("insert_one", "insert_many", "replace_one", "update_one", "update_many", "bulk_write", + "delete_one", "delete_many", "find_one_and_update", "find_one_and_replace", "find_one_and_delete") + counts = {"reads": 0, "writes": 0} + + def counting(kind, original): + def wrapper(self, *args, **kwargs): + counts[kind] += 1 + return original(self, *args, **kwargs) + return wrapper + + collection = pymongo.collection.Collection + with patch.multiple(collection, **{ + name: counting("reads" if name in reads else "writes", getattr(collection, name)) for name in reads + writes + }): + yield counts + + +def sql_writes(queries): + """``(statement, table)`` for each INSERT, UPDATE and DELETE among the captured ``queries``.""" + writes = [] + for query in queries.captured_queries: + words = query["sql"].split() + statement = words[0].upper() + if statement in ("INSERT", "DELETE"): + writes.append((statement, words[2].strip('"`'))) + elif statement == "UPDATE": + writes.append((statement, words[1].strip('"`'))) + return writes + + +@contextmanager +def record_effects(test_case): + """ + Record, inside the block, SQL writes, Mongo writes, modulestore updates, + model saves, xblock-updated events and course-published signals. Commit + callbacks registered inside the block are run, so events sent on commit + are seen. + """ + effects = {"saves": [], "xblock_updated": [], "published": [], "update_item": []} + update_item = MixedModuleStore.update_item + + def spy_update_item(store, xblock, *args, **kwargs): + effects["update_item"].append(str(xblock.location)) + return update_item(store, xblock, *args, **kwargs) + + def on_save(sender, **kwargs): # pylint: disable=unused-argument + effects["saves"].append(sender.__name__) + + def on_xblock_updated(**kwargs): + effects["xblock_updated"].append(str(kwargs["xblock_info"].usage_key)) + + def on_published(sender, **kwargs): # pylint: disable=unused-argument + effects["published"].append(kwargs) + + post_save.connect(on_save, weak=False) + XBLOCK_UPDATED.connect(on_xblock_updated, weak=False) + SignalHandler.course_published.connect(on_published, weak=False) + try: + with test_case.captureOnCommitCallbacks(execute=True), \ + CaptureQueriesContext(connection) as queries, \ + count_mongo_calls() as mongo, \ + patch.object(MixedModuleStore, "update_item", spy_update_item): + yield effects + effects["sql_writes"] = sql_writes(queries) + effects["mongo_writes"] = mongo["writes"] + finally: + post_save.disconnect(on_save) + XBLOCK_UPDATED.disconnect(on_xblock_updated) + SignalHandler.course_published.disconnect(on_published) + + +# --------------------------------------------------------------------------- +# Who may call the endpoint +# --------------------------------------------------------------------------- + +def assert_inactive_jwt_reads(user, course_key, view): + """Deactivate ``user``; assert a JWT for it reads both views of v2 and the v1 read.""" + user.is_active = False + user.save() + header = {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(user)}"} + response = APIClient().get(outline_url(course_key), _params(view), **header) + assert response.status_code == 200, response.content + assert_matches_one_read_shape(response.json(), FULL_SHAPE if view == "full" else DEFAULT_SHAPE) + assert APIClient().get(v1_url(course_key), **header).status_code == 200 + user.refresh_from_db() + assert user.is_active is False + + +class _AccessMatrixMixin: + """Checks a caller's status on both views against the expected one and the v1 decision.""" + + def _call(self, user, view, course_key): + return _client(user).get(outline_url(course_key), _params(view)) + + def assert_access(self, user, view, expected, course_key=None): + """Assert the status, the error envelope when refused, and agreement with v1.""" + course_key = course_key or self.course.id + response = self._call(user, view, course_key) + v1_status = _client(user).get(v1_url(course_key)).status_code + assert response.status_code == expected, response.content + if expected != 200: + slug = {401: "authn", 403: "authz", 404: "not-found"}[expected] + assert_error_envelope(response, expected_status=expected, expected_type_slug=slug) + else: + assert_matches_one_read_shape(response.json(), FULL_SHAPE if view == "full" else DEFAULT_SHAPE) + assert v1_status == expected, f"v1 decided {v1_status} for the same caller" + + +@ddt.ddt +class OutlineAccessLegacyPermissionsTest(_AccessMatrixMixin, SharedModuleStoreTestCase): + """Who may read the outline when policy-based authoring permissions are off.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = build_course(sections=1, subsections=1, units=1) + cls.other_course = build_course(sections=1, subsections=1, units=1) + + def _user_with(self, role): + user = UserFactory() + role.add_users(user) + return user + + @ddt.data(*VIEWS) + def test_anonymous_caller_is_refused_with_401(self, view): + self.assert_access(None, view, 401) + + @ddt.data(*VIEWS) + def test_user_without_a_role_is_refused_with_403(self, view): + self.assert_access(UserFactory(), view, 403) + + @ddt.data(*VIEWS) + def test_unknown_course_is_404_before_permission_is_checked(self, view): + self.assert_access(UserFactory(), view, 404, course_key=UNKNOWN_COURSE_KEY) + + @ddt.data(*VIEWS) + def test_anonymous_caller_of_an_unknown_course_is_refused_with_401(self, view): + self.assert_access(None, view, 401, course_key=UNKNOWN_COURSE_KEY) + + @ddt.data(*VIEWS) + def test_limited_staff_is_refused(self, view): + self.assert_access(self._user_with(CourseLimitedStaffRole(self.course.id)), view, 403) + + @ddt.data(*VIEWS) + def test_staff_of_another_course_is_refused(self, view): + self.assert_access(self._user_with(CourseStaffRole(self.other_course.id)), view, 403) + + @ddt.data(*VIEWS) + def test_course_staff_may_read(self, view): + self.assert_access(self._user_with(CourseStaffRole(self.course.id)), view, 200) + + @ddt.data(*VIEWS) + def test_course_instructor_may_read(self, view): + self.assert_access(self._user_with(CourseInstructorRole(self.course.id)), view, 200) + + @ddt.data(*VIEWS) + def test_org_staff_and_org_instructor_may_read(self, view): + self.assert_access(self._user_with(OrgStaffRole(self.course.id.org)), view, 200) + self.assert_access(self._user_with(OrgInstructorRole(self.course.id.org)), view, 200) + + @ddt.data(*VIEWS) + def test_global_staff_and_superuser_may_read(self, view): + self.assert_access(UserFactory(is_staff=True), view, 200) + self.assert_access(UserFactory(is_superuser=True), view, 200) + + @ddt.data(*VIEWS) + def test_an_inactive_session_is_not_authenticated(self, view): + """ + A session whose account is not activated is refused with 401, global + staff included. v1 authenticates such a session and decides access + as for an active account, so it admits inactive global staff and + refuses inactive course staff, whose course role does not count while + inactive. + """ + for user, v1_expected in ( + (UserFactory(is_staff=True), 200), + (self._user_with(CourseStaffRole(self.course.id)), 403), + ): + client = _session_client(user) + assert client.get(outline_url(self.course.id), _params(view)).status_code == 200, "active control" + user.is_active = False + user.save() + response = client.get(outline_url(self.course.id), _params(view)) + assert_error_envelope(response, expected_status=401, expected_type_slug="authn") + assert client.get(v1_url(self.course.id)).status_code == v1_expected + + @ddt.data(*VIEWS) + def test_jwt_caller_may_read(self, view): + user = UserFactory() + CourseStaffRole(self.course.id).add_users(user) + response = APIClient().get( + outline_url(self.course.id), _params(view), HTTP_AUTHORIZATION=f"JWT {create_jwt_for_user(user)}", + ) + assert response.status_code == 200 + + @ddt.data(*VIEWS) + def test_an_inactive_jwt_caller_who_is_global_staff_may_read(self, view): + """A JWT is not checked for an activated account, on v2 as on v1.""" + assert_inactive_jwt_reads(UserFactory(is_staff=True), self.course.id, view) + + @ddt.data(*VIEWS) + def test_bearer_token_is_refused(self, view): + """Bearer tokens are not accepted here, though the v1 read takes them.""" + user = UserFactory(is_staff=True) + token = AccessTokenFactory(user=user, application=ApplicationFactory(user=user)).token + response = APIClient().get(outline_url(self.course.id), _params(view), HTTP_AUTHORIZATION=f"Bearer {token}") + assert_error_envelope(response, expected_status=401, expected_type_slug="authn") + assert APIClient().get(v1_url(self.course.id), HTTP_AUTHORIZATION=f"Bearer {token}").status_code == 200 + + def test_a_subtree_of_another_course_is_refused_without_naming_it(self): + """A caller allowed on one course cannot read another course's blocks through it.""" + user = self._user_with(CourseStaffRole(self.course.id)) + other_section = modulestore().get_course(self.other_course.id).get_children()[0] + response = _client(user).get(outline_url(self.course.id), {"usage_key": str(other_section.location)}) + envelope = assert_error_envelope(response, expected_status=400, expected_type_slug="validation") + assert envelope["errors"] == {"usage_key": [NOT_AN_OUTLINE_CONTAINER]} + body = response.content.decode() + assert str(self.other_course.id.course) not in body + assert other_section.location.block_id not in body + # The same caller is refused the other course outright. + assert _client(user).get(outline_url(self.other_course.id)).status_code == 403 + + +@ddt.ddt +class OutlineAccessAuthzTest(CourseAuthoringAuthzTestMixin, _AccessMatrixMixin, SharedModuleStoreTestCase): + """Who may read the outline when policy-based authoring permissions are on.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = build_course(sections=1, subsections=1, units=1) + cls.other_course = build_course(sections=1, subsections=1, units=1) + + def _user_with(self, role, course_key=None): + user = UserFactory() + self.add_user_to_role_in_course(user, role, course_key or self.course.id) + return user + + @ddt.data(*VIEWS) + def test_anonymous_caller_is_refused_with_401(self, view): + self.assert_access(None, view, 401) + + @ddt.data(*VIEWS) + def test_user_without_a_role_is_refused_with_403(self, view): + self.assert_access(self.unauthorized_user, view, 403) + + @ddt.data(*[(view, role) for view in VIEWS for role in (COURSE_AUDITOR.external_key, COURSE_ADMIN.external_key)]) + @ddt.unpack + def test_a_role_on_another_course_is_refused(self, view, role): + user = self._user_with(role, self.other_course.id) + self.assert_access(user, view, 200, course_key=self.other_course.id) + self.assert_access(user, view, 403) + + @ddt.data(*VIEWS) + def test_unknown_course_is_404_before_permission_is_checked(self, view): + self.assert_access(self.unauthorized_user, view, 404, course_key=UNKNOWN_COURSE_KEY) + + @ddt.data(*[(view, role) for view in VIEWS for role in ( + COURSE_AUDITOR.external_key, COURSE_EDITOR.external_key, COURSE_STAFF.external_key, COURSE_ADMIN.external_key, + )]) + @ddt.unpack + def test_roles_that_may_view_the_course_may_read(self, view, role): + self.assert_access(self._user_with(role), view, 200) + + @ddt.data(*[(view, role) for view in VIEWS for role in ( + COURSE_LIMITED_STAFF.external_key, COURSE_DATA_RESEARCHER.external_key, COURSE_BETA_TESTER.external_key, + )]) + @ddt.unpack + def test_other_roles_are_refused(self, view, role): + self.assert_access(self._user_with(role), view, 403) + + @ddt.data(*VIEWS) + def test_legacy_course_staff_without_a_policy_role_is_refused(self, view): + user = UserFactory() + CourseAccessRole.objects.create(user=user, role="staff", course_id=self.course.id, org=self.course.id.org) + self.assert_access(user, view, 403) + + @ddt.data(*VIEWS) + def test_an_inactive_session_with_a_role_is_not_authenticated(self, view): + """v1 admits it, as the permission policy does not look at whether the account is activated.""" + user = self._user_with(COURSE_AUDITOR.external_key) + client = _session_client(user) + assert client.get(outline_url(self.course.id), _params(view)).status_code == 200, "active control" + user.is_active = False + user.save() + response = client.get(outline_url(self.course.id), _params(view)) + assert_error_envelope(response, expected_status=401, expected_type_slug="authn") + assert client.get(v1_url(self.course.id)).status_code == 200 + + @ddt.data(*VIEWS) + def test_an_inactive_jwt_caller_with_a_role_may_read(self, view): + """A JWT is not checked for an activated account, on v2 as on v1.""" + user = self._user_with(COURSE_AUDITOR.external_key) + assert_inactive_jwt_reads(user, self.course.id, view) + + @ddt.data(*VIEWS) + def test_staff_and_superuser_may_read_without_a_role(self, view): + self.assert_access(self.staff_user, view, 200) + self.assert_access(self.super_user, view, 200) + + +# --------------------------------------------------------------------------- +# The default view +# --------------------------------------------------------------------------- + +@ddt.ddt +class OutlineDefaultViewTest(ModuleStoreTestCase): + """The outline tree, its depth cap and the child addresses.""" + + def setUp(self): + super().setUp() + self.reader = UserFactory() + self.course = build_course(sections=2, subsections=2, units=2) + CourseStaffRole(self.course.id).add_users(self.reader) + self.client = _client(self.reader) + + def _read(self, course_key=None, **params): + response = self.client.get(outline_url(course_key or self.course.id), params) + assert response.status_code == 200, response.content + return response.json() + + def _expected_tree(self, block, levels_left): + """The node the outline shows for ``block``, built from the modulestore.""" + node = { + "id": str(block.location), "category": block.category, "display_name": block.display_name_with_default, + } + if block.category == "vertical": + return node + if levels_left > 0: + node["children"] = [self._expected_tree(child, levels_left - 1) for child in block.get_children()] + elif block.children: + query = urlencode({"usage_key": str(block.location), "depth": 1}) + node["children_url"] = f"http://testserver{outline_url(self.course.id)}?{query}" + else: + node["children"] = [] + return node + + @ddt.data(1, 2, 3) + def test_each_depth_cuts_at_its_level(self, depth): + course = modulestore().get_course(self.course.id) + assert self._read(depth=depth) == self._expected_tree(course, depth) + + def test_the_default_depth_is_sections_and_subsections(self): + body = self._read() + assert body == self._read(depth=2) + assert [node["category"] for node in tree_nodes(body)] == [ + "course", "chapter", "sequential", "sequential", "chapter", "sequential", "sequential", + ] + subsections = [node for node in tree_nodes(body) if node["category"] == "sequential"] + assert all("children" not in node and node["children_url"] for node in subsections) + + def test_minimal_view_is_the_default(self): + assert self._read(view="minimal") == self._read() + + def test_children_url_is_on_exactly_the_cut_nodes_with_children(self): + body = self._read(depth=1) + assert "children_url" not in body + sections = body["children"] + assert [("children" in s, "children_url" in s) for s in sections] == [(False, True), (False, True)] + units = [n for n in tree_nodes(self._read(depth=3)) if n["category"] == "vertical"] + assert len(units) == 8 + assert all(set(unit) == {"id", "category", "display_name"} for unit in units) + + def test_following_children_url_returns_that_blocks_children(self): + for depth in (1, 2): + for node in tree_nodes(self._read(depth=depth)): + if "children_url" not in node: + continue + query = parse_qs(urlsplit(node["children_url"]).query) + assert query == {"usage_key": [node["id"]], "depth": ["1"]} + followed = self.client.get(node["children_url"]) + assert followed.status_code == 200, followed.content + block = modulestore().get_item(UsageKey.from_string(node["id"])) + assert followed.json() == self._expected_tree(block, 1) + + def test_the_tree_follows_the_full_outline_order(self): + """The default tree to unit depth lists the same blocks in the same order as the full view.""" + default = self._read(depth=3) + full = self.client.get(outline_url(self.course.id), {"view": "full"}).json() + assert tree_ids(default) == full_structure_ids(full["course_structure"]) + assert tree_ids(default) == [block_id for block_id, _ in outline_blocks(self.course.id)] + + @ddt.data("chapter", "sequential", "vertical") + def test_usage_key_returns_the_tree_under_that_block(self, category): + block_id = next(block_id for block_id, cat in outline_blocks(self.course.id) if cat == category) + block = modulestore().get_item(UsageKey.from_string(block_id)) + assert self._read(usage_key=block_id) == self._expected_tree(block, 2) + assert self._read(usage_key=block_id, depth=1) == self._expected_tree(block, 1) + + def test_empty_course_and_empty_section(self): + empty = CourseFactory.create() + CourseStaffRole(empty.id).add_users(self.reader) + root = self._read(course_key=empty.id) + assert root == { + "id": str(empty.location), "category": "course", "display_name": empty.display_name, "children": [], + } + assert_matches_one_read_shape(root, DEFAULT_SHAPE) + section = BlockFactory.create(parent=empty, category="chapter", display_name="Empty") + tree = self._read(course_key=empty.id, depth=1) + assert tree["children"] == [ + {"id": str(section.location), "category": "chapter", "display_name": "Empty", "children": []}, + ] + assert_matches_one_read_shape(tree, DEFAULT_SHAPE) + + def test_a_component_placed_directly_in_the_outline_is_a_leaf(self): + subsection = modulestore().get_course(self.course.id).get_children()[0].get_children()[0] + problem = BlockFactory.create(parent_location=subsection.location, category="problem", display_name="Loose") + tree = self._read(usage_key=str(subsection.location), depth=1) + assert tree["children"][-1] == {"id": str(problem.location), "category": "problem", "display_name": "Loose"} + assert tree_ids(self._read(depth=3)) == [block_id for block_id, _ in outline_blocks(self.course.id)] + + def test_bodies_match_the_published_shape(self): + for depth in (1, 2, 3): + assert_matches_one_read_shape(self._read(depth=depth), DEFAULT_SHAPE) + + def test_blank_parameters_are_omitted_parameters(self): + default = self._read() + assert self._read(view="", depth="", usage_key="", show="") == default + full = self.client.get(outline_url(self.course.id), {"view": "full", "show": ""}) + assert full.status_code == 200 + assert full.json()["initial_state"] is None + + def test_unknown_parameters_are_ignored(self): + """format=concise, a v1 parameter, is not one of this endpoint's parameters; the tree is returned.""" + assert self._read(format="concise", foo="bar") == self._read() + + @ddt.data( + ({"view": "bogus"}, ["view"]), + ({"depth": "0"}, ["depth"]), + ({"depth": "4"}, ["depth"]), + ({"depth": "x"}, ["depth"]), + ({"depth": "1.5"}, ["depth"]), + ({"view": "full", "depth": "1"}, ["depth"]), + ({"view": "full", "usage_key": "SECTION"}, ["usage_key"]), + ({"view": "full", "depth": "1", "usage_key": "SECTION"}, ["depth", "usage_key"]), + ({"show": "x"}, ["show"]), + ({"view": "minimal", "show": "x"}, ["show"]), + ({"usage_key": "not-a-key"}, ["usage_key"]), + ) + @ddt.unpack + def test_invalid_parameters_are_refused_with_400(self, params, fields): + section = str(modulestore().get_course(self.course.id).get_children()[0].location) + params = {name: section if value == "SECTION" else value for name, value in params.items()} + response = self.client.get(outline_url(self.course.id), params) + envelope = assert_error_envelope(response, expected_status=400, expected_type_slug="validation") + assert sorted(envelope["errors"]) == fields + + def test_usage_keys_that_are_not_an_outline_block_of_this_course_are_refused(self): + course = modulestore().get_course(self.course.id) + problem = next(b for b in modulestore().get_items(self.course.id) if b.category == "problem") + section = course.get_children()[0] + missing = section.location.replace(block_id="missing") + for key in ( + course.location, # the course itself + problem.location, # a component, not a container + missing, # a section that does not exist + "lb:Org:lib:problem:1", # a library block + "i4x://Org/C/chapter/x", # a deprecated key + f"block-v1:version@{VERSION_GUID}+type@chapter+block@x", # a key naming only a version + f"lib-block-v1:version@{VERSION_GUID}+type@problem+block@x", # a library key naming only a version + ): + response = self.client.get(outline_url(self.course.id), {"usage_key": str(key)}) + envelope = assert_error_envelope(response, expected_status=400, expected_type_slug="validation") + assert envelope["errors"] == {"usage_key": [NOT_AN_OUTLINE_CONTAINER]} + assert VERSION_GUID not in response.content.decode() + + def test_head_answers_without_a_body(self): + for params in ({}, {"view": "full"}): + response = self.client.head(outline_url(self.course.id), params) + assert response.status_code == 200 + assert response.content == b"" + + def test_a_course_missing_from_the_modulestore_is_404(self): + """A course known only to its overview record is reported missing, not a server error.""" + with patch.object(CourseOverview, "course_exists", return_value=True): + for params in ({}, {"view": "full"}): + response = _client(UserFactory(is_staff=True)).get(outline_url(UNKNOWN_COURSE_KEY), params) + envelope = assert_error_envelope(response, expected_status=404, expected_type_slug="not-found") + assert envelope["detail"] == COURSE_NOT_FOUND + + +@ddt.ddt +class OutlineOtherContainersTest(ModuleStoreTestCase): + """ + Outlines holding containers other than sections, subsections and units, + and blocks of the course that are not in its outline. + """ + + def setUp(self): + super().setUp() + self.reader = UserFactory() + self.client = _client(self.reader) + course = CourseFactory.create() + CourseStaffRole(course.id).add_users(self.reader) + + def block(parent, category, name): + return BlockFactory.create(parent_location=parent, category=category, display_name=name).location + + section_a = block(course.location, "chapter", "Section A") + subsection = block(section_a, "sequential", "Subsection A1") + block(block(subsection, "vertical", "Unit A1.1"), "problem", "Problem") + block(block(subsection, "conditional", "Conditional in a subsection"), "html", "Shown A") + block(block(subsection, "library_content", "Library content in a subsection"), "problem", "Picked A") + split = block(subsection, "split_test", "Split test in a subsection") + block(block(split, "vertical", "Group 0"), "problem", "Problem 0") + block(block(split, "vertical", "Group 1"), "html", "Text 1") + block(block(section_a, "split_test", "Split test in a section"), "vertical", "Group") + section_b = block(course.location, "chapter", "Section B") + block(block(section_b, "conditional", "Conditional in a section"), "html", "Shown B") + block(block(section_b, "library_content", "Library content in a section"), "problem", "Picked B") + unit = block(block(section_b, "sequential", "Subsection B1"), "vertical", "Unit B1.1") + self.nested_unit = block(unit, "vertical", "Unit inside a unit") + block(self.nested_unit, "problem", "Nested problem") + self.orphan_section = modulestore().create_item(self.user.id, course.id, "chapter", "orphan").location + self.course_key = course.id + + def _read(self, **params): + response = self.client.get(outline_url(self.course_key), params) + assert response.status_code == 200, response.content + return response.json() + + def _full_structure(self): + return self._read(view="full")["course_structure"] + + def _follow(self, node): + """Follow ``node``'s children_url; assert it returns that block with exactly its children.""" + response = self.client.get(node["children_url"]) + assert response.status_code == 200, (node["category"], response.content) + followed = response.json() + block = modulestore().get_item(UsageKey.from_string(node["id"])) + assert {key: followed[key] for key in ("id", "category", "display_name")} == { + key: node[key] for key in ("id", "category", "display_name") + } + assert [(child["id"], child["category"]) for child in followed["children"]] == [ + (str(child), child.block_type) for child in block.children + ] + return followed["children"] + + def _walk(self, **params): + """Ids of every node reached from a read by following each children_url, recursively.""" + seen = [] + + def visit(node): + for each in tree_nodes(node): + seen.append(each["id"]) + if "children_url" in each: + for child in self._follow(each): + visit(child) + + visit(self._read(**params)) + return seen + + def test_the_fixture_cuts_other_containers_at_every_depth(self): + """Each kind of container is cut with a children_url at some depth, so the walks below follow one.""" + cut = { + node["category"] + for depth in (1, 2, 3) + for node in tree_nodes(self._read(depth=depth)) + if "children_url" in node + } + assert cut == {"chapter", "sequential", "conditional", "library_content", "split_test"} + + @ddt.data(1, 2, 3) + def test_every_children_url_from_the_course_is_followable(self, depth): + seen = self._walk(depth=depth) + assert sorted(seen) == sorted(full_structure_ids(self._full_structure())) + + @ddt.data(1, 2, 3) + def test_every_children_url_from_each_reroot_is_followable(self, depth): + structure = {node["id"]: node for node in structure_nodes(self._full_structure())} + containers = [ + block_id for block_id, category in outline_blocks(self.course_key) + if category != "course" and modulestore().get_item(UsageKey.from_string(block_id)).has_children + ] + assert len(containers) == 15 + for block_id in containers: + seen = self._walk(usage_key=block_id, depth=depth) + assert sorted(seen) == sorted(full_structure_ids(structure[block_id])), block_id + + def test_usage_key_accepts_exactly_the_containers_in_the_outline(self): + """Every block of the course is tried; only containers the outline walk reaches are accepted.""" + outline = {block_id for block_id, _ in outline_blocks(self.course_key)} + everything = modulestore().get_items(self.course_key) + assert str(self.orphan_section) in {str(block.location) for block in everything} + accepted = [] + for block in everything: + response = self.client.get(outline_url(self.course_key), {"usage_key": str(block.location)}) + if response.status_code == 200: + assert response.json()["id"] == str(block.location) + accepted.append(str(block.location)) + else: + envelope = assert_error_envelope(response, expected_status=400, expected_type_slug="validation") + assert envelope["errors"] == { + "usage_key": [NOT_AN_OUTLINE_CONTAINER], + } + expected = sorted( + str(block.location) for block in everything + if str(block.location) in outline and block.has_children and block.category != "course" + ) + assert sorted(accepted) == expected + assert len(expected) == 15 + + def test_blocks_outside_the_outline_are_refused(self): + """A section no block links to, and a unit inside a unit, are not in the outline.""" + for key in (self.orphan_section, self.nested_unit): + response = self.client.get(outline_url(self.course_key), {"usage_key": str(key)}) + envelope = assert_error_envelope(response, expected_status=400, expected_type_slug="validation") + assert envelope["errors"] == {"usage_key": [NOT_AN_OUTLINE_CONTAINER]} + assert key.block_id not in response.content.decode() + assert str(self.orphan_section) not in full_structure_ids(self._full_structure()) + assert str(self.nested_unit) not in full_structure_ids(self._full_structure()) + + +# --------------------------------------------------------------------------- +# The full view against v1 +# --------------------------------------------------------------------------- + +PRE_1900_DATES = "a start date before 1900" + +# A course_structure node at any depth. +_NODE = r"course_structure(\.child_info\.children\[\d+\])*" + +# Differences between the v1 read and view=full: (json path pattern, the +# condition under which it occurs, or None when it always does, the reason). +# The comparison asserts that each difference whose condition holds occurs and +# that nothing else differs. +INTENTIONAL_DIFFERENCES = [ + (r"initial_user_clipboard([.\[].*)?", None, + "the clipboard is its own resource at /api/content-staging/v1/clipboard/"), + (r"course_release_date", PRE_1900_DATES, + "v1 stores the unset start date over a date before 1900 and reports 'Set Date'; view=full reports the date"), + (rf"{_NODE}\.start", PRE_1900_DATES, "view=full reports the stored start date, v1 the unset date it stores"), + (rf"{_NODE}\.release_date", PRE_1900_DATES, "the release date is formatted from the start date"), + (rf"{_NODE}\.released_to_students", PRE_1900_DATES, + "a block dated 1800 has been released; one dated with the unset date has not"), + (rf"{_NODE}\.visibility_state", PRE_1900_DATES, "it follows whether the block has been released"), +] + +_ABSENT = object() + + +def _flatten(obj, prefix=""): + """ + A ``{json_path: value}`` map of every leaf of ``obj``. An empty object or + array is a leaf, so a container that empties or changes kind is seen. + """ + flat = {} + if isinstance(obj, dict) and obj: + for key in sorted(obj): + flat.update(_flatten(obj[key], f"{prefix}.{key}" if prefix else key)) + elif isinstance(obj, list) and obj: + for index, item in enumerate(obj): + flat.update(_flatten(item, f"{prefix}[{index}]")) + else: + flat[prefix] = obj + return flat + + +def _same(a, b): + """Whether two leaves are equal and of the same JSON type: true is not 1, 1.0 is not 1, {} is not [].""" + return type(a) is type(b) and a == b + + +def _differences(legacy, new, conditions=frozenset()): + """ + Compare two bodies under ``conditions``. Return the json paths that + differ, those among them no applicable declared difference explains, and + the applicable declared differences that did not occur. + """ + a, b = _flatten(legacy), _flatten(new) + changed = {key for key in set(a) | set(b) if not _same(a.get(key, _ABSENT), b.get(key, _ABSENT))} + applicable = [pattern for pattern, when, _ in INTENTIONAL_DIFFERENCES if when is None or when in conditions] + unexpected = {key for key in changed if not any(re.fullmatch(pattern, key) for pattern in applicable)} + missing = {pattern for pattern in applicable if not any(re.fullmatch(pattern, key) for key in changed)} + return changed, unexpected, missing + + +def test_the_comparison_sees_types_and_empty_containers(): + assert _differences({"a": {"b": []}}, {"a": {}})[0] == {"a", "a.b"} + assert _differences({"x": True}, {"x": 1})[0] == {"x"} + assert _differences({"x": 1.0}, {"x": 1})[0] == {"x"} + assert _differences({"x": {}}, {"x": []})[0] == {"x"} + assert _differences({"x": None, "y": 1}, {"y": 1})[0] == {"x"} + assert _differences({"x": [1, {"y": False}]}, {"x": [1, {"y": False}]})[0] == set() + + +def test_a_declared_difference_is_allowed_only_when_its_condition_holds(): + legacy = {"course_release_date": "Set Date", "initial_user_clipboard": {}} + new = {"course_release_date": "Jan 01, 1800 at 00:00 UTC"} + _, unexpected, missing = _differences(legacy, new) + assert unexpected == {"course_release_date"} + assert missing == set() + _, unexpected, missing = _differences(legacy, new, {PRE_1900_DATES}) + assert unexpected == set() + assert missing == {pattern for pattern, when, _ in INTENTIONAL_DIFFERENCES if when == PRE_1900_DATES} - { + "course_release_date", + } + + +class _FullViewParityMixin: + """Compares view=full against the v1 read of ``self.course``.""" + + def _pair(self, user, **params): + """ + The v1 read and the view=full read of the course by ``user``, as JSON. + view=full is read first, so nothing the v1 read stores can reach it. + """ + client = _client(user) + new = client.get(outline_url(self.course.id), {"view": "full", **params}) + legacy = client.get(v1_url(self.course.id), params) + assert legacy.status_code == new.status_code == 200, (legacy.content, new.content) + return legacy.json(), new.json() + + def compare(self, user, conditions=frozenset(), **params): + """ + Assert that the two reads differ exactly by the declared differences + that apply under ``conditions``, each of which occurs. Return the v1 + body, the v2 body and the json paths that differ. + """ + legacy, new = self._pair(user, **params) + changed, unexpected, missing = _differences(legacy, new, conditions) + assert unexpected == set(), sorted(unexpected) + assert missing == set(), sorted(missing) + assert set(legacy) - set(new) == {"initial_user_clipboard"} + assert set(new) <= set(legacy) + assert_matches_one_read_shape(new, FULL_SHAPE) + return legacy, new, changed + + def assert_parity(self, user, **params): + """Assert parity with no condition-dependent difference and return the v2 body.""" + return self.compare(user, **params)[1] + + +@ddt.ddt +class OutlineFullViewParityTest(_FullViewParityMixin, ModuleStoreTestCase): + """view=full returns what v1 returns for the same caller, except the declared differences.""" + + def setUp(self): + super().setUp() + self.course = build_course(sections=2, subsections=1, units=2) + + def _course_staff(self): + user = UserFactory() + CourseStaffRole(self.course.id).add_users(user) + return user + + @ddt.data(True, False) + def test_plain_course(self, relative_dates): + with override_waffle_flag(CUSTOM_RELATIVE_DATES, active=relative_dates): + body = self.assert_parity(self._course_staff()) + assert body["is_custom_relative_dates_active"] is relative_dates + assert body["created_on"] is None + assert body["rerun_notification_id"] is None + assert body["notification_dismiss_url"] is None + assert body["initial_state"] is None + assert body["reindex_link"] is None + + def test_course_with_a_start_date(self): + self.course = build_course(sections=1, subsections=1, units=1, start=datetime(2021, 3, 4, tzinfo=UTC)) + body = self.assert_parity(self._course_staff()) + assert body["course_release_date"] == "Mar 04, 2021 at 00:00 UTC" + + def test_course_without_a_start_date(self): + self.course = build_course(sections=1, subsections=1, units=1, start=DEFAULT_START_DATE) + assert self.assert_parity(self._course_staff())["course_release_date"] == "Set Date" + + def test_dates_before_1900_are_reported_as_stored(self): + """ + v1 replaces each start date before 1900 with the unset date and reports + that; view=full reports the stored date. + """ + self.course = build_course(sections=1, subsections=1, units=1, start=PRE_1900) + reader = self._course_staff() + legacy, new, changed = self.compare(reader, conditions={PRE_1900_DATES}) + assert new["course_release_date"] == "Jan 01, 1800 at 00:00 UTC" + assert structure_nodes(new["course_structure"])[0]["start"] == "1800-01-01T00:00:00+00:00" + assert legacy["course_release_date"] == "Set Date" + assert {key.rsplit(".", 1)[-1] for key in changed if not key.startswith("initial_user_clipboard")} == { + "course_release_date", "start", "release_date", "released_to_students", "visibility_state", + } + + def test_course_with_an_overview_and_a_rerun_notification(self): + CourseOverview.load_from_module_store(self.course.id) + CourseRerunState.objects.initiated(self.course.id, self.course.id, self.user, "Rerun") + state = CourseRerunState.objects.find_first(course_key=self.course.id) + body = self.assert_parity(self._course_staff()) + created = CourseOverview.objects.get(id=self.course.id).created + assert body["created_on"] == created.isoformat().replace("+00:00", "Z") + assert body["rerun_notification_id"] == state.id + assert body["notification_dismiss_url"] == f"/course_notifications/{self.course.id}/{state.id}" + + @ddt.data("section", "unit", "unknown") + def test_show(self, which): + course = modulestore().get_course(self.course.id) + section = course.get_children()[0] + unit = section.get_children()[0].get_children()[0] + locator = { + "section": str(section.location), + "unit": str(unit.location), + "unknown": str(section.location.replace(block_id="missing")), + }[which] + body = self.assert_parity(self._course_staff(), show=locator) + if which == "unknown": + assert body["initial_state"] is None + elif which == "unit": + assert body["initial_state"] == {"locator_to_show": locator, "expanded_locators": [locator]} + else: + expected = [b for b, _ in outline_blocks(self.course.id)] + start = expected.index(locator) + assert body["initial_state"] == { + "locator_to_show": locator, "expanded_locators": expected[start:start + 4], + } + + def test_empty_show_is_no_show(self): + assert self.assert_parity(self._course_staff(), show="")["initial_state"] is None + + @override_settings(ENABLE_COURSEWARE_INDEX=True) + @override_waffle_flag(core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, active=False) + def test_reindex_link_follows_the_reindex_permission(self): + """Global staff may reindex; course staff may not, while policy-based permissions are off.""" + reindex = f"/course/{self.course.id}/search_reindex" + assert self.assert_parity(UserFactory(is_staff=True))["reindex_link"] == reindex + assert self.assert_parity(self._course_staff())["reindex_link"] is None + with override_settings(ENABLE_COURSEWARE_INDEX=False): + assert self.assert_parity(UserFactory(is_staff=True))["reindex_link"] is None + + @override_settings(LMS_ROOT_URL=None) + def test_lms_link_is_null_without_an_lms_address(self): + assert self.assert_parity(self._course_staff())["lms_link"] is None + + def test_every_node_carries_the_course_tag_counts(self): + taxonomy = tagging_api.create_taxonomy("Topics", allow_free_text=True) + tagging_api.set_taxonomy_orgs(taxonomy, all_orgs=True) + unit = modulestore().get_course(self.course.id).get_children()[0].get_children()[0].get_children()[0] + tagging_api.tag_object(str(unit.location), taxonomy, ["Algebra", "Geometry"]) + body = self.assert_parity(self._course_staff()) + nodes = structure_nodes(body["course_structure"]) + assert len(nodes) == len(outline_blocks(self.course.id)) == 9 + assert {str(node["tag_counts_by_block"]) for node in nodes} == {str({str(unit.location): 2})} + + def test_deprecated_blocks_are_reported(self): + course = modulestore().get_course(self.course.id) + course.advanced_modules = ["poll", "survey"] + modulestore().update_item(course, self.user.id) + unit = course.get_children()[0].get_children()[0].get_children()[0] + BlockFactory.create(parent=unit, category="poll", display_name="Old poll") + with patch("cms.djangoapps.contentstore.rest_api.v2.course_outline_service.deprecated_xblocks") as v2_types, \ + patch("cms.djangoapps.contentstore.utils.deprecated_xblocks") as v1_types: + v2_types.return_value = v1_types.return_value = [type("Deprecated", (), {"name": "poll"})] + body = self.assert_parity(self._course_staff()) + assert body["deprecated_blocks_info"]["deprecated_enabled_block_types"] == ["poll"] + assert [name for _, name in body["deprecated_blocks_info"]["blocks"]] == ["Old poll"] + + @ddt.data("unavailable_provider", "missing_escalation_email") + def test_proctoring_errors_are_reported_as_v1_reports_them(self, problem): + """ + Each kind of stored proctoring problem is reported with the same bytes the + shared proctoring error serializers produce for v1. + """ + course = modulestore().get_course(self.course.id) + course.enable_proctored_exams = True + backends = settings.PROCTORING_BACKENDS + if problem == "unavailable_provider": + available = ["lti_external"] + message = ( + f"The proctoring provider configured for this course, '{course.proctoring_provider}', is not valid." + ) + else: + backends = {"DEFAULT": "proctortrack", "proctortrack": {"requires_escalation_email": True}} + course.proctoring_provider = "proctortrack" + available = ["proctortrack"] + message = "Provider 'proctortrack' requires an exam escalation contact." + modulestore().update_item(course, self.user.id) + reader = self._course_staff() + with override_settings(PROCTORING_BACKENDS=backends), patch( + "cms.djangoapps.models.settings.course_metadata.get_available_providers", return_value=available, + ): + body = self.assert_parity(reader) + course = modulestore().get_course(self.course.id) + stored = CourseMetadata.validate_proctoring_settings(course, CourseMetadata.fetch(course), reader) + assert [(error["key"], error["message"]) for error in body["proctoring_errors"]] == [ + ("proctoring_provider", message), + ] + assert body["proctoring_errors"][0]["model"]["value"] == course.proctoring_provider + shared = JSONRenderer().render(ProctoringErrorListSerializer(stored, many=True).data) + local = JSONRenderer().render(CourseOutlineProctoringErrorSerializer(stored, many=True).data) + assert local == shared + assert JSONRenderer().render(body["proctoring_errors"]) == shared + + def test_empty_course(self): + self.course = CourseFactory.create() + body = self.assert_parity(self._course_staff()) + assert body["course_structure"]["id"] == str(self.course.location) + assert body["course_structure"]["child_info"] == { + "category": "chapter", "display_name": "Section", "children": [], + } + + +@ddt.ddt +@override_settings(ENABLE_COURSEWARE_INDEX=True) +class OutlineFullViewReindexLinkAuthzTest(CourseAuthoringAuthzTestMixin, _FullViewParityMixin, ModuleStoreTestCase): + """ + With policy-based authoring permissions on, the reindex link is shown to + callers allowed to publish the course's content, as v1 shows it. + """ + + def setUp(self): + super().setUp() + self.course = build_course(sections=1, subsections=1, units=1) + + def _user_with(self, role): + user = UserFactory() + self.add_user_to_role_in_course(user, role, self.course.id) + return user + + @ddt.data( + (COURSE_AUDITOR.external_key, False), + (COURSE_EDITOR.external_key, False), + (COURSE_STAFF.external_key, True), + (COURSE_ADMIN.external_key, True), + ) + @ddt.unpack + def test_the_link_follows_the_publish_permission(self, role, may_publish): + """Every one of these roles may view the course; only those that may publish get the link.""" + reindex = f"/course/{self.course.id}/search_reindex" + body = self.assert_parity(self._user_with(role)) + assert body["reindex_link"] == (reindex if may_publish else None) + + def test_global_staff_and_superuser_get_the_link_without_a_role(self): + reindex = f"/course/{self.course.id}/search_reindex" + assert self.assert_parity(self.staff_user)["reindex_link"] == reindex + assert self.assert_parity(self.super_user)["reindex_link"] == reindex + + def test_a_role_that_may_publish_another_course_does_not_get_the_link(self): + other = build_course(sections=1, subsections=1, units=1) + user = self._user_with(COURSE_AUDITOR.external_key) + self.add_user_to_role_in_course(user, COURSE_ADMIN.external_key, other.id) + assert self.assert_parity(user)["reindex_link"] is None + + @override_settings(ENABLE_COURSEWARE_INDEX=False) + def test_no_link_while_course_indexing_is_off(self): + assert self.assert_parity(self._user_with(COURSE_ADMIN.external_key))["reindex_link"] is None + + +# The keys of each full-view course_structure node, by block type, on a course +# with every configuration-dependent key off except tagging. The nodes come +# from the shared create_xblock_info builder; v2 publishes them as documented +# in CourseOutlineFullSerializer.course_structure. +_EVERY_NODE_KEYS = frozenset({ + "actions", "ancestor_has_staff_lock", "category", "course_authoring_url", "course_graders", + "course_tags_count", "display_name", "due", "due_date", "edited_on", "edited_on_raw", "embed_lms_url", + "enable_copy_paste_units", "enable_hide_from_toc_ui", "explanatory_message", "format", "graded", + "group_access", "has_changes", "has_children", "has_explicit_staff_lock", "has_partition_group_components", + "hide_from_toc", "hide_from_toc_message", "id", "is_tagging_feature_disabled", "lms_url", "published", + "published_on", "relative_weeks_due", "release_date", "released_to_students", "show_correctness", + "staff_only_message", "start", "studio_url", "tag_counts_by_block", "taxonomy_tags_widget_url", + "upstream_info", "user_partition_info", "user_partitions", "visibility_state", "xblock_type", +}) +_HIGHLIGHTS_KEYS = frozenset({"highlights_doc_url", "highlights_enabled", "highlights_preview_only"}) +FULL_VIEW_NODE_KEYS = { + "course": _EVERY_NODE_KEYS | _HIGHLIGHTS_KEYS | { + "child_info", "end", "highlights_enabled_for_messaging", "unit_level_discussions", + }, + "chapter": _EVERY_NODE_KEYS | _HIGHLIGHTS_KEYS | {"child_info", "highlights"}, + "sequential": _EVERY_NODE_KEYS | {"child_info", "hide_after_due"}, + "vertical": _EVERY_NODE_KEYS | {"discussion_enabled"}, + "split_test": _EVERY_NODE_KEYS | {"child_info"}, + "vertical outside a subsection": _EVERY_NODE_KEYS, + "problem": _EVERY_NODE_KEYS, +} +_TAGGING_KEYS = frozenset({ + "course_authoring_url", "course_tags_count", "tag_counts_by_block", "taxonomy_tags_widget_url", +}) +FULL_VIEW_CHILD_INFO_KEYS = { + "course": {"category", "display_name", "children"}, + "chapter": {"category", "display_name", "children"}, + "sequential": {"category", "display_name", "children"}, + "split_test": {"children"}, +} + + +@ddt.ddt +class OutlineFullViewNodeKeysTest(ModuleStoreTestCase): + """The keys each view=full course_structure node carries are the published contract.""" + + @ddt.data(False, True) + def test_every_node_carries_exactly_the_documented_keys(self, tagging_disabled): + course = build_course(sections=1, subsections=1, units=1) + subsection = modulestore().get_course(course.id).get_children()[0].get_children()[0] + BlockFactory.create(parent_location=subsection.location, category="problem", display_name="Loose") + split = BlockFactory.create(parent_location=subsection.location, category="split_test", display_name="Split") + BlockFactory.create(parent_location=split.location, category="vertical", display_name="Group") + reader = UserFactory() + CourseStaffRole(course.id).add_users(reader) + with override_waffle_flag(DISABLE_TAGGING_FEATURE, active=tagging_disabled): + body = _client(reader).get(outline_url(course.id), {"view": "full"}).json() + + def kinds(node, parent_category=None): + """``(kind, node)`` of every node; a vertical is a unit only directly in a subsection.""" + kind = node["category"] + if kind == "vertical" and parent_category != "sequential": + kind = "vertical outside a subsection" + yield kind, node + for child in node.get("child_info", {}).get("children", []): + yield from kinds(child, node["category"]) + + nodes = list(kinds(body["course_structure"])) + assert sorted({kind for kind, _ in nodes}) == sorted(FULL_VIEW_NODE_KEYS) + for category, node in nodes: + keys = set(node) + expected = FULL_VIEW_NODE_KEYS[category] - (_TAGGING_KEYS if tagging_disabled else set()) + assert keys == expected, ( + f"The keys of a {category} node in view=full course_structure changed: added " + f"{sorted(keys - expected)}, removed {sorted(expected - keys)}. These nodes are built by the " + "shared create_xblock_info and are part of the published v2 outline contract. A removed or " + "renamed key breaks v2 callers and needs a new version; an added key must be documented in " + "CourseOutlineFullSerializer.course_structure and listed here." + ) + assert set(node.get("child_info", {})) == FULL_VIEW_CHILD_INFO_KEYS.get(category, set()), category + + def test_the_documented_keys_are_named_in_the_published_description(self): + description = _published_schema()["components"]["schemas"][FULL_SHAPE]["properties"]["course_structure"][ + "description" + ] + named = set(re.findall(r"[a-z]+(?:_[a-z]+)+|\b[a-z]{2,}\b", description)) + missing = set().union(*FULL_VIEW_NODE_KEYS.values(), *FULL_VIEW_CHILD_INFO_KEYS.values()) - named + assert missing == set() + + +# --------------------------------------------------------------------------- +# Reads write nothing +# --------------------------------------------------------------------------- + +# The one write the permission library makes on a database's first role or +# permission lookup, whichever API makes it. +FIRST_LOOKUP_WRITES = [("INSERT", PolicyCacheControl._meta.db_table)] # pylint: disable=protected-access + + +class _ReadIsSideEffectFreeMixin: + """ + Every read (default, full, a child address, HEAD on both views) of a course + holding blocks dated before 1900 writes no row and no Mongo document, + stores nothing through the modulestore, saves no model and sends no + xblock-updated or publishing signal. The stored dates are left as they are. + + The reads start on a database that has never served a role or permission + lookup, so the first read may insert the permission library's one policy + version row and nothing else; the v1 read inserts the same row. + """ + + # Whether the course has an overview row when the reads start. Without + # one, a read that builds it would be seen writing it. + overview_before_reads = False + + def reader_for(self, course): + """Return a user who may read ``course``, leaving the database as no lookup had touched it.""" + raise NotImplementedError + + def _course_with_old_dates(self): + """A course whose course block, section, subsection and unit all start in 1800.""" + course = CourseFactory.create(start=PRE_1900) + section = BlockFactory.create( + parent_location=course.location, category="chapter", display_name="Old", start=PRE_1900, + ) + subsection = BlockFactory.create(parent_location=section.location, category="sequential", start=PRE_1900) + BlockFactory.create(parent_location=subsection.location, category="vertical", start=PRE_1900) + return course, section + + def _stored_starts(self, course_key): + return { + block_id: modulestore().get_item(UsageKey.from_string(block_id)).start + for block_id, _ in outline_blocks(course_key) + } + + def test_reads_have_no_side_effects(self): + course, section = self._course_with_old_dates() + client = _client(self.reader_for(course)) + starts = self._stored_starts(course.id) + assert {start.year for start in starts.values()} == {1800} + url = outline_url(course.id) + child_url = f"{url}?{urlencode({'usage_key': str(section.location), 'depth': 1})}" + reads = [("get", url, {}), ("get", url, {"view": "full"}), ("get", child_url, None), + ("head", url, {}), ("head", url, {"view": "full"})] + assert not PolicyCacheControl.objects.exists() + assert CourseOverview.objects.filter(id=course.id).exists() is self.overview_before_reads + for index, (method, address, params) in enumerate(reads): + with record_effects(self) as effects: + response = getattr(client, method)(address, params) + request = (index, method, address, params) + assert response.status_code == 200, (request, response.content) + assert effects["sql_writes"] == (FIRST_LOOKUP_WRITES if index == 0 else []), request + assert effects["saves"] == (["PolicyCacheControl"] if index == 0 else []), request + assert effects["mongo_writes"] == 0, request + assert effects["update_item"] == [], request + assert effects["xblock_updated"] == [], request + assert effects["published"] == [], request + assert self._stored_starts(course.id) == starts + full = client.get(url, {"view": "full"}).json() + assert {node["start"][:4] for node in structure_nodes(full["course_structure"])} == {"1800"} + + def test_a_clipboard_from_a_course_without_an_overview_is_not_read(self): + """The full view does not read the reader's clipboard, so it never builds a course overview.""" + course = build_course(sections=1, subsections=1, units=1) + reader = self.reader_for(course) + source = build_course(sections=1, subsections=1, units=1) + unit = modulestore().get_course(source.id).get_children()[0].get_children()[0].get_children()[0] + content_staging_api.save_xblock_to_user_clipboard(unit, reader.id) + CourseOverview.objects.filter(id=source.id).delete() + PolicyCacheControl.objects.all().delete() + with record_effects(self) as effects: + response = _client(reader).get(outline_url(course.id), {"view": "full"}) + assert response.status_code == 200, response.content + assert effects["sql_writes"] == FIRST_LOOKUP_WRITES + assert not CourseOverview.objects.filter(id=source.id).exists() + + def test_the_course_block_read_is_left_unchanged(self): + """The discussion configuration address is added to a copy of the course's discussion settings.""" + course = build_course(sections=1, subsections=1, units=1) + request = RequestFactory().get("/") + request.user = self.reader_for(course) + request.LANGUAGE_CODE = "en" + store = modulestore() + with store.bulk_operations(course.id): + stored = dict(store.get_course(course.id).discussions_settings) + outline = get_full_outline(request, course.id) + assert store.get_course(course.id).discussions_settings == stored + assert "discussion_configuration_url" not in stored + assert outline["discussions_settings"] == { + **stored, "discussion_configuration_url": f"{get_pages_and_resources_url(course.id)}/discussion/settings", + } + + def test_v1_still_resaves_old_dates_and_builds_the_clipboard_overview(self): + """ + The deprecated v1 read keeps both writes: it stores the default start + date over each date before 1900, announcing every block it updates, + and it builds a missing course overview for the clipboard's source. + """ + course, _ = self._course_with_old_dates() + reader = self.reader_for(course) + source = build_course(sections=1, subsections=1, units=1) + unit = modulestore().get_course(source.id).get_children()[0].get_children()[0].get_children()[0] + content_staging_api.save_xblock_to_user_clipboard(unit, reader.id) + CourseOverview.objects.filter(id=source.id).delete() + PolicyCacheControl.objects.all().delete() + with record_effects(self) as effects: + response = _client(reader).get(v1_url(course.id)) + assert response.status_code == 200, response.content + outline = [block_id for block_id, _ in outline_blocks(course.id)] + assert sorted(effects["update_item"]) == sorted(outline) + assert effects["mongo_writes"] > 0 + assert sorted(effects["xblock_updated"]) == sorted(outline) + assert ("INSERT", CourseOverview._meta.db_table) in effects["sql_writes"] # pylint: disable=protected-access + assert CourseOverview.objects.filter(id=source.id).exists() + assert {start.year for start in self._stored_starts(course.id).values()} == {DEFAULT_START_DATE.year} + + +class OutlineReadSideEffectsLegacyPermissionsTest(_ReadIsSideEffectFreeMixin, ModuleStoreTestCase): + """ + Reads by course staff with policy-based authoring permissions off. Course + staff rather than global staff, whose access is decided without a role lookup. + """ + + def reader_for(self, course): + reader = UserFactory() + CourseStaffRole(course.id).add_users(reader) + return reader + + +class OutlineReadSideEffectsAuthzTest(CourseAuthoringAuthzTestMixin, _ReadIsSideEffectFreeMixin, ModuleStoreTestCase): + """ + Reads by a course auditor with policy-based authoring permissions on. The + test setup loads the policy and assigns roles through the permission + library, which creates the policy version row; it is deleted before the + reads so they start on a database no lookup has touched. Assigning a role + on a course builds the course's overview, which the reads need: the + library grants a course role only on a course that has one. + """ + + overview_before_reads = True + + def reader_for(self, course): + reader = UserFactory() + self.add_user_to_role_in_course(reader, COURSE_AUDITOR.external_key, course.id) + PolicyCacheControl.objects.all().delete() + return reader + + def test_a_role_on_a_course_without_an_overview_is_refused_without_building_one(self): + course = build_course(sections=1, subsections=1, units=1) + client = _client(self.reader_for(course)) + CourseOverview.objects.filter(id=course.id).delete() + for params in ({}, {"view": "full"}): + with record_effects(self) as effects: + response = client.get(outline_url(course.id), params) + assert_error_envelope(response, expected_status=403, expected_type_slug="authz") + assert ("INSERT", CourseOverview._meta.db_table) not in effects["sql_writes"] # pylint: disable=protected-access + assert client.get(v1_url(course.id)).status_code == 403 + assert not CourseOverview.objects.filter(id=course.id).exists() + + +# --------------------------------------------------------------------------- +# Query counts +# --------------------------------------------------------------------------- + +def _measure(request): + """SQL queries, Mongo reads and Mongo writes of the second of two identical requests.""" + request() + with CaptureQueriesContext(connection) as queries, count_mongo_calls() as mongo: + response = request() + assert response.status_code == 200, response.content + return len(queries.captured_queries), mongo["reads"], mongo["writes"] + + +SMALL = (1, 1, 1) +LARGE = (4, 3, 3) + + +def v2_costs(costs): + """The v2 reads' costs; the v1 read is compared, not pinned, as its cost is not this endpoint's.""" + return {read: cost for read, cost in costs.items() if read != "v1"} + + +class _QueryCountMixin: + """Request costs against the v1 read, on a small and a larger course.""" + + def reader_for(self, course): + raise NotImplementedError + + def costs(self, size): + """``{read: (sql, mongo reads, mongo writes)}`` for v1, view=full and the default view.""" + course = build_course(*size) + client = _client(self.reader_for(course)) + return { + "v1": _measure(lambda: client.get(v1_url(course.id))), + "full": _measure(lambda: client.get(outline_url(course.id), {"view": "full"})), + "default": _measure(lambda: client.get(outline_url(course.id))), + "unit_depth": _measure(lambda: client.get(outline_url(course.id), {"depth": 3})), + } + + def test_full_view_costs_no_more_than_v1_and_the_default_view_does_not_grow(self): + small, large = self.costs(SMALL), self.costs(LARGE) + for costs in (small, large): + assert costs["full"][0] <= costs["v1"][0], costs + assert costs["full"][1] <= costs["v1"][1], costs + assert costs["v1"][2] == costs["full"][2] == costs["default"][2] == 0, costs + assert large["default"] == small["default"] + assert large["unit_depth"] == small["unit_depth"] + + +class OutlineQueryCountTest(_QueryCountMixin, ModuleStoreTestCase): + """ + Pinned costs with policy-based authoring permissions off, read by course + staff: SQL queries, Mongo reads and Mongo writes per request, the same on a + course of 1 and of 36 units. + """ + + def reader_for(self, course): + reader = UserFactory() + CourseStaffRole(course.id).add_users(reader) + return reader + + def test_pinned_costs(self): + expected = {"full": (34, 6, 0), "default": (11, 4, 0), "unit_depth": (11, 4, 0)} + assert v2_costs(self.costs(SMALL)) == expected + assert v2_costs(self.costs(LARGE)) == expected + + +class OutlineQueryCountAuthzTest(CourseAuthoringAuthzTestMixin, _QueryCountMixin, ModuleStoreTestCase): + """ + Pinned costs with policy-based authoring permissions on, read by a course + auditor, the same on a course of 1 and of 36 units. + """ + + def reader_for(self, course): + reader = UserFactory() + self.add_user_to_role_in_course(reader, COURSE_AUDITOR.external_key, course.id) + return reader + + def test_pinned_costs(self): + expected = {"full": (35, 6, 0), "default": (11, 4, 0), "unit_depth": (11, 4, 0)} + assert v2_costs(self.costs(SMALL)) == expected + assert v2_costs(self.costs(LARGE)) == expected + + +class OutlineRerootQueryCountTest(ModuleStoreTestCase): + """ + Re-rooting the outline costs what reading it from the course costs: the + check that the block is in the outline reads nothing more. + """ + + def _costs(self, size): + """``(sql, mongo reads, mongo writes)`` of reading from the course and from each depth of block.""" + course = build_course(*size) + reader = UserFactory() + CourseStaffRole(course.id).add_users(reader) + client = _client(reader) + section = course.get_children()[-1] + subsection = section.get_children()[-1] + unit = subsection.get_children()[-1] + orphan = modulestore().create_item(self.user.id, course.id, "chapter", "orphan").location + costs = {"course": _measure(lambda: client.get(outline_url(course.id), {"depth": 1}))} + for name, block in (("section", section), ("subsection", subsection), ("unit", unit)): + costs[name] = _measure( + lambda key=block.location: client.get(outline_url(course.id), {"usage_key": str(key), "depth": 1}) + ) + client.get(outline_url(course.id), {"usage_key": str(orphan)}) + with CaptureQueriesContext(connection) as queries, count_mongo_calls() as mongo: + refused = client.get(outline_url(course.id), {"usage_key": str(orphan)}) + assert refused.status_code == 400, refused.content + costs["refused"] = (len(queries.captured_queries), mongo["reads"], mongo["writes"]) + return costs + + def test_pinned_costs(self): + # A refusal adds only the request transaction's rollback statement. + expected = { + "course": (11, 4, 0), "section": (11, 4, 0), "subsection": (11, 4, 0), "unit": (11, 4, 0), + "refused": (12, 4, 0), + } + assert self._costs(SMALL) == expected + assert self._costs(LARGE) == expected + + +# --------------------------------------------------------------------------- +# Addresses, methods and the published schema +# --------------------------------------------------------------------------- + +COURSE_KEY = "course-v1:edX+ToyX+Toy_Course" + + +@ddt.ddt +class OutlineRoutingTest(SharedModuleStoreTestCase): + """The address, the deprecated v1 address, and requests no route or method serves.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = build_course(sections=1, subsections=1, units=1) + + def test_the_address(self): + assert outline_url(COURSE_KEY) == f"/api/authoring/v2/courses/{COURSE_KEY}/outline/" + match = resolve(outline_url(COURSE_KEY)) + assert match.func.cls is CourseOutlineViewSet + assert routed_actions(match.func) == {"get": "retrieve"} + + def test_the_v1_address_is_unchanged(self): + assert v1_url(COURSE_KEY) == f"/api/contentstore/v1/course_index/{COURSE_KEY}" + assert resolve(v1_url(COURSE_KEY)).func.cls is CourseIndexView + + # The error library does not catalogue MethodNotAllowed or NotAcceptable, so + # these answer with the envelope typed internal. Change the expected type + # here once it does. + + @ddt.data("post", "put", "patch", "delete") + def test_other_methods_are_not_allowed(self, method): + response = getattr(_client(UserFactory(is_staff=True)), method)(outline_url(self.course.id), {}, format="json") + assert_error_envelope(response, expected_status=405, expected_type_slug="internal") + + def test_unsupported_accept_is_refused_with_406(self): + response = _client(UserFactory(is_staff=True)).get(outline_url(self.course.id), HTTP_ACCEPT="application/xml") + assert_error_envelope(response, expected_status=406, expected_type_slug="internal") + + def test_options_describes_the_endpoint(self): + response = _client(UserFactory(is_staff=True)).options(outline_url(self.course.id)) + assert response.status_code == 200 + assert response.json()["name"] == "Course Outline" + + # These addresses match no route, so the service's HTML 404 page answers + # them. Change these to the error envelope once a catch-all route under the + # mount answers unknown addresses. + + @ddt.data("not-a-course-key", "edX/ToyX/Toy_Course", "edX+ToyX+Toy_Course") + def test_unparsable_or_slash_form_key_is_404(self, key): + response = _client(UserFactory(is_staff=True)).get(f"/api/authoring/v2/courses/{key}/outline/") + assert response.status_code == 404 + assert response["Content-Type"].startswith("text/html") + + def test_the_address_without_its_trailing_slash_redirects_to_it(self): + """No route answers the address without its slash; the service redirects a read to the slashed one.""" + response = _client(UserFactory(is_staff=True)).get(f"/api/authoring/v2/courses/{self.course.id}/outline") + assert response.status_code == 301 + assert response["Location"] == outline_url(self.course.id) + with pytest.raises(Resolver404): + resolve(f"/api/authoring/v2/courses/{self.course.id}/outline") + + def test_version_only_course_key_is_404(self): + # The converter accepts a key that names only a version. No course run is + # behind it, so the view answers 404 rather than 500. + version_only = "course-v1:version@0123456789abcdef01234567" + response = _client(UserFactory()).get(f"/api/authoring/v2/courses/{version_only}/outline/") + envelope = assert_error_envelope(response, expected_status=404, expected_type_slug="not-found") + assert envelope["detail"] == COURSE_NOT_FOUND + + def test_a_library_is_not_a_course(self): + library = LibraryFactory.create() + for params in ({}, {"view": "full"}): + response = _client(UserFactory(is_staff=True)).get(outline_url(library.location.library_key), params) + envelope = assert_error_envelope(response, expected_status=404, expected_type_slug="not-found") + assert envelope["detail"] == COURSE_NOT_FOUND + + def test_v1_keeps_its_own_error_body(self): + response = _client().get(v1_url(self.course.id)) + assert response.status_code == 401 + assert response.json() == {"developer_message": "Authentication credentials were not provided."} + + +class OutlineSchemaTest(SharedModuleStoreTestCase): + """The outline read as published, generated from the real contentstore and authoring routes.""" + + PATH = "/api/authoring/v2/courses/{course_key}/outline/" + V1 = "/api/contentstore/v1/course_index/{course_id}" + + @staticmethod + def _ref(response): + return response["content"]["application/json"]["schema"]["$ref"].rsplit("/", 1)[-1] + + def test_the_read_is_published_for_the_sdk(self): + schema = _published_schema() + assert list(schema["paths"][self.PATH]) == ["get"] + operation = schema["paths"][self.PATH]["get"] + assert operation["operationId"] == "v2_courses_outline_retrieve" + assert operation["tags"] == ["openedx-platform-sdk"] + assert "deprecated" not in operation + assert "x-internal" not in operation + parameters = {p["name"]: p for p in operation["parameters"]} + assert set(parameters) == {"course_key", "view", "depth", "usage_key", "show"} + assert all(parameters[name]["in"] == "query" for name in ("view", "depth", "usage_key", "show")) + assert not any(parameters[name].get("required") for name in ("view", "depth", "usage_key", "show")) + assert parameters["course_key"] == { + "in": "path", + "name": "course_key", + "schema": {"type": "string", "pattern": "^[^/]+$"}, + "description": "Key of the course, such as course-v1:edX+DemoX+2026.", + "required": True, + } + assert parameters["view"]["schema"] == {"type": "string", "enum": ["full", "minimal"], "default": "minimal"} + assert parameters["depth"]["schema"] == {"type": "integer", "minimum": 1, "maximum": 3, "default": 2} + assert parameters["usage_key"]["schema"] == parameters["show"]["schema"] == {"type": "string"} + assert self._ref(operation["responses"]["200"]) == READ_SHAPE + for status in ("400", "401", "403", "404"): + assert self._ref(operation["responses"][status]) == "ErrorResponse", status + + def test_the_read_offers_the_tree_and_the_full_shape(self): + components = _published_schema()["components"]["schemas"] + assert components[READ_SHAPE] == {"oneOf": [ + {"$ref": f"#/components/schemas/{DEFAULT_SHAPE}"}, + {"$ref": f"#/components/schemas/{FULL_SHAPE}"}, + ]} + node = components[DEFAULT_SHAPE] + assert node["required"] == ["category", "display_name", "id"] + assert node["properties"]["children"]["items"] == {"$ref": f"#/components/schemas/{DEFAULT_SHAPE}"} + full = components[FULL_SHAPE] + nullable = {name for name, field in full["properties"].items() if field.get("nullable")} + assert nullable == { + "initial_state", "lms_link", "notification_dismiss_url", "reindex_link", "rerun_notification_id", + "created_on", + } + assert set(full["required"]) == set(full["properties"]) + assert "initial_user_clipboard" not in full["properties"] + + def test_every_field_of_the_read_is_described(self): + """Every property of every component the read references, however deep, has a description.""" + components = _published_schema()["components"]["schemas"] + reached, pending = set(), [READ_SHAPE] + while pending: + name = pending.pop() + if name not in reached: + reached.add(name) + refs = re.findall(r"'\$ref': '([^']+)'", str(components[name])) + pending.extend(ref.rsplit("/", 1)[-1] for ref in refs) + assert reached == { + READ_SHAPE, DEFAULT_SHAPE, FULL_SHAPE, "CourseOutlineDeprecatedBlocks", "CourseOutlineInitialState", + "CourseOutlineProctoringError", "CourseOutlineProctoringSetting", + } + undescribed = { + f"{name}.{field}" + for name in reached + for field, spec in components[name].get("properties", {}).items() + if not spec.get("description") + } + assert undescribed == set() + + def test_proctoring_errors_publish_the_shape_v1_publishes(self): + """The outline's proctoring error components are v1's, with descriptions and their own names.""" + components = _published_schema()["components"]["schemas"] + + def shape(name, renames): + """ + The component without descriptions, its references renamed by ``renames``. + A described reference is published as a one-entry allOf; that is the reference. + """ + def strip(node): + if isinstance(node, dict): + node = {key: value for key, value in node.items() if key != "description"} + if list(node) == ["allOf"] and len(node["allOf"]) == 1: + return strip(node["allOf"][0]) + return { + key: renames.get(value, value) if key == "$ref" else strip(value) + for key, value in node.items() + } + return [strip(item) for item in node] if isinstance(node, list) else node + return strip(components[name]) + + ours = {"#/components/schemas/CourseOutlineProctoringSetting": "#/components/schemas/ProctoringErrorModel"} + assert shape("CourseOutlineProctoringError", ours) == shape("ProctoringErrorList", {}) + assert shape("CourseOutlineProctoringSetting", ours) == shape("ProctoringErrorModel", {}) + assert components[FULL_SHAPE]["properties"]["proctoring_errors"]["items"] == { + "$ref": "#/components/schemas/CourseOutlineProctoringError", + } + + def test_the_examples_are_valid_bodies_of_their_view(self): + operation = _published_schema()["paths"][self.PATH]["get"] + examples = operation["responses"]["200"]["content"]["application/json"]["examples"] + assert set(examples) == {"OutlineTree", "OutlinePageData"} + tree = examples["OutlineTree"]["value"] + assert_matches_one_read_shape(tree, DEFAULT_SHAPE) + assert [node["category"] for node in tree_nodes(tree)] == ["course", "chapter", "sequential", "sequential"] + cut = [node for node in tree_nodes(tree) if "children_url" in node] + assert len(cut) == 1 + assert parse_qs(urlsplit(cut[0]["children_url"]).query) == {"usage_key": [cut[0]["id"]], "depth": ["1"]} + page = examples["OutlinePageData"]["value"] + assert_matches_one_read_shape(page, FULL_SHAPE) + node_keys = set(page["course_structure"]) - {"child_info"} + assert node_keys < FULL_VIEW_NODE_KEYS["course"] + + def test_the_v1_read_is_deprecated(self): + operation = _published_schema()["paths"][self.V1]["get"] + assert operation["deprecated"] is True + assert operation["operationId"] == "v1_course_index_retrieve" diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_course_settings.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_course_settings.py new file mode 100644 index 000000000000..b1c9d9fc0b77 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_course_settings.py @@ -0,0 +1,1228 @@ +"""Tests for the v2 course settings API.""" + +import json +import re +from datetime import UTC, datetime +from unittest.mock import patch + +import ddt +import pytest +from django.conf import settings +from django.contrib.auth.models import User +from django.test import SimpleTestCase, TestCase +from django.test.utils import override_settings +from django.urls import Resolver404, resolve, reverse +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from edx_rest_framework_extensions.permissions import NotJwtRestrictedApplication +from edx_rest_framework_extensions.testing import assert_error_envelope +from openedx_authz.api.users import assign_role_to_user_in_scope +from openedx_authz.constants.roles import ( + COURSE_ADMIN, + COURSE_AUDITOR, + COURSE_BETA_TESTER, + COURSE_DATA_RESEARCHER, + COURSE_EDITOR, + COURSE_LIMITED_STAFF, + COURSE_STAFF, +) +from openedx_authz.engine.enforcer import AuthzEnforcer +from openedx_authz.models.engine import PolicyCacheControl +from rest_framework.permissions import IsAuthenticated +from rest_framework.test import APIClient + +from cms.djangoapps.contentstore.rest_api.v1.views.tests.helpers import ( + FIRST_LOOKUP_SIGNALS, + FIRST_LOOKUP_WRITES, + UNKNOWN_COURSE_KEY, + VERSION_ONLY_KEY, + api_client, + assert_matches_published_schema, + assert_refused, + changed_paths, + measure, + published_schema, + record_effects, + undeclared_paths, +) +from cms.djangoapps.contentstore.rest_api.v2 import course_settings_service +from cms.djangoapps.contentstore.rest_api.v2.views.course_settings import CourseSettingsViewSet +from cms.djangoapps.contentstore.rest_api.v2.views.tests.helpers import PASSWORD, logged_in_client +from cms.djangoapps.contentstore.tests.utils import routed_actions +from cms.djangoapps.contentstore.views import course as course_views +from cms.djangoapps.contentstore.views.permissions import CanViewCourse +from cms.lib.spectacular import LEGACY_MIGRATED_PATH_PREFIXES, SUPERSEDED_PATHS +from common.djangoapps.course_action_state.models import CourseRerunState +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.course_modes.tests.factories import CourseModeFactory +from common.djangoapps.student.models import CourseAccessRole +from common.djangoapps.student.roles import ( + CourseInstructorRole, + CourseLimitedStaffRole, + CourseStaffRole, + OrgInstructorRole, + OrgStaffRole, +) +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core import toggles as core_toggles +from openedx.core.djangoapps.authz.tests.fixtures import seed_policies +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.credit.tests.factories import CreditCourseFactory, CreditRequirementFactory +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.mixed import MixedModuleStore +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory + +URL_NAME = "authoring_v2:course_settings" +LEGACY_URL_NAME = "cms.djangoapps.contentstore:v1:course_settings" +LEGACY_VIEW = ("cms.djangoapps.contentstore.rest_api.v1.views.settings", "CourseSettingsView") +GROUP_CONFIGURATIONS_URL_NAME = "cms.djangoapps.contentstore:v1:group_configurations" +SERVICE_LOGGER = "cms.djangoapps.contentstore.rest_api.v2.course_settings_service" +COURSE_KEY = "course-v1:edX+DemoX+Demo_Course" +FULL = {"view": "full"} +VIEWS = ("minimal", "full") +HEAVY_FIELDS = {"credit_requirements", "language_options", "possible_pre_requisite_courses"} +MINIMAL_FIELDS = { + "about_page_editable", "can_show_certificate_available_date_field", "content_experiments_enabled", + "course_display_name", "course_display_name_with_default", "credit_eligibility_enabled", + "enable_extended_course_details", "enrollment_end_editable", "is_credit_course", "is_entrance_exams_enabled", + "is_prerequisite_courses_enabled", "licensing_enabled", "lms_link_for_about_page", "marketing_enabled", + "mfe_proctored_exam_settings_url", "platform_name", "short_description_editable", "show_min_grade_warning", + "sidebar_html_enabled", "upgrade_deadline", +} +PREREQUISITE_MEMBERS = {"course_key", "display_name", "lms_link", "number", "org", "rerun_link", "run", "url"} +PREREQUISITES_ON = override_settings(ENABLE_PREREQUISITE_COURSES=True, MILESTONES_APP=True) +# Query parameters the Studio course listing reads, with values that narrow it or that it refuses. +COURSE_LIST_PARAMETERS = {"search": "Alpha", "ordering": "-display_name", "active_only": "true"} + + +def url(course_key): + """Return the settings address of ``course_key``.""" + return reverse(URL_NAME, kwargs={"course_key": str(course_key)}) + + +def legacy_url(course_key): + """Return the deprecated course settings address of ``course_key``.""" + return reverse(LEGACY_URL_NAME, kwargs={"course_id": str(course_key)}) + + +def view_params(view): + """The query parameters that ask for ``view``; the minimal view is asked for by sending none.""" + return FULL if view == "full" else {} + + +def roots(paths): + """The top-level member of each json path.""" + return {re.split(r"[.\[]", path, maxsplit=1)[0] for path in paths} + + +def create_course(**kwargs): + """Create a course and its overview, as publishing it from Studio does.""" + course = CourseFactory.create(**kwargs) + CourseOverview.load_from_module_store(course.id) + return course + + +def non_staff_superuser(): + """ + Return a superuser without staff status. + + Saving a superuser gives it staff status (the student app's + ``grant_staff_access_to_superusers`` receiver), so staff status is cleared + with an update, which sends no save signal. + """ + user = UserFactory(is_superuser=True) + User.objects.filter(pk=user.pk).update(is_staff=False) + user.refresh_from_db() + assert (user.is_superuser, user.is_staff) == (True, False) + return user + + +def turn_on_content_experiments(course_key, user_id=ModuleStoreEnum.UserID.test): + """Add content experiments to the advanced modules of the stored course.""" + course = modulestore().get_course(course_key) + course.advanced_modules = [*course.advanced_modules, "split_test"] + modulestore().update_item(course, user_id) + + +def make_credit_course(course_key, requirements=(("grade", "grade", "Grade", {"min_grade": 0.8}),)): + """Register ``course_key`` as a credit course with ``(namespace, name, display_name, criteria)`` requirements.""" + credit_course = CreditCourseFactory(course_key=course_key, enabled=True) + for namespace, name, display_name, criteria in requirements: + CreditRequirementFactory( + course=credit_course, namespace=namespace, name=name, display_name=display_name, criteria=criteria, + ) + return credit_course + + +# --------------------------------------------------------------------------- +# Who may call the endpoint +# --------------------------------------------------------------------------- + +class _AccessMixin: + """Checks a caller's status on both views against the expected one and the v1 decision.""" + + def assert_read(self, user, view, expected, course_key=None): + """Assert the status of a read by ``user``, its members or envelope, and agreement with v1.""" + course_key = course_key or self.course.id + response = api_client(user).get(url(course_key), view_params(view)) + legacy = api_client(user).get(legacy_url(course_key)) + assert response.status_code == expected, response.content + if expected == 200: + extra = {"language_options"} if view == "full" else set() + assert set(response.json()) == MINIMAL_FIELDS | extra + else: + assert_refused(response, expected) + assert legacy.status_code == expected, f"v1 decided {legacy.status_code} for the same caller" + + +@ddt.ddt +class SettingsAccessLegacyPermissionsTest(_AccessMixin, SharedModuleStoreTestCase): + """Who may read the settings when policy-based authoring permissions are off.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + cls.other_course = CourseFactory.create() + + def _user_with(self, role): + user = UserFactory() + role.add_users(user) + return user + + @ddt.data(*VIEWS) + def test_anonymous_caller_is_refused_with_401(self, view): + self.assert_read(None, view, 401) + + @ddt.data(*VIEWS) + def test_anonymous_caller_of_an_unknown_course_is_refused_with_401(self, view): + self.assert_read(None, view, 401, course_key=UNKNOWN_COURSE_KEY) + + @ddt.data(*VIEWS) + def test_user_without_a_role_is_refused_with_403(self, view): + self.assert_read(UserFactory(), view, 403) + + @ddt.data(*VIEWS) + def test_unknown_course_is_404_before_permission_is_checked(self, view): + self.assert_read(UserFactory(), view, 404, course_key=UNKNOWN_COURSE_KEY) + + @ddt.data(*VIEWS) + def test_limited_staff_is_refused(self, view): + self.assert_read(self._user_with(CourseLimitedStaffRole(self.course.id)), view, 403) + + @ddt.data(*VIEWS) + def test_staff_and_instructors_of_another_course_are_refused(self, view): + self.assert_read(self._user_with(CourseStaffRole(self.other_course.id)), view, 403) + self.assert_read(self._user_with(CourseInstructorRole(self.other_course.id)), view, 403) + + @ddt.data(*VIEWS) + def test_course_staff_and_instructor_may_read(self, view): + self.assert_read(self._user_with(CourseStaffRole(self.course.id)), view, 200) + self.assert_read(self._user_with(CourseInstructorRole(self.course.id)), view, 200) + + @ddt.data(*VIEWS) + def test_org_staff_and_org_instructor_may_read(self, view): + self.assert_read(self._user_with(OrgStaffRole(self.course.id.org)), view, 200) + self.assert_read(self._user_with(OrgInstructorRole(self.course.id.org)), view, 200) + + @ddt.data(*VIEWS) + def test_global_staff_may_read(self, view): + self.assert_read(UserFactory(is_staff=True), view, 200) + + @ddt.data(*VIEWS) + def test_a_superuser_without_staff_status_is_refused(self, view): + """ + Studio read access comes from staff status or a course or organization + role, and being a superuser is neither, so both addresses refuse. + """ + self.assert_read(non_staff_superuser(), view, 403) + + @ddt.data(*VIEWS) + def test_a_saved_superuser_has_staff_status_and_may_read(self, view): + """ + The student app's ``grant_staff_access_to_superusers`` receiver gives + every superuser staff status when it is saved, so a superuser created + through the model is admitted by both addresses. + """ + user = UserFactory(is_superuser=True, is_staff=False) + assert User.objects.get(pk=user.pk).is_staff is True + self.assert_read(user, view, 200) + + def test_a_policy_role_does_not_count_for_an_inactive_account(self): + """ + A course staff role assigned through policy-based permissions counts as + Studio's course staff role while those permissions are off for the + course, but, as for any course role, only while the account is active. + """ + seed_policies() + self.addCleanup(AuthzEnforcer.get_enforcer().clear_policy) + user = UserFactory(password=PASSWORD) + assign_role_to_user_in_scope(user.username, COURSE_STAFF.external_key, str(self.course.id)) + AuthzEnforcer.get_enforcer().load_policy() + assert not CourseAccessRole.objects.filter(user=user).exists() + client = logged_in_client(user) + with patch.object(core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, "is_enabled", return_value=False): + for is_active, expected in ((True, 200), (False, 403)): + user.is_active = is_active + user.save() + for params in ({}, FULL): + response = client.get(url(self.course.id), params) + assert response.status_code == expected, (is_active, params, response.content) + if expected == 403: + assert_refused(response, 403) + legacy = client.get(legacy_url(self.course.id)) + assert legacy.status_code == expected, f"v1 decided {legacy.status_code} for is_active={is_active}" + + def test_a_version_only_course_key_is_404(self): + """The key parses but names no course run; v1 has no address that takes it.""" + assert_refused(api_client(UserFactory(is_staff=True)).get(url(VERSION_ONLY_KEY)), 404) + + def test_a_library_is_not_a_course(self): + library_key = LibraryFactory.create().location.library_key + assert_refused(api_client(UserFactory(is_staff=True)).get(url(library_key)), 404) + + +@ddt.ddt +class SettingsAccessAuthzTest(CourseAuthoringAuthzTestMixin, _AccessMixin, SharedModuleStoreTestCase): + """Who may read the settings when policy-based authoring permissions are on: the course view permission.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + cls.other_course = CourseFactory.create() + + def _user_with(self, role, course_key=None): + user = UserFactory() + self.add_user_to_role_in_course(user, role, course_key or self.course.id) + return user + + @ddt.data(*VIEWS) + def test_anonymous_caller_is_refused_with_401(self, view): + self.assert_read(None, view, 401) + + @ddt.data(*VIEWS) + def test_user_without_a_role_is_refused_with_403(self, view): + self.assert_read(self.unauthorized_user, view, 403) + + @ddt.data(*VIEWS) + def test_unknown_course_is_404_before_permission_is_checked(self, view): + self.assert_read(self.unauthorized_user, view, 404, course_key=UNKNOWN_COURSE_KEY) + + @ddt.data(*[(view, role) for view in VIEWS for role in ( + COURSE_AUDITOR.external_key, COURSE_EDITOR.external_key, COURSE_STAFF.external_key, COURSE_ADMIN.external_key, + )]) + @ddt.unpack + def test_roles_that_may_view_the_course_may_read(self, view, role): + self.assert_read(self._user_with(role), view, 200) + + @ddt.data(*[(view, role) for view in VIEWS for role in ( + COURSE_LIMITED_STAFF.external_key, COURSE_DATA_RESEARCHER.external_key, COURSE_BETA_TESTER.external_key, + )]) + @ddt.unpack + def test_other_roles_are_refused(self, view, role): + self.assert_read(self._user_with(role), view, 403) + + @ddt.data(*VIEWS) + def test_a_role_on_another_course_is_refused(self, view): + user = self._user_with(COURSE_ADMIN.external_key, self.other_course.id) + self.assert_read(user, view, 200, course_key=self.other_course.id) + self.assert_read(user, view, 403) + + @ddt.data(*VIEWS) + def test_legacy_course_staff_without_a_policy_role_is_refused(self, view): + user = UserFactory() + CourseAccessRole.objects.create(user=user, role="staff", course_id=self.course.id, org=self.course.id.org) + self.assert_read(user, view, 403) + + @ddt.data(*VIEWS) + def test_global_staff_may_read_without_a_role(self, view): + self.assert_read(self.staff_user, view, 200) + + @ddt.data(*VIEWS) + def test_a_superuser_without_staff_status_may_read_without_a_role(self, view): + """The policy admits staff and superusers on every course, so both addresses admit this account.""" + self.assert_read(non_staff_superuser(), view, 200) + + def test_an_inactive_session_with_a_role_may_read(self): + """The policy does not look at whether the account is activated, on v2 as on v1.""" + user = UserFactory(password=PASSWORD) + self.add_user_to_role_in_course(user, COURSE_AUDITOR.external_key, self.course.id) + client = logged_in_client(user) + user.is_active = False + user.save() + assert client.get(url(self.course.id)).status_code == 200 + assert client.get(legacy_url(self.course.id)).status_code == 200 + + +class SettingsAuthenticationTest(SharedModuleStoreTestCase): + """How callers authenticate, with policy-based authoring permissions off.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + + def _course_staff(self): + user = UserFactory(password=PASSWORD) + CourseStaffRole(self.course.id).add_users(user) + return user + + def test_a_jwt_caller_may_read(self): + header = {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(self._course_staff())}"} + response = APIClient().get(url(self.course.id), **header) + assert response.status_code == 200, response.content + assert set(response.json()) == MINIMAL_FIELDS + + def test_a_session_caller_may_read(self): + response = logged_in_client(self._course_staff()).get(url(self.course.id), FULL) + assert response.status_code == 200, response.content + assert set(response.json()) == MINIMAL_FIELDS | {"language_options"} + + def test_an_inactive_session_is_authenticated(self): + """ + A session whose account is not activated is accepted, as on v1. Global + staff then pass; course staff are refused (403, not 401) because a + course role does not count while the account is inactive. + """ + for user, expected in ((UserFactory(is_staff=True, password=PASSWORD), 200), (self._course_staff(), 403)): + client = logged_in_client(user) + user.is_active = False + user.save() + response = client.get(url(self.course.id)) + assert response.status_code == expected, response.content + if expected == 403: + assert_refused(response, 403) + assert client.get(legacy_url(self.course.id)).status_code == expected + + def test_a_bearer_token_is_refused(self): + """Bearer tokens are not accepted here, though v1 takes them.""" + user = UserFactory(is_staff=True) + token = AccessTokenFactory(user=user, application=ApplicationFactory(user=user)).token + header = {"HTTP_AUTHORIZATION": f"Bearer {token}"} + assert_refused(APIClient().get(url(self.course.id), **header), 401) + assert APIClient().get(legacy_url(self.course.id), **header).status_code == 200 + + +class SettingsPermissionWiringTest(SimpleTestCase): + """The checks the endpoint runs, in order.""" + + def test_the_course_view_permission_is_checked_after_authentication(self): + view = CourseSettingsViewSet() + for action in ("retrieve", None, "metadata"): + view.action = action + # The JWT middleware appends NotJwtRestrictedApplication to views that take JWTs. + declared = [ + type(permission) for permission in view.get_permissions() + if not isinstance(permission, NotJwtRestrictedApplication) + ] + assert declared == [IsAuthenticated, CanViewCourse], action + + def test_bearer_tokens_are_not_among_the_authentication_classes(self): + assert CourseSettingsViewSet.authentication_classes == ( + JwtAuthentication, SessionAuthenticationAllowInactiveUser, + ) + + +# --------------------------------------------------------------------------- +# Reads +# --------------------------------------------------------------------------- + +@ddt.ddt +class SettingsReadTest(ModuleStoreTestCase): + """What a read returns, by view, with every feature that adds members turned on.""" + + def setUp(self): + super().setUp() + self.course = create_course(display_name="Settings") + self.alpha = create_course(display_name="Alpha") + self.beta = create_course(display_name="Beta") + make_credit_course(self.course.id) + self.client = api_client(UserFactory(is_staff=True)) + + def get(self, **params): + response = self.client.get(url(self.course.id), params) + assert response.status_code == 200, response.content + return response.json() + + @PREREQUISITES_ON + def test_the_minimal_view_leaves_out_the_lists_and_credit_requirements(self): + body = self.get() + assert set(body) == MINIMAL_FIELDS + assert (body["is_prerequisite_courses_enabled"], body["is_credit_course"]) == (True, True) + + @PREREQUISITES_ON + def test_the_full_view_adds_the_lists_and_credit_requirements(self): + body = self.get(**FULL) + assert set(body) == MINIMAL_FIELDS | HEAVY_FIELDS + assert body["language_options"] == [list(pair) for pair in settings.ALL_LANGUAGES] + assert body["credit_requirements"] == { + "grade": [{"name": "grade", "display_name": "Grade", "criteria": {"min_grade": 0.8}}], + } + prerequisites = body["possible_pre_requisite_courses"] + assert [course["course_key"] for course in prerequisites] == [str(self.alpha.id), str(self.beta.id)] + assert {member for course in prerequisites for member in course} == PREREQUISITE_MEMBERS + + def test_the_full_view_without_prerequisites_or_credit_adds_the_language_list_only(self): + with override_settings(ENABLE_CREDIT_ELIGIBILITY=False): + body = self.get(**FULL) + assert set(body) == MINIMAL_FIELDS | {"language_options"} + assert (body["is_prerequisite_courses_enabled"], body["is_credit_course"]) == (False, False) + + def test_the_minimal_view_is_the_default(self): + assert self.get(view="minimal") == self.get() + + def test_an_unknown_view_is_refused(self): + response = self.client.get(url(self.course.id), {"view": "default"}) + envelope = assert_error_envelope(response, expected_status=400, expected_type_slug="validation") + assert envelope["errors"] == {"view": ['"default" is not a valid choice.']} + + @ddt.data(*VIEWS) + @PREREQUISITES_ON + def test_the_course_list_parameters_change_nothing(self, view): + """The prerequisite list is the same whatever course list parameters the request carries.""" + expected = self.get(**view_params(view)) + assert self.get(**view_params(view), **COURSE_LIST_PARAMETERS) == expected + assert self.get(**view_params(view), start_date_on_or_after="not a date") == expected + + @PREREQUISITES_ON + def test_the_course_listing_is_given_the_caller_and_no_query_parameters(self): + target = "cms.djangoapps.contentstore.rest_api.v2.course_settings_service.get_course_settings" + with patch(target, wraps=course_settings_service.get_course_settings) as listing: + self.get(**FULL, **COURSE_LIST_PARAMETERS) + (given, course_key, course), _ = listing.call_args + assert dict(given.query_params) == {} + assert dict(given.GET) == {} + assert given.user.is_staff is True + assert (course_key, course.id) == (self.course.id, self.course.id) + + @ddt.data(*VIEWS) + @PREREQUISITES_ON + def test_the_body_matches_the_published_schema(self, view): + assert_matches_published_schema(self.get(**view_params(view)), "CourseSettings.v2") + + def test_content_experiments_enabled_follows_the_advanced_modules(self): + assert self.get()["content_experiments_enabled"] is False + turn_on_content_experiments(self.course.id) + assert self.get()["content_experiments_enabled"] is True + + def test_a_course_without_a_name(self): + course = modulestore().get_course(self.course.id) + course.display_name = None + modulestore().update_item(course, ModuleStoreEnum.UserID.test) + body = self.get() + assert body["course_display_name"] is None + assert body["course_display_name_with_default"] == modulestore().get_course( + self.course.id, + ).display_name_with_default + legacy = self.client.get(legacy_url(self.course.id)).json() + assert legacy["course_display_name"] is None + assert legacy["course_display_name_with_default"] == body["course_display_name_with_default"] + + @ddt.data(*VIEWS) + def test_head_answers_without_a_body(self, view): + response = self.client.head(url(self.course.id), view_params(view)) + assert response.status_code == 200 + assert response.content == b"" + + +# Requests v1 and v2 answer differently beyond the error body, as +# (request, v1 answer, v2 answer, test in SettingsResponseDifferencesTest). +RESPONSE_DIFFERENCES = [ + ("read with course list parameters such as search", "prerequisites narrowed or reordered", + "prerequisites unchanged", "test_course_list_parameters"), + ("read with a malformed start_date_on_or_after", "400", "200", "test_a_malformed_start_date_parameter"), + ("read with view=default", "200", "400", "test_an_unknown_view"), + ("read of a course the modulestore returns nothing for", "500", "404", + "test_a_course_the_modulestore_returns_nothing_for"), + ("read with a Bearer token", "200", "401", "test_a_bearer_token"), + ("slash-separated course key", "404 JSON", "404 HTML", "test_a_slash_separated_course_key"), +] + + +@PREREQUISITES_ON +class SettingsResponseDifferencesTest(ModuleStoreTestCase): + """Each request v1 and v2 answer differently, both answers observed.""" + + def setUp(self): + super().setUp() + self.course = create_course() + self.alpha = create_course(display_name="Alpha") + self.beta = create_course(display_name="Beta") + self.client = api_client(UserFactory(is_staff=True)) + + def both(self, **params): + """The v1 and v2 (full view) responses to a read with ``params``.""" + legacy = self.client.get(legacy_url(self.course.id), params) + return legacy, self.client.get(url(self.course.id), {**FULL, **params}) + + def test_every_listed_difference_has_its_test(self): + assert [name for *_, name in RESPONSE_DIFFERENCES if not hasattr(self, name)] == [] + + def test_course_list_parameters(self): + all_keys = [str(self.alpha.id), str(self.beta.id)] + for params, narrowed in ( + ({"search": "Alpha"}, [str(self.alpha.id)]), + ({"ordering": "-display_name"}, [str(self.beta.id), str(self.alpha.id)]), + ): + legacy, new = self.both(**params) + assert legacy.status_code == new.status_code == 200 + assert [c["course_key"] for c in legacy.json()["possible_pre_requisite_courses"]] == narrowed, params + assert [c["course_key"] for c in new.json()["possible_pre_requisite_courses"]] == all_keys, params + + def test_a_malformed_start_date_parameter(self): + legacy, new = self.both(start_date_on_or_after="not a date") + assert legacy.status_code == 400 + assert new.status_code == 200, new.content + + def test_an_unknown_view(self): + legacy = self.client.get(legacy_url(self.course.id), {"view": "default"}) + new = self.client.get(url(self.course.id), {"view": "default"}) + assert legacy.status_code == 200 + assert_error_envelope(new, expected_status=400, expected_type_slug="validation") + + def test_a_course_the_modulestore_returns_nothing_for(self): + with patch.object(MixedModuleStore, "get_course", return_value=None): + legacy = self.client.get(legacy_url(self.course.id)) + with self.assertLogs(SERVICE_LOGGER, "INFO") as logs: + new = self.client.get(url(self.course.id)) + assert legacy.status_code == 500 + assert_refused(new, 404) + (record,) = logs.output + assert str(self.course.id) in record + + def test_a_bearer_token(self): + user = UserFactory(is_staff=True) + token = AccessTokenFactory(user=user, application=ApplicationFactory(user=user)).token + header = {"HTTP_AUTHORIZATION": f"Bearer {token}"} + assert APIClient().get(legacy_url(self.course.id), **header).status_code == 200 + assert_refused(APIClient().get(url(self.course.id), **header), 401) + + def test_a_slash_separated_course_key(self): + """v1 routes the slash form and reports the course unknown in JSON; v2 has no route for it.""" + legacy = self.client.get("/api/contentstore/v1/course_settings/edX/NoSuch/Course") + new = self.client.get("/api/authoring/v2/courses/edX/NoSuch/Course/settings/") + assert legacy.status_code == new.status_code == 404 + assert legacy.json()["error_code"] == "course_does_not_exist" + assert new["Content-Type"].startswith("text/html") + + +# --------------------------------------------------------------------------- +# Parity with v1 +# --------------------------------------------------------------------------- + +# Members of a response body that differ between v1 and v2 for the same +# caller and course. The comparisons assert nothing else differs, which of +# these each fixture produces, and (with every feature on) that each occurs. +INTENTIONAL_DIFFERENCES = [ + ("content_experiments_enabled", "added: whether content experiments apply to the course, which the v1 " + "group configurations read reports as should_show_experiment_groups"), + ("language_options", "returned only with view=full"), + ("possible_pre_requisite_courses", "returned only with view=full"), + ("credit_requirements", "returned only with view=full"), + ("type", "errors use the standardized envelope"), + ("title", "errors use the standardized envelope"), + ("status", "errors use the standardized envelope"), + ("detail", "errors use the standardized envelope"), + ("developer_message", "the v1 error body carries developer_message; the envelope does not"), + ("error_code", "the v1 not-found body carries error_code; the envelope does not"), +] + + +def _undeclared(changed): + """The changed paths no declared intentional difference covers.""" + return undeclared_paths(changed, INTENTIONAL_DIFFERENCES) + + +def test_only_declared_differences_are_tolerated(): + assert _undeclared({"content_experiments_enabled", "possible_pre_requisite_courses[0].url", "detail"}) == set() + assert _undeclared({"is_credit_course", "upgrade_deadline", "language"}) == { + "is_credit_course", "upgrade_deadline", "language", + } + + +def test_roots_are_the_top_level_members(): + assert roots({"a", "b.c", "d[0].e", "f[1]"}) == {"a", "b", "d", "f"} + + +class _ParityMixin: + """ + v2 against v1 for the same course and caller: the full view is the v1 + body plus content_experiments_enabled, the minimal view is that without + the heavy members, and errors differ only by the declared differences. + """ + + def reader_for(self, courses): + """Return a user who may view each of ``courses`` in Studio.""" + raise NotImplementedError + + def outsider(self): + """Return a signed-in user who may not view the course.""" + raise NotImplementedError + + def setUp(self): + super().setUp() + self.course = create_course(display_name="Parity") + self.other_course = create_course(display_name="Other") + self.reader = self.reader_for([self.course, self.other_course]) + self.observed = set() + + def compare(self, legacy, new, expected_status): + """Assert both statuses and that the bodies differ only as declared; return both bodies.""" + assert legacy.status_code == expected_status, legacy.content + assert new.status_code == expected_status, new.content + legacy_body, new_body = json.loads(legacy.content or b"null"), json.loads(new.content or b"null") + changed = changed_paths(legacy_body, new_body) + assert _undeclared(changed) == set(), f"undeclared differences: legacy={legacy_body} new={new_body}" + self.observed |= changed + return legacy_body, new_body + + def assert_parity(self, reader=None): + """Compare both views with v1 for ``reader``; return the v1 body.""" + client = api_client(reader or self.reader) + legacy = client.get(legacy_url(self.course.id)) + legacy_body, full = self.compare(legacy, client.get(url(self.course.id), FULL), 200) + assert changed_paths(legacy_body, full) == {"content_experiments_enabled"} + _, minimal = self.compare(legacy, client.get(url(self.course.id)), 200) + assert roots(changed_paths(legacy_body, minimal)) == {"content_experiments_enabled"} | ( + HEAVY_FIELDS & set(legacy_body) + ) + assert minimal == {name: value for name, value in full.items() if name not in HEAVY_FIELDS} + return legacy_body + + def test_both_views_match_v1(self): + legacy_body = self.assert_parity() + assert "language_options" in legacy_body + + def test_global_staff_get_what_v1_gives_them(self): + self.assert_parity(UserFactory(is_staff=True)) + + def test_content_experiments_enabled_is_the_v1_group_configurations_flag(self): + staff = api_client(UserFactory(is_staff=True)) + address = reverse(GROUP_CONFIGURATIONS_URL_NAME, kwargs={"course_id": str(self.course.id)}) + for turned_on in (False, True): + if turned_on: + turn_on_content_experiments(self.course.id) + body = api_client(self.reader).get(url(self.course.id)).json() + group_configurations = staff.get(address) + assert group_configurations.status_code == 200, group_configurations.content + assert body["content_experiments_enabled"] is turned_on + assert group_configurations.json()["should_show_experiment_groups"] is turned_on + self.assert_parity() + + @override_settings(ENABLE_PUBLISHER=True) + def test_a_course_managed_by_a_publishing_service(self): + legacy_body = self.assert_parity() + assert legacy_body["about_page_editable"] is False + assert legacy_body["enrollment_end_editable"] is False + staff_body = self.assert_parity(UserFactory(is_staff=True)) + assert staff_body["enrollment_end_editable"] is True + + def test_a_course_with_a_verified_mode_deadline(self): + CourseModeFactory( + course_id=self.course.id, + mode_slug=CourseMode.VERIFIED, + expiration_datetime=datetime(2030, 1, 1, tzinfo=UTC), + ) + legacy_body = self.assert_parity() + assert legacy_body["upgrade_deadline"] == "2030-01-01T00:00:00+00:00" + + def _error_pairs(self): + """``(legacy, new, status)`` for each error both addresses produce, on both views.""" + for client, course_key, status in ( + (api_client(), self.course.id, 401), + (api_client(self.outsider()), self.course.id, 403), + (api_client(self.outsider()), UNKNOWN_COURSE_KEY, 404), + ): + legacy = client.get(legacy_url(course_key)) + for params in ({}, FULL): + yield legacy, client.get(url(course_key), params), status + + def test_errors_differ_only_by_the_envelope(self): + for legacy, new, status in self._error_pairs(): + self.compare(legacy, new, status) + + +class _AllFeaturesParityMixin(_ParityMixin): + """ + Parity with prerequisite courses on and a credit course, so every member + v1 can return is returned, and every declared difference occurs. + """ + + def setUp(self): + super().setUp() + make_credit_course(self.course.id) + + def test_the_lists_and_credit_requirements_match_v1(self): + legacy_body = self.assert_parity() + assert HEAVY_FIELDS <= set(legacy_body) + keys = [course["course_key"] for course in legacy_body["possible_pre_requisite_courses"]] + assert keys == [str(self.other_course.id)] + assert list(legacy_body["credit_requirements"]) == ["grade"] + + def test_a_credit_course_without_a_minimum_grade_warns(self): + course = modulestore().get_course(self.course.id) + course.minimum_grade_credit = 0 + modulestore().update_item(course, ModuleStoreEnum.UserID.test) + assert self.assert_parity()["show_min_grade_warning"] is True + + def test_courses_being_created_as_reruns_are_not_prerequisites(self): + """Global staff list every course; one whose rerun is still running is left out by both.""" + staff = UserFactory(is_staff=True) + rerun = create_course(display_name="Rerun in progress") + CourseRerunState.objects.initiated(self.other_course.id, rerun.id, staff, "Rerun in progress") + legacy_body = self.assert_parity(staff) + keys = {course["course_key"] for course in legacy_body["possible_pre_requisite_courses"]} + assert str(self.other_course.id) in keys + assert str(rerun.id) not in keys + + def test_every_declared_difference_occurs(self): + self.assert_parity() + for legacy, new, status in self._error_pairs(): + self.compare(legacy, new, status) + missing = [ + path for path, _ in INTENTIONAL_DIFFERENCES + if not any(key == path or key.startswith(f"{path}.") or key.startswith(f"{path}[") for key in self.observed) + ] + assert missing == [] + + +class _LegacyPermissionsReaders: + """Readers and outsiders with policy-based authoring permissions off.""" + + def reader_for(self, courses): + reader = UserFactory() + for course in courses: + CourseStaffRole(course.id).add_users(reader) + return reader + + def outsider(self): + return UserFactory() + + +class _AuthzReaders: + """Readers and outsiders with policy-based authoring permissions on.""" + + def reader_for(self, courses): + reader = UserFactory() + for course in courses: + self.add_user_to_role_in_course(reader, COURSE_STAFF.external_key, course.id) + return reader + + def outsider(self): + return self.unauthorized_user + + +class SettingsParityLegacyPermissionsTest(_LegacyPermissionsReaders, _ParityMixin, ModuleStoreTestCase): + """Parity with prerequisite courses off and a course that is not a credit course.""" + + +class SettingsParityAuthzTest(_AuthzReaders, _ParityMixin, CourseAuthoringAuthzTestMixin, ModuleStoreTestCase): + """As above, with policy-based authoring permissions on.""" + + +@PREREQUISITES_ON +@override_settings(ENABLE_CREDIT_ELIGIBILITY=True) +class SettingsParityAllFeaturesLegacyPermissionsTest( + _LegacyPermissionsReaders, _AllFeaturesParityMixin, ModuleStoreTestCase, +): + """Parity with prerequisite courses and credit eligibility on, for a credit course.""" + + +@PREREQUISITES_ON +@override_settings(ENABLE_CREDIT_ELIGIBILITY=True) +class SettingsParityAllFeaturesAuthzTest( + _AuthzReaders, _AllFeaturesParityMixin, CourseAuthoringAuthzTestMixin, ModuleStoreTestCase, +): + """As above, with policy-based authoring permissions on.""" + + +@override_settings(ENABLE_CREDIT_ELIGIBILITY=False) +class SettingsParityCreditOffLegacyPermissionsTest(_LegacyPermissionsReaders, _ParityMixin, ModuleStoreTestCase): + """Parity with credit eligibility off: a registered credit course is reported as not one.""" + + def test_a_registered_credit_course_is_not_reported(self): + make_credit_course(self.course.id) + legacy_body = self.assert_parity() + assert (legacy_body["credit_eligibility_enabled"], legacy_body["is_credit_course"]) == (False, False) + assert "credit_requirements" not in legacy_body + + +@override_settings(ENABLE_CREDIT_ELIGIBILITY=False) +class SettingsParityCreditOffAuthzTest( + _AuthzReaders, _ParityMixin, CourseAuthoringAuthzTestMixin, ModuleStoreTestCase, +): + """As above, with policy-based authoring permissions on.""" + + def test_a_registered_credit_course_is_not_reported(self): + make_credit_course(self.course.id) + legacy_body = self.assert_parity() + assert (legacy_body["credit_eligibility_enabled"], legacy_body["is_credit_course"]) == (False, False) + assert "credit_requirements" not in legacy_body + + +# --------------------------------------------------------------------------- +# Side effects of reads +# --------------------------------------------------------------------------- + +# What a read may write on a database that has never served a role or +# permission lookup: nothing, or the permission library's policy version row. +FIRST_READ_EFFECTS = (([], []), (FIRST_LOOKUP_WRITES, FIRST_LOOKUP_SIGNALS)) +READS = [("get", {}), ("get", FULL), ("head", {}), ("head", FULL)] + + +class _ReadsAreSideEffectFreeMixin: + """ + Every read (both views, GET and HEAD) by course staff, by global staff and + by a course reader whose role records send the course listing to its + every-course fallback writes no row and no Mongo document, stores nothing + through the modulestore and sends no signal or event. On a database that + has never served a role or permission lookup, a read may insert the + permission library's one policy version row, and does exactly what the v1 + read does. + + The course is a credit course with a verified mode, and another course has + a rerun still running, so every lookup a read can make has a row to find. + """ + + # Whether prerequisite courses are on, so that a read lists courses. + lists_prerequisites = False + + def reader_for(self, courses): + """Return a user who may view each of ``courses`` in Studio.""" + raise NotImplementedError + + def setUp(self): + super().setUp() + self.course = create_course() + self.other_course = create_course() + make_credit_course(self.course.id) + CourseModeFactory( + course_id=self.course.id, + mode_slug=CourseMode.VERIFIED, + expiration_datetime=datetime(2030, 1, 1, tzinfo=UTC), + ) + self.rerun = create_course() + CourseRerunState.objects.initiated(self.other_course.id, self.rerun.id, UserFactory(), "Rerun in progress") + + def readers(self): + """ + Return the readers by name. The fallback reader also holds a staff role + record with neither a course nor an organization, which makes the + course listing consider every course and check the running reruns + against the caller's permission, as it does for global staff. + """ + fallback = self.reader_for([self.course, self.other_course]) + CourseAccessRole.objects.create(user=fallback, role=CourseStaffRole.ROLE, org="", course_id=None) + return { + "course reader": self.reader_for([self.course, self.other_course]), + "global staff": UserFactory(is_staff=True), + "fallback reader": fallback, + } + + def test_each_reader_reaches_its_course_listing_branch(self): + """ + With prerequisite courses on, the course listing checks the running + rerun against the permission of global staff and of the fallback + reader, and not of the course reader, whose courses come from its + roles. With them off, no read lists courses. + """ + listing_checks = {"course reader": False, "global staff": True, "fallback reader": True} + reads = ((url(self.course.id), FULL), (url(self.course.id), {}), (legacy_url(self.course.id), {})) + for name, reader in self.readers().items(): + for address, params in reads: + with ( + patch.object(course_views, "get_courses_accessible_to_user", + wraps=course_views.get_courses_accessible_to_user) as listing, + patch.object(course_views, "get_in_process_course_actions", + wraps=course_views.get_in_process_course_actions) as running, + patch.object(course_views, "user_has_course_permission", + wraps=course_views.user_has_course_permission) as row_check, + ): + response = api_client(reader).get(address, params) + assert response.status_code == 200, (name, address, response.content) + assert listing.call_count == int(self.lists_prerequisites), (name, address) + checks_rows = self.lists_prerequisites and listing_checks[name] + assert running.call_count == int(checks_rows), (name, address) + checked = [call.args[2] for call in row_check.call_args_list] + assert checked == ([self.rerun.id] if checks_rows else []), (name, address) + + def _read_on_a_fresh_database(self, client, address, method, params): + """Record the effects of one read made after the policy version row is removed.""" + PolicyCacheControl.objects.all().delete() + with record_effects(self) as effects: + response = getattr(client, method)(address, params) + assert response.status_code == 200, response.content + return effects + + def test_reads_have_no_side_effects(self): + for name, reader in self.readers().items(): + client = api_client(reader) + legacy = self._read_on_a_fresh_database(client, legacy_url(self.course.id), "get", {}) + first_effects = (legacy["sql_writes"], legacy["signals"]) + assert first_effects in FIRST_READ_EFFECTS, name + for method, params in READS: + read = (name, method, params) + first = self._read_on_a_fresh_database(client, url(self.course.id), method, params) + assert (first["sql_writes"], first["signals"]) == first_effects, read + with record_effects(self) as again: + response = getattr(client, method)(url(self.course.id), params) + assert response.status_code == 200, (read, response.content) + assert again["sql_writes"] == [], read + assert again["signals"] == [], read + for effects in (first, again): + assert effects["events"] == [], read + assert effects["mongo_writes"] == 0, read + assert effects["update_item"] == [], read + + def test_a_course_without_an_overview_is_read_without_building_one(self): + """Building a course overview saves it and sends signals, which a read must not do; v1 builds none either.""" + course = CourseFactory.create() + client = api_client(UserFactory(is_staff=True)) + for params in ({}, FULL): + assert client.get(url(course.id), params).status_code == 200 + assert not CourseOverview.objects.filter(id=course.id).exists() + assert client.get(legacy_url(course.id)).status_code == 200 + assert not CourseOverview.objects.filter(id=course.id).exists() + + +class SettingsReadSideEffectsLegacyPermissionsTest( + _LegacyPermissionsReaders, _ReadsAreSideEffectFreeMixin, ModuleStoreTestCase, +): + """Reads with policy-based authoring permissions off and prerequisite courses off.""" + + +@PREREQUISITES_ON +class SettingsReadSideEffectsPrerequisitesLegacyPermissionsTest( + _LegacyPermissionsReaders, _ReadsAreSideEffectFreeMixin, ModuleStoreTestCase, +): + """Reads with policy-based authoring permissions off and prerequisite courses on.""" + + lists_prerequisites = True + + +class SettingsReadSideEffectsAuthzTest( + _AuthzReaders, _ReadsAreSideEffectFreeMixin, CourseAuthoringAuthzTestMixin, ModuleStoreTestCase, +): + """Reads with policy-based authoring permissions on and prerequisite courses off.""" + + +@PREREQUISITES_ON +class SettingsReadSideEffectsPrerequisitesAuthzTest( + _AuthzReaders, _ReadsAreSideEffectFreeMixin, CourseAuthoringAuthzTestMixin, ModuleStoreTestCase, +): + """Reads with policy-based authoring permissions on and prerequisite courses on.""" + + lists_prerequisites = True + + +# --------------------------------------------------------------------------- +# Query counts +# --------------------------------------------------------------------------- + +class _QueryCountMixin: + """ + Each view costs no more SQL queries or Mongo reads than the v1 read, with + prerequisite courses on and a credit course, by course staff and global + staff. + """ + + def reader_for(self, courses): + """Return a user who may view each of ``courses`` in Studio.""" + raise NotImplementedError + + def test_each_view_costs_no_more_than_the_v1_read(self): + course, other_course = create_course(), create_course() + make_credit_course(course.id) + for reader in (self.reader_for([course, other_course]), UserFactory(is_staff=True)): + client = api_client(reader) + legacy = measure(lambda client=client: client.get(legacy_url(course.id))) + for params in ({}, FULL): + new = measure(lambda client=client, params=params: client.get(url(course.id), params)) + assert new[0] <= legacy[0], (reader.is_staff, params, new, legacy) + assert new[1] <= legacy[1], (reader.is_staff, params, new, legacy) + assert new[2] == legacy[2] == 0, (reader.is_staff, params, new, legacy) + + +@PREREQUISITES_ON +class SettingsQueryCountLegacyPermissionsTest(_LegacyPermissionsReaders, _QueryCountMixin, ModuleStoreTestCase): + """Costs with policy-based authoring permissions off.""" + + +@PREREQUISITES_ON +class SettingsQueryCountAuthzTest( + _AuthzReaders, CourseAuthoringAuthzTestMixin, _QueryCountMixin, ModuleStoreTestCase, +): + """Costs with policy-based authoring permissions on.""" + + +# --------------------------------------------------------------------------- +# Addresses and methods +# --------------------------------------------------------------------------- + +@ddt.ddt +class SettingsRoutingTest(SharedModuleStoreTestCase): + """The address, the v1 address, and requests no route or method serves.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + + def test_the_address(self): + assert url(COURSE_KEY) == f"/api/authoring/v2/courses/{COURSE_KEY}/settings/" + match = resolve(url(COURSE_KEY)) + assert match.func.cls is CourseSettingsViewSet + assert routed_actions(match.func) == {"get": "retrieve"} + + def test_the_v1_address_is_unchanged(self): + assert legacy_url(COURSE_KEY) == f"/api/contentstore/v1/course_settings/{COURSE_KEY}" + view = resolve(legacy_url(COURSE_KEY)).func.cls + assert (view.__module__, view.__name__) == LEGACY_VIEW + + def test_v1_still_takes_a_slash_separated_key_that_v2_does_not(self): + view = resolve("/api/contentstore/v1/course_settings/edX/DemoX/Demo_Course").func.cls + assert (view.__module__, view.__name__) == LEGACY_VIEW + with pytest.raises(Resolver404): + resolve("/api/authoring/v2/courses/edX/DemoX/Demo_Course/settings/") + + def test_the_url_names_are_unique_in_the_namespace(self): + # Imported here: at module import the URL converters are not registered yet. + from cms.djangoapps.contentstore.rest_api.v2 import authoring_urls # noqa: PLC0415 + names = [pattern.name for pattern in authoring_urls.urlpatterns] + assert len(names) == len(set(names)) + assert {"course_settings", "course_rerun_defaults"} <= set(names) + + # The error library does not catalogue MethodNotAllowed or NotAcceptable, so + # these answer with the envelope typed internal. Change the expected type + # here once it does. + + @ddt.data("post", "put", "patch", "delete") + def test_other_methods_are_not_allowed(self, method): + response = getattr(api_client(UserFactory(is_staff=True)), method)(url(self.course.id), {}, format="json") + assert_error_envelope(response, expected_status=405, expected_type_slug="internal") + + def test_unsupported_accept_is_refused_with_406(self): + response = api_client(UserFactory(is_staff=True)).get(url(self.course.id), HTTP_ACCEPT="application/xml") + assert_error_envelope(response, expected_status=406, expected_type_slug="internal") + + # These addresses match no route, so the service's HTML 404 page answers + # them. Change these to the error envelope once a catch-all route under the + # mount answers unknown addresses. + + @ddt.data("not-a-course-key", "edX/DemoX/Demo_Course", "edX+DemoX+Demo_Course") + def test_an_unparsable_or_slash_form_key_is_an_html_404(self, key): + response = api_client(UserFactory(is_staff=True)).get(f"/api/authoring/v2/courses/{key}/settings/") + assert response.status_code == 404 + assert response["Content-Type"].startswith("text/html") + + def test_the_address_without_its_trailing_slash_redirects_to_it(self): + """No route answers the address without its slash; the service redirects a read to the slashed one.""" + address = f"/api/authoring/v2/courses/{self.course.id}/settings" + response = api_client(UserFactory(is_staff=True)).get(address) + assert response.status_code == 301 + assert response["Location"] == url(self.course.id) + with pytest.raises(Resolver404): + resolve(address) + + +# --------------------------------------------------------------------------- +# The published schema +# --------------------------------------------------------------------------- + +class SettingsSchemaTest(TestCase): + """The read as published in the CMS authoring schema.""" + + PATH = "/api/authoring/v2/courses/{course_key}/settings/" + LEGACY = "/api/contentstore/v1/course_settings/{course_id}" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.schema = published_schema() + cls.components = cls.schema["components"]["schemas"] + + @staticmethod + def _ref(node): + return node["content"]["application/json"]["schema"]["$ref"].rsplit("/", 1)[-1] + + def test_the_read_is_published_for_the_sdk(self): + operations = self.schema["paths"][self.PATH] + assert sorted(operations) == ["get"] + operation = operations["get"] + assert operation["operationId"] == "v2_courses_settings_retrieve" + assert operation["tags"] == ["openedx-platform-sdk"] + assert "deprecated" not in operation + assert self._ref(operation["responses"]["200"]) == "CourseSettings.v2" + for status in ("400", "401", "403", "404"): + assert self._ref(operation["responses"][status]) == "ErrorResponse", status + + def test_the_parameters(self): + parameters = {p["name"]: p for p in self.schema["paths"][self.PATH]["get"]["parameters"]} + assert set(parameters) == {"course_key", "view"} + assert parameters["course_key"]["in"] == "path" + assert parameters["course_key"]["schema"] == {"type": "string", "pattern": "^[^/]+$"} + assert parameters["view"]["in"] == "query" + assert not parameters["view"].get("required") + assert parameters["view"]["schema"] == {"type": "string", "enum": ["full", "minimal"], "default": "minimal"} + + def test_the_settings_component(self): + component = self.components["CourseSettings.v2"] + assert set(component["properties"]) == MINIMAL_FIELDS | HEAVY_FIELDS + assert set(component["required"]) == MINIMAL_FIELDS + nullable = {name for name, field in component["properties"].items() if field.get("nullable")} + assert nullable == {"course_display_name", "upgrade_deadline"} + prerequisites = component["properties"]["possible_pre_requisite_courses"] + assert prerequisites["items"] == {"$ref": "#/components/schemas/PrerequisiteCourse.v2"} + requirements = component["properties"]["credit_requirements"]["additionalProperties"] + assert requirements["items"] == {"$ref": "#/components/schemas/CreditRequirement.v2"} + + def test_the_prerequisite_course_component(self): + component = self.components["PrerequisiteCourse.v2"] + assert set(component["properties"]) == PREREQUISITE_MEMBERS + assert set(component["required"]) == PREREQUISITE_MEMBERS + nullable = {name for name, field in component["properties"].items() if field.get("nullable")} + assert nullable == {"display_name", "lms_link", "rerun_link", "url"} + + def test_every_member_is_described(self): + for name in ("CourseSettings.v2", "PrerequisiteCourse.v2", "CreditRequirement.v2"): + component = self.components[name] + assert component.get("description"), name + properties = component["properties"] + assert [field for field, spec in properties.items() if not spec.get("description")] == [], name + + def test_the_examples_are_valid_bodies(self): + content = self.schema["paths"][self.PATH]["get"]["responses"]["200"]["content"]["application/json"] + examples = content["examples"] + assert set(examples) == {"CourseSettings", "CourseSettingsWithLists"} + assert set(examples["CourseSettings"]["value"]) == MINIMAL_FIELDS + for example in examples.values(): + assert_matches_published_schema(example["value"], "CourseSettings.v2") + + def test_the_v1_read_is_deprecated_and_left_out_of_the_sdk(self): + operations = self.schema["paths"][self.LEGACY] + assert sorted(operations) == ["get"] + assert operations["get"]["operationId"] == "v1_course_settings_retrieve" + assert operations["get"]["deprecated"] is True + assert "openedx-platform-sdk" not in operations["get"].get("tags", []) + + def test_only_superseded_or_migrated_addresses_are_deprecated(self): + """No operation is marked deprecated except by the legacy-address lists, so a stray mark is caught.""" + deprecated = { + (path, method) + for path, operations in self.schema["paths"].items() + for method, operation in operations.items() + if isinstance(operation, dict) and operation.get("deprecated") + } + assert (self.LEGACY, "get") in deprecated + assert (self.PATH, "get") not in deprecated + stray = sorted( + (path, method) for path, method in deprecated + if path not in SUPERSEDED_PATHS and not path.startswith(LEGACY_MIGRATED_PATH_PREFIXES) + ) + assert stray == [] diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_rerun_defaults.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_rerun_defaults.py new file mode 100644 index 000000000000..49ff1afb01b3 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_rerun_defaults.py @@ -0,0 +1,802 @@ +"""Tests for the v2 course rerun defaults API.""" + +import json +from itertools import product +from types import SimpleNamespace +from unittest.mock import patch + +import ddt +import pytest +from django.contrib.auth.models import User +from django.test import SimpleTestCase, TestCase +from django.test.utils import override_settings +from django.urls import Resolver404, resolve, reverse +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from edx_rest_framework_extensions.permissions import NotJwtRestrictedApplication +from edx_rest_framework_extensions.testing import assert_error_envelope +from openedx_authz.constants.roles import COURSE_ADMIN, COURSE_AUDITOR, COURSE_EDITOR, COURSE_STAFF +from openedx_authz.models.engine import PolicyCacheControl +from rest_framework.permissions import IsAdminUser, IsAuthenticated +from rest_framework.test import APIClient + +from cms.djangoapps.contentstore.rest_api.v1.views.tests.helpers import ( + FIRST_LOOKUP_SIGNALS, + FIRST_LOOKUP_WRITES, + UNKNOWN_COURSE_KEY, + VERSION_ONLY_KEY, + api_client, + assert_matches_published_schema, + assert_refused, + changed_paths, + measure, + published_schema, + record_effects, + undeclared_paths, +) +from cms.djangoapps.contentstore.rest_api.v2.views.rerun_defaults import CourseRerunDefaultsViewSet +from cms.djangoapps.contentstore.rest_api.v2.views.tests.helpers import PASSWORD, logged_in_client +from cms.djangoapps.contentstore.tests.utils import routed_actions +from cms.djangoapps.contentstore.views import course as course_views +from cms.djangoapps.contentstore.views.permissions import IsExistingCourse +from cms.djangoapps.course_creators.models import CourseCreator +from cms.lib.spectacular import LEGACY_MIGRATED_PATH_PREFIXES, SUPERSEDED_PATHS +from common.djangoapps.student.models import CourseAccessRole +from common.djangoapps.student.roles import ( + CourseInstructorRole, + CourseLimitedStaffRole, + CourseStaffRole, + GlobalStaff, + OrgInstructorRole, + OrgStaffRole, +) +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.mixed import MixedModuleStore +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory + +URL_NAME = "authoring_v2:course_rerun_defaults" +LEGACY_URL_NAME = "cms.djangoapps.contentstore:v1:course_rerun" +LEGACY_VIEW = ("cms.djangoapps.contentstore.rest_api.v1.views.course_rerun", "CourseRerunView") +SERVICE_LOGGER = "cms.djangoapps.contentstore.rest_api.v2.rerun_defaults_service" +COURSE_KEY = "course-v1:edX+DemoX+Demo_Course" +FIELDS = {"allow_unicode_course_id", "course_creator_status", "display_name", "number", "org", "run"} + + +def url(course_key): + """Return the rerun defaults address of ``course_key``.""" + return reverse(URL_NAME, kwargs={"course_key": str(course_key)}) + + +def legacy_url(course_key): + """Return the deprecated course rerun address of ``course_key``.""" + return reverse(LEGACY_URL_NAME, kwargs={"course_id": str(course_key)}) + + +def non_staff_superuser(): + """ + Return a superuser without staff status. + + Saving a superuser gives it staff status (the student app's + ``grant_staff_access_to_superusers`` receiver), so staff status is cleared + with an update, which sends no save signal. + """ + user = UserFactory(is_superuser=True) + User.objects.filter(pk=user.pk).update(is_staff=False) + user.refresh_from_db() + assert (user.is_superuser, user.is_staff) == (True, False) + return user + + +def expected_body(course, allow_unicode_course_id=False): + """The rerun defaults of ``course`` as global staff reads them.""" + return { + "allow_unicode_course_id": allow_unicode_course_id, + "course_creator_status": "granted", + "display_name": course.display_name, + "number": course.id.course, + "org": course.id.org, + "run": course.id.run, + } + + +# --------------------------------------------------------------------------- +# Who may call the endpoint +# --------------------------------------------------------------------------- + +class _AccessMixin: + """Checks a caller's status on a read, its body or envelope, and that v1 decides the same.""" + + def assert_read(self, user, expected, course_key=None): + """Assert the status of a read by ``user`` and agreement with the v1 read.""" + course_key = course_key or self.course.id + response = api_client(user).get(url(course_key)) + legacy = api_client(user).get(legacy_url(course_key)) + assert response.status_code == expected, response.content + if expected == 200: + assert response.json() == expected_body(self.course) + else: + assert_refused(response, expected) + assert legacy.status_code == expected, f"v1 decided {legacy.status_code} for the same caller" + + +def _with_role(role_factory): + """Return ``(name, function)`` giving a new user ``role_factory(course)`` on a course.""" + def give(course): + user = UserFactory() + role_factory(course).add_users(user) + return user + return give + + +COURSE_ROLES = { + "course staff": _with_role(lambda course: CourseStaffRole(course.id)), + "course instructor": _with_role(lambda course: CourseInstructorRole(course.id)), + "course limited staff": _with_role(lambda course: CourseLimitedStaffRole(course.id)), + "org staff": _with_role(lambda course: OrgStaffRole(course.id.org)), + "org instructor": _with_role(lambda course: OrgInstructorRole(course.id.org)), +} + + +@ddt.ddt +class RerunDefaultsAccessLegacyPermissionsTest(_AccessMixin, SharedModuleStoreTestCase): + """Who may read the defaults when policy-based authoring permissions are off: global staff only.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + cls.other_course = CourseFactory.create() + + def test_anonymous_caller_is_refused_with_401(self): + self.assert_read(None, 401) + + def test_anonymous_caller_of_an_unknown_course_is_refused_with_401(self): + self.assert_read(None, 401, course_key=UNKNOWN_COURSE_KEY) + + def test_user_without_a_role_is_refused_with_403(self): + self.assert_read(UserFactory(), 403) + + def test_unknown_course_is_404_before_permission_is_checked(self): + self.assert_read(UserFactory(), 404, course_key=UNKNOWN_COURSE_KEY) + + @ddt.data(*COURSE_ROLES) + def test_every_course_and_org_role_is_refused(self, role): + self.assert_read(COURSE_ROLES[role](self.course), 403) + + @ddt.data(*COURSE_ROLES) + def test_a_role_on_another_course_is_refused(self, role): + self.assert_read(COURSE_ROLES[role](self.other_course), 403) + + def test_global_staff_may_read(self): + self.assert_read(UserFactory(is_staff=True), 200) + + def test_global_staff_learn_that_a_course_does_not_exist(self): + self.assert_read(UserFactory(is_staff=True), 404, course_key=UNKNOWN_COURSE_KEY) + + def test_a_superuser_without_staff_status_is_refused(self): + """Both addresses check staff status alone, which being a superuser does not give.""" + self.assert_read(non_staff_superuser(), 403) + + def test_a_saved_superuser_has_staff_status_and_may_read(self): + """ + The student app's ``grant_staff_access_to_superusers`` receiver gives + every superuser staff status when it is saved, so a superuser created + through the model is admitted by both addresses. + """ + user = UserFactory(is_superuser=True, is_staff=False) + assert user.is_staff is True + assert User.objects.get(pk=user.pk).is_staff is True + self.assert_read(user, 200) + + def test_a_version_only_course_key_is_404(self): + """The key parses but names no course run; v1 has no address that takes it.""" + assert_refused(api_client(UserFactory(is_staff=True)).get(url(VERSION_ONLY_KEY)), 404) + + def test_a_library_is_not_a_course(self): + library_key = LibraryFactory.create().location.library_key + assert_refused(api_client(UserFactory(is_staff=True)).get(url(library_key)), 404) + + +@ddt.ddt +class RerunDefaultsAccessAuthzTest(CourseAuthoringAuthzTestMixin, _AccessMixin, SharedModuleStoreTestCase): + """ + Who may read the defaults when policy-based authoring permissions are on: + still global staff only, as no course permission is consulted. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + + def _user_with(self, role): + user = UserFactory() + self.add_user_to_role_in_course(user, role, self.course.id) + return user + + def test_anonymous_caller_is_refused_with_401(self): + self.assert_read(None, 401) + + def test_user_without_a_role_is_refused_with_403(self): + self.assert_read(self.unauthorized_user, 403) + + def test_unknown_course_is_404_before_permission_is_checked(self): + self.assert_read(self.unauthorized_user, 404, course_key=UNKNOWN_COURSE_KEY) + + @ddt.data(COURSE_ADMIN.external_key, COURSE_STAFF.external_key, COURSE_EDITOR.external_key, + COURSE_AUDITOR.external_key) + def test_every_course_role_is_refused(self, role): + self.assert_read(self._user_with(role), 403) + + def test_legacy_course_instructor_is_refused(self): + user = UserFactory() + CourseAccessRole.objects.create(user=user, role="instructor", course_id=self.course.id, org=self.course.id.org) + self.assert_read(user, 403) + + def test_global_staff_may_read(self): + self.assert_read(self.staff_user, 200) + + def test_a_superuser_without_staff_status_is_refused(self): + """No course permission is consulted, so the policy admitting superusers plays no part on either address.""" + self.assert_read(non_staff_superuser(), 403) + + +class RerunDefaultsAuthenticationTest(SharedModuleStoreTestCase): + """How callers authenticate.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + + def test_a_jwt_caller_who_is_global_staff_may_read(self): + header = {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(UserFactory(is_staff=True))}"} + response = APIClient().get(url(self.course.id), **header) + assert response.status_code == 200, response.content + assert response.json() == expected_body(self.course) + + def test_a_jwt_caller_who_is_not_global_staff_is_refused(self): + user = UserFactory() + CourseInstructorRole(self.course.id).add_users(user) + header = {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(user)}"} + assert_refused(APIClient().get(url(self.course.id), **header), 403) + + def test_a_session_caller_who_is_global_staff_may_read(self): + client = logged_in_client(UserFactory(is_staff=True, password=PASSWORD)) + response = client.get(url(self.course.id)) + assert response.status_code == 200, response.content + assert response.json() == expected_body(self.course) + + def test_an_inactive_session_is_authenticated(self): + """ + A session whose account is not activated is accepted, as on v1. Global + staff then pass; course instructors are refused (403, not 401), as for + an active account. + """ + instructor = UserFactory(password=PASSWORD) + CourseInstructorRole(self.course.id).add_users(instructor) + for user, expected in ((UserFactory(is_staff=True, password=PASSWORD), 200), (instructor, 403)): + client = logged_in_client(user) + user.is_active = False + user.save() + response = client.get(url(self.course.id)) + assert response.status_code == expected, response.content + if expected == 403: + assert_refused(response, 403) + assert client.get(legacy_url(self.course.id)).status_code == expected + + def test_a_bearer_token_is_refused(self): + """Bearer tokens are not accepted here, though v1 takes them.""" + user = UserFactory(is_staff=True) + token = AccessTokenFactory(user=user, application=ApplicationFactory(user=user)).token + header = {"HTTP_AUTHORIZATION": f"Bearer {token}"} + assert_refused(APIClient().get(url(self.course.id), **header), 401) + assert APIClient().get(legacy_url(self.course.id), **header).status_code == 200 + + +class RerunDefaultsPermissionWiringTest(SimpleTestCase): + """The checks the endpoint runs, in order, and the staff check against the one v1 runs.""" + + def test_the_course_is_checked_before_staff_status(self): + view = CourseRerunDefaultsViewSet() + for action in ("retrieve", None, "metadata"): + view.action = action + # The JWT middleware appends NotJwtRestrictedApplication to views that take JWTs. + declared = [ + type(permission) for permission in view.get_permissions() + if not isinstance(permission, NotJwtRestrictedApplication) + ] + assert declared == [IsAuthenticated, IsExistingCourse, IsAdminUser], action + + def test_the_staff_check_decides_as_the_v1_check(self): + """v1 refuses unless ``GlobalStaff().has_user``; staff status alone decides both, whatever else is set.""" + for is_staff, is_superuser, is_active in product((True, False), repeat=3): + user = SimpleNamespace(is_staff=is_staff, is_superuser=is_superuser, is_active=is_active) + decided = IsAdminUser().has_permission(SimpleNamespace(user=user), view=None) + assert decided is GlobalStaff().has_user(user) is is_staff, (is_staff, is_superuser, is_active) + + def test_bearer_tokens_are_not_among_the_authentication_classes(self): + assert CourseRerunDefaultsViewSet.authentication_classes == ( + JwtAuthentication, SessionAuthenticationAllowInactiveUser, + ) + + +# --------------------------------------------------------------------------- +# Reads +# --------------------------------------------------------------------------- + +class RerunDefaultsReadTest(ModuleStoreTestCase): + """What a read by global staff returns.""" + + def setUp(self): + super().setUp() + self.course = CourseFactory.create(display_name="Rerun Me") + self.client = api_client(UserFactory(is_staff=True)) + + def get(self, course_key=None, **params): + response = self.client.get(url(course_key or self.course.id), params) + assert response.status_code == 200, response.content + return response.json() + + def test_the_body_is_the_course_key_parts_the_name_and_the_site_setting(self): + body = self.get() + assert body == expected_body(self.course) + assert body["display_name"] == "Rerun Me" + assert list(body) == sorted(FIELDS) + + @override_settings(ALLOW_UNICODE_COURSE_ID=True) + def test_the_site_setting_for_non_ascii_keys_is_reported_when_on(self): + assert self.get() == expected_body(self.course, allow_unicode_course_id=True) + assert self.client.get(legacy_url(self.course.id)).json()["allow_unicode_course_id"] is True + + @override_settings(ALLOW_UNICODE_COURSE_ID=False) + def test_the_site_setting_for_non_ascii_keys_is_reported_when_off(self): + assert self.get()["allow_unicode_course_id"] is False + assert self.client.get(legacy_url(self.course.id)).json()["allow_unicode_course_id"] is False + + def test_a_course_without_a_name_has_a_null_name(self): + course = modulestore().get_course(self.course.id) + course.display_name = None + modulestore().update_item(course, ModuleStoreEnum.UserID.test) + assert self.get()["display_name"] is None + assert self.client.get(legacy_url(self.course.id)).json()["display_name"] is None + + def test_the_body_matches_the_published_schema(self): + assert_matches_published_schema(self.get(), "CourseRerunDefaults.v2") + + def test_query_parameters_are_ignored(self): + assert self.get(view="full", fields="org") == self.get() + + def test_head_answers_without_a_body(self): + response = self.client.head(url(self.course.id)) + assert response.status_code == 200 + assert response.content == b"" + + +@override_settings(ENABLE_CREATOR_GROUP=True) +class RerunDefaultsCourseCreatorTest(ModuleStoreTestCase): + """ + Looking up whether the caller may create courses records a course creator + request for a caller who is not global staff. Such a caller is refused + before the lookup runs, so a read records nothing. + """ + + def setUp(self): + super().setUp() + self.course = CourseFactory.create() + self.instructor = UserFactory() + CourseInstructorRole(self.course.id).add_users(self.instructor) + + def test_the_lookup_records_a_request_for_a_caller_who_is_not_staff(self): + """The write the endpoint must never reach exists: a direct lookup makes it, and the recorder sees it.""" + with record_effects(self) as effects: + status = course_views._get_course_creator_status(self.instructor) # pylint: disable=protected-access + assert status == CourseCreator.UNREQUESTED + assert CourseCreator.objects.filter(user=self.instructor).exists() + assert ("INSERT", CourseCreator._meta.db_table) in effects["sql_writes"] # pylint: disable=protected-access + + def test_a_caller_who_is_not_staff_is_refused_before_the_lookup(self): + lookup = course_views._get_course_creator_status # pylint: disable=protected-access + with patch.object(course_views, "_get_course_creator_status", wraps=lookup) as spy: + assert_refused(api_client(self.instructor).get(url(self.course.id)), 403) + assert api_client(self.instructor).get(legacy_url(self.course.id)).status_code == 403 + spy.assert_not_called() + assert not CourseCreator.objects.filter(user=self.instructor).exists() + + def test_global_staff_are_granted_without_a_request_being_recorded(self): + staff = UserFactory(is_staff=True) + lookup = course_views._get_course_creator_status # pylint: disable=protected-access + with patch.object(course_views, "_get_course_creator_status", wraps=lookup) as spy: + response = api_client(staff).get(url(self.course.id)) + assert response.status_code == 200, response.content + assert response.json()["course_creator_status"] == "granted" + spy.assert_called_once_with(staff) + assert not CourseCreator.objects.filter(user=staff).exists() + + +# Requests v1 and v2 answer differently beyond the error body, as +# (request, v1 answer, v2 answer, test in RerunDefaultsResponseDifferencesTest). +RESPONSE_DIFFERENCES = [ + ("read of a course the modulestore returns nothing for", "500", "404", + "test_a_course_the_modulestore_returns_nothing_for"), + ("read with a Bearer token", "200", "401", "test_a_bearer_token"), + ("slash-separated course key", "404 JSON", "404 HTML", "test_a_slash_separated_course_key"), +] + + +class RerunDefaultsResponseDifferencesTest(ModuleStoreTestCase): + """Each request v1 and v2 answer differently, both answers observed.""" + + def setUp(self): + super().setUp() + self.course = CourseFactory.create() + self.client = api_client(UserFactory(is_staff=True)) + + def test_every_listed_difference_has_its_test(self): + assert [name for *_, name in RESPONSE_DIFFERENCES if not hasattr(self, name)] == [] + + def test_a_course_the_modulestore_returns_nothing_for(self): + with patch.object(MixedModuleStore, "get_course", return_value=None): + legacy = self.client.get(legacy_url(self.course.id)) + with self.assertLogs(SERVICE_LOGGER, "INFO") as logs: + new = self.client.get(url(self.course.id)) + assert legacy.status_code == 500 + assert_refused(new, 404) + (record,) = logs.output + assert str(self.course.id) in record + + def test_a_bearer_token(self): + user = UserFactory(is_staff=True) + token = AccessTokenFactory(user=user, application=ApplicationFactory(user=user)).token + header = {"HTTP_AUTHORIZATION": f"Bearer {token}"} + assert APIClient().get(legacy_url(self.course.id), **header).status_code == 200 + assert_refused(APIClient().get(url(self.course.id), **header), 401) + + def test_a_slash_separated_course_key(self): + """v1 routes the slash form and reports the course unknown in JSON; v2 has no route for it.""" + legacy = self.client.get("/api/contentstore/v1/course_rerun/edX/NoSuch/Course") + new = self.client.get("/api/authoring/v2/courses/edX/NoSuch/Course/rerun_defaults/") + assert legacy.status_code == new.status_code == 404 + assert legacy.json()["error_code"] == "course_does_not_exist" + assert new["Content-Type"].startswith("text/html") + + +# --------------------------------------------------------------------------- +# Parity with v1 +# --------------------------------------------------------------------------- + +# Members of a response body that differ between v1 and v2 for the same +# request. The comparison asserts nothing else differs, and that each of these +# occurs in the error cases below. +INTENTIONAL_DIFFERENCES = [ + ("type", "errors use the standardized envelope"), + ("title", "errors use the standardized envelope"), + ("status", "errors use the standardized envelope"), + ("detail", "errors use the standardized envelope"), + ("developer_message", "the v1 error body carries developer_message; the envelope does not"), + ("error_code", "the v1 not-found body carries error_code; the envelope does not"), +] + + +def _undeclared(changed): + """The changed paths no declared intentional difference covers.""" + return undeclared_paths(changed, INTENTIONAL_DIFFERENCES) + + +def test_only_declared_differences_are_tolerated(): + assert _undeclared({"type", "detail", "developer_message", "error_code"}) == set() + assert _undeclared({"display_name", "course_creator_status", "typed"}) == { + "display_name", "course_creator_status", "typed", + } + + +class _ParityMixin: + """ + v2 against v1 for the same course and caller: a read returns an identical + body, and errors differ only by the declared differences. + """ + + def outsider(self): + """Return a signed-in user who is not global staff.""" + raise NotImplementedError + + def setUp(self): + super().setUp() + self.course = CourseFactory.create(display_name="Parity") + self.reader = UserFactory(is_staff=True) + self.observed = set() + + def compare(self, legacy, new, expected_status): + """Assert both statuses and that the bodies differ only as declared; return both bodies.""" + assert legacy.status_code == expected_status, legacy.content + assert new.status_code == expected_status, new.content + legacy_body, new_body = json.loads(legacy.content or b"null"), json.loads(new.content or b"null") + changed = changed_paths(legacy_body, new_body) + assert _undeclared(changed) == set(), f"undeclared differences: legacy={legacy_body} new={new_body}" + self.observed |= changed + return legacy_body, new_body + + def test_a_read_returns_an_identical_body(self): + client = api_client(self.reader) + legacy_body, new_body = self.compare( + client.get(legacy_url(self.course.id)), client.get(url(self.course.id)), 200, + ) + assert changed_paths(legacy_body, new_body) == set() + assert list(new_body) == list(legacy_body) + assert new_body == expected_body(self.course) + + def _error_pairs(self): + """``(legacy, new, status)`` for each error both addresses produce.""" + for client, course_key, status in ( + (api_client(), self.course.id, 401), + (api_client(self.outsider()), self.course.id, 403), + (api_client(self.outsider()), UNKNOWN_COURSE_KEY, 404), + ): + yield client.get(legacy_url(course_key)), client.get(url(course_key)), status + + def test_errors_differ_only_by_the_envelope(self): + for legacy, new, status in self._error_pairs(): + self.compare(legacy, new, status) + + def test_every_declared_difference_occurs(self): + for legacy, new, status in self._error_pairs(): + self.compare(legacy, new, status) + missing = [ + path for path, _ in INTENTIONAL_DIFFERENCES + if not any(key == path or key.startswith(f"{path}.") or key.startswith(f"{path}[") for key in self.observed) + ] + assert missing == [] + + +class RerunDefaultsParityLegacyPermissionsTest(_ParityMixin, ModuleStoreTestCase): + """Parity with policy-based authoring permissions off; the outsider is a course instructor.""" + + def outsider(self): + user = UserFactory() + CourseInstructorRole(self.course.id).add_users(user) + return user + + +class RerunDefaultsParityAuthzTest(_ParityMixin, CourseAuthoringAuthzTestMixin, ModuleStoreTestCase): + """Parity with policy-based authoring permissions on; the outsider holds the course admin role.""" + + def outsider(self): + user = UserFactory() + self.add_user_to_role_in_course(user, COURSE_ADMIN.external_key, self.course.id) + return user + + +# --------------------------------------------------------------------------- +# Side effects of reads +# --------------------------------------------------------------------------- + +# What a read may write on a database that has never served a role or +# permission lookup: nothing, or the permission library's policy version row. +FIRST_READ_EFFECTS = (([], []), (FIRST_LOOKUP_WRITES, FIRST_LOOKUP_SIGNALS)) + + +class _ReadsAreSideEffectFreeMixin: + """ + A read (GET and HEAD) by global staff writes no row and no Mongo document, + stores nothing through the modulestore and sends no signal or event. On a + database that has never served a role or permission lookup, a read may + insert the permission library's one policy version row, and does exactly + what the v1 read does there. + """ + + def setUp(self): + super().setUp() + self.course = CourseFactory.create() + self.reader = UserFactory(is_staff=True) + + def _read_on_a_fresh_database(self, address, method="get"): + """Record the effects of one read made after the policy version row is removed.""" + PolicyCacheControl.objects.all().delete() + with record_effects(self) as effects: + response = getattr(api_client(self.reader), method)(address) + assert response.status_code == 200, response.content + return effects + + def assert_nothing_else(self, effects, label): + assert effects["events"] == [], label + assert effects["mongo_writes"] == 0, label + assert effects["update_item"] == [], label + + def test_reads_have_no_side_effects(self): + legacy = self._read_on_a_fresh_database(legacy_url(self.course.id)) + assert (legacy["sql_writes"], legacy["signals"]) in FIRST_READ_EFFECTS + for method in ("get", "head"): + first = self._read_on_a_fresh_database(url(self.course.id), method) + assert (first["sql_writes"], first["signals"]) == (legacy["sql_writes"], legacy["signals"]), method + self.assert_nothing_else(first, method) + with record_effects(self) as again: + response = getattr(api_client(self.reader), method)(url(self.course.id)) + assert response.status_code == 200, response.content + assert again["sql_writes"] == [], method + assert again["signals"] == [], method + self.assert_nothing_else(again, method) + + +class RerunDefaultsReadSideEffectsLegacyPermissionsTest(_ReadsAreSideEffectFreeMixin, ModuleStoreTestCase): + """Reads with policy-based authoring permissions off.""" + + +class RerunDefaultsReadSideEffectsAuthzTest( + _ReadsAreSideEffectFreeMixin, CourseAuthoringAuthzTestMixin, ModuleStoreTestCase, +): + """Reads with policy-based authoring permissions on.""" + + +# --------------------------------------------------------------------------- +# Query counts +# --------------------------------------------------------------------------- + +class _QueryCountMixin: + """A read costs no more SQL queries or Mongo reads than the v1 read.""" + + def test_a_read_costs_no_more_than_the_v1_read(self): + course = CourseFactory.create() + client = api_client(UserFactory(is_staff=True)) + legacy = measure(lambda: client.get(legacy_url(course.id))) + new = measure(lambda: client.get(url(course.id))) + assert new[0] <= legacy[0], (new, legacy) + assert new[1] <= legacy[1], (new, legacy) + assert new[2] == legacy[2] == 0, (new, legacy) + + +class RerunDefaultsQueryCountLegacyPermissionsTest(_QueryCountMixin, ModuleStoreTestCase): + """Costs with policy-based authoring permissions off.""" + + +class RerunDefaultsQueryCountAuthzTest(CourseAuthoringAuthzTestMixin, _QueryCountMixin, ModuleStoreTestCase): + """Costs with policy-based authoring permissions on.""" + + +# --------------------------------------------------------------------------- +# Addresses and methods +# --------------------------------------------------------------------------- + +@ddt.ddt +class RerunDefaultsRoutingTest(SharedModuleStoreTestCase): + """The address, the v1 address, and requests no route or method serves.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + + def test_the_address(self): + assert url(COURSE_KEY) == f"/api/authoring/v2/courses/{COURSE_KEY}/rerun_defaults/" + match = resolve(url(COURSE_KEY)) + assert match.func.cls is CourseRerunDefaultsViewSet + assert routed_actions(match.func) == {"get": "retrieve"} + + def test_the_v1_address_is_unchanged(self): + assert legacy_url(COURSE_KEY) == f"/api/contentstore/v1/course_rerun/{COURSE_KEY}" + view = resolve(legacy_url(COURSE_KEY)).func.cls + assert (view.__module__, view.__name__) == LEGACY_VIEW + + def test_v1_still_takes_a_slash_separated_key_that_v2_does_not(self): + view = resolve("/api/contentstore/v1/course_rerun/edX/DemoX/Demo_Course").func.cls + assert (view.__module__, view.__name__) == LEGACY_VIEW + with pytest.raises(Resolver404): + resolve("/api/authoring/v2/courses/edX/DemoX/Demo_Course/rerun_defaults/") + + def test_the_url_names_are_unique_in_the_namespace(self): + # Imported here: at module import the URL converters are not registered yet. + from cms.djangoapps.contentstore.rest_api.v2 import authoring_urls # noqa: PLC0415 + names = [pattern.name for pattern in authoring_urls.urlpatterns] + assert len(names) == len(set(names)) + assert "course_rerun_defaults" in names + + # The error library does not catalogue MethodNotAllowed or NotAcceptable, so + # these answer with the envelope typed internal. Change the expected type + # here once it does. + + @ddt.data("post", "put", "patch", "delete") + def test_other_methods_are_not_allowed(self, method): + response = getattr(api_client(UserFactory(is_staff=True)), method)(url(self.course.id), {}, format="json") + assert_error_envelope(response, expected_status=405, expected_type_slug="internal") + + def test_unsupported_accept_is_refused_with_406(self): + response = api_client(UserFactory(is_staff=True)).get(url(self.course.id), HTTP_ACCEPT="application/xml") + assert_error_envelope(response, expected_status=406, expected_type_slug="internal") + + # These addresses match no route, so the service's HTML 404 page answers + # them. Change these to the error envelope once a catch-all route under the + # mount answers unknown addresses. + + @ddt.data("not-a-course-key", "edX/DemoX/Demo_Course", "edX+DemoX+Demo_Course") + def test_an_unparsable_or_slash_form_key_is_an_html_404(self, key): + response = api_client(UserFactory(is_staff=True)).get(f"/api/authoring/v2/courses/{key}/rerun_defaults/") + assert response.status_code == 404 + assert response["Content-Type"].startswith("text/html") + + def test_the_address_without_its_trailing_slash_redirects_to_it(self): + """No route answers the address without its slash; the service redirects a read to the slashed one.""" + address = f"/api/authoring/v2/courses/{self.course.id}/rerun_defaults" + response = api_client(UserFactory(is_staff=True)).get(address) + assert response.status_code == 301 + assert response["Location"] == url(self.course.id) + with pytest.raises(Resolver404): + resolve(address) + + +# --------------------------------------------------------------------------- +# The published schema +# --------------------------------------------------------------------------- + +class RerunDefaultsSchemaTest(TestCase): + """The read as published in the CMS authoring schema.""" + + PATH = "/api/authoring/v2/courses/{course_key}/rerun_defaults/" + LEGACY = "/api/contentstore/v1/course_rerun/{course_id}" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.schema = published_schema() + cls.components = cls.schema["components"]["schemas"] + + @staticmethod + def _ref(node): + return node["content"]["application/json"]["schema"]["$ref"].rsplit("/", 1)[-1] + + def test_the_read_is_published_for_the_sdk(self): + operations = self.schema["paths"][self.PATH] + assert sorted(operations) == ["get"] + operation = operations["get"] + assert operation["operationId"] == "v2_courses_rerun_defaults_retrieve" + assert operation["tags"] == ["openedx-platform-sdk"] + assert "deprecated" not in operation + assert self._ref(operation["responses"]["200"]) == "CourseRerunDefaults.v2" + for status in ("401", "403", "404"): + assert self._ref(operation["responses"][status]) == "ErrorResponse", status + assert "400" not in operation["responses"] + + def test_the_only_parameter_is_the_course_key(self): + (parameter,) = self.schema["paths"][self.PATH]["get"]["parameters"] + assert (parameter["name"], parameter["in"]) == ("course_key", "path") + assert parameter["schema"] == {"type": "string", "pattern": "^[^/]+$"} + + def test_the_defaults_component(self): + component = self.components["CourseRerunDefaults.v2"] + assert set(component["properties"]) == FIELDS + assert set(component["required"]) == FIELDS + nullable = {name for name, field in component["properties"].items() if field.get("nullable")} + assert nullable == {"display_name"} + assert component.get("description") + assert [name for name, spec in component["properties"].items() if not spec.get("description")] == [] + + def test_the_example_is_a_valid_body(self): + content = self.schema["paths"][self.PATH]["get"]["responses"]["200"]["content"]["application/json"] + assert_matches_published_schema(content["examples"]["RerunDefaults"]["value"], "CourseRerunDefaults.v2") + + def test_the_v1_read_is_deprecated_and_left_out_of_the_sdk(self): + operations = self.schema["paths"][self.LEGACY] + assert sorted(operations) == ["get"] + assert operations["get"]["operationId"] == "v1_course_rerun_retrieve" + assert operations["get"]["deprecated"] is True + assert "openedx-platform-sdk" not in operations["get"].get("tags", []) + + def test_only_superseded_or_migrated_addresses_are_deprecated(self): + """No operation is marked deprecated except by the legacy-address lists, so a stray mark is caught.""" + deprecated = { + (path, method) + for path, operations in self.schema["paths"].items() + for method, operation in operations.items() + if isinstance(operation, dict) and operation.get("deprecated") + } + assert (self.LEGACY, "get") in deprecated + assert (self.PATH, "get") not in deprecated + stray = sorted( + (path, method) for path, method in deprecated + if path not in SUPERSEDED_PATHS and not path.startswith(LEGACY_MIGRATED_PATH_PREFIXES) + ) + assert stray == [] diff --git a/cms/djangoapps/contentstore/views/permissions.py b/cms/djangoapps/contentstore/views/permissions.py index 6450a3bfcc3e..b8763a6b930a 100644 --- a/cms/djangoapps/contentstore/views/permissions.py +++ b/cms/djangoapps/contentstore/views/permissions.py @@ -2,14 +2,22 @@ Custom permissions for the content store views. """ -from openedx_authz.constants.permissions import COURSES_VIEW_PAGES_AND_RESOURCES +from openedx_authz.constants.permissions import COURSES_VIEW_COURSE, COURSES_VIEW_PAGES_AND_RESOURCES +from rest_framework.exceptions import NotFound from rest_framework.permissions import BasePermission -from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access +from common.djangoapps.student.auth import ( + check_course_advanced_settings_access, + has_studio_read_access, + has_studio_write_access, +) from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission from openedx.core.djangoapps.authz.decorators import user_has_course_permission +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.lib.api.view_utils import validate_course_key +COURSE_NOT_FOUND_MESSAGE = "The requested course does not exist." + class HasStudioWriteAccess(BasePermission): """ @@ -58,3 +66,91 @@ def has_permission(self, request, view): if course_key is None: return False return has_studio_read_access(request.user, course_key) + + +class CanViewCourse(BasePermission): + """ + Allow callers who may view the course in the path in Studio. + + Reads the parsed ``course_key`` URL argument. A course that does not exist + is answered with 404 before permission is checked, so a signed-in caller + learns that a course is missing before being refused. It must follow + ``IsAuthenticated`` in ``permission_classes``; ahead of it, anonymous + callers would learn which courses exist. + + On courses where policy-based authoring permissions are enabled, only that + policy is consulted; elsewhere the caller needs Studio read access to the + course. + """ + + def has_permission(self, request, view): + course_key = view.kwargs.get("course_key") + if course_key is None: + return False + # A key naming only a version has no course run behind it, and the + # existence lookup raises for one. + if course_key.org is None or not CourseOverview.course_exists(course_key): + raise NotFound(COURSE_NOT_FOUND_MESSAGE) + return user_has_course_permission( + request.user, + COURSES_VIEW_COURSE.identifier, + course_key, + LegacyAuthoringPermission.READ, + ) + + +class IsExistingCourse(BasePermission): + """ + Answer 404 when the course in the path does not exist. + + Reads the parsed ``course_key`` URL argument. A key that names only a + content version parses, but no course run is behind it, so it is answered + as an unknown course. List this class after the authentication check, so + an anonymous caller learns nothing about which courses exist, and before + any class that checks roles on the course, so a signed-in caller is told + that a course is missing before being refused. + """ + + def has_permission(self, request, view): + course_key = view.kwargs.get("course_key") + if course_key is None: + return False + if course_key.org is None or not CourseOverview.course_exists(course_key): + raise NotFound(COURSE_NOT_FOUND_MESSAGE) + return True + + +class CanViewAdvancedSettings(BasePermission): + """ + Allow callers who may view the advanced settings of the course in the path. + + Reads the parsed ``course_key`` URL argument. On courses where + policy-based authoring permissions are enabled, only that policy is + consulted; elsewhere the caller needs Studio read access to the course. + Advanced settings being restricted to staff site-wide does not apply here: + course teams also change page and resource toggles through these settings. + """ + + def has_permission(self, request, view): + course_key = view.kwargs.get("course_key") + if course_key is None: + return False + return check_course_advanced_settings_access(request.user, course_key, access_type="read") + + +class CanManageAdvancedSettings(BasePermission): + """ + Allow callers who may change the advanced settings of the course in the path. + + Reads the parsed ``course_key`` URL argument. On courses where + policy-based authoring permissions are enabled, only that policy is + consulted; elsewhere the caller needs Studio write access to the course. + Advanced settings being restricted to staff site-wide does not apply here, + as for ``CanViewAdvancedSettings``. + """ + + def has_permission(self, request, view): + course_key = view.kwargs.get("course_key") + if course_key is None: + return False + return check_course_advanced_settings_access(request.user, course_key, access_type="write") diff --git a/cms/lib/spectacular.py b/cms/lib/spectacular.py index 662cf80c65f0..359df60c3497 100644 --- a/cms/lib/spectacular.py +++ b/cms/lib/spectacular.py @@ -4,15 +4,20 @@ from drf_spectacular.openapi import AutoSchema -# Legacy operations superseded by a conforming address under /api/authoring/. -# Each is listed in the truncated spelling as well as the full one: the routes -# make the final character optional, so both addresses resolve, and which one -# the generator emits depends on how it renders that regex. +# Legacy operations superseded by a conforming address under /api/authoring/, +# matched on the exact path. A route that makes its final character optional +# resolves with and without it, and the generator may emit either spelling, so +# such a route is listed in both. SUPERSEDED_PATHS = ( "/api/contentstore/v0/youtube_transcripts/{course_id}/chec", "/api/contentstore/v0/youtube_transcripts/{course_id}/check", "/api/contentstore/v0/youtube_transcripts/{course_id}/uploa", "/api/contentstore/v0/youtube_transcripts/{course_id}/upload", + "/api/contentstore/v1/course_index/{course_id}", # → /api/authoring/v2/courses/{course_key}/outline/ + # → /api/authoring/v1/courses/{course_key}/advanced_settings/ + "/api/contentstore/v0/advanced_settings/{course_id}", + "/api/contentstore/v1/course_rerun/{course_id}", # → /api/authoring/v2/courses/{course_key}/rerun_defaults/ + "/api/contentstore/v1/course_settings/{course_id}", # → /api/authoring/v2/courses/{course_key}/settings/ ) # Legacy addresses of APIs migrated to /api/authoring/, marked deprecated for diff --git a/cms/lib/tests/test_spectacular.py b/cms/lib/tests/test_spectacular.py index 4581f23a6148..99d14e99f248 100644 --- a/cms/lib/tests/test_spectacular.py +++ b/cms/lib/tests/test_spectacular.py @@ -76,15 +76,53 @@ def test_both_spellings_of_each_superseded_path_are_listed(self): "/api/contentstore/v0/youtube_transcripts/{course_id}/check", "/api/contentstore/v0/youtube_transcripts/{course_id}/uploa", "/api/contentstore/v0/youtube_transcripts/{course_id}/upload", + "/api/contentstore/v1/course_index/{course_id}", + "/api/contentstore/v0/advanced_settings/{course_id}", + "/api/contentstore/v1/course_rerun/{course_id}", + "/api/contentstore/v1/course_settings/{course_id}", } def test_the_conforming_paths_are_left_unmarked(self): - conforming = "/api/authoring/v1/courses/{course_key}/youtube_transcript_checks/" - result = self._run({"paths": {conforming: {"get": {"operationId": "x"}}}}) - assert result["paths"][conforming]["get"] == {"operationId": "x"} + for conforming in ( + "/api/authoring/v1/courses/{course_key}/youtube_transcript_checks/", + "/api/authoring/v2/courses/{course_key}/outline/", + ): + result = self._run({"paths": {conforming: {"get": {"operationId": "x"}}}}) + assert result["paths"][conforming]["get"] == {"operationId": "x"} + + def test_the_course_index_read_is_marked_and_its_sibling_container_route_is_not(self): + index = "/api/contentstore/v1/course_index/{course_id}" + container = "/api/contentstore/v1/container/{usage_key_string}/children" + result = self._run({"paths": {index: {"get": {}}, container: {"get": {}}}}) + assert result["paths"][index]["get"] == {"deprecated": True} + assert result["paths"][container]["get"] == {} + + def test_the_advanced_settings_operations_are_marked_and_their_successor_is_not(self): + legacy = "/api/contentstore/v0/advanced_settings/{course_id}" + conforming = "/api/authoring/v1/courses/{course_key}/advanced_settings/" + result = self._run({"paths": { + legacy: {"get": {}, "patch": {}}, + conforming: {"get": {}, "patch": {}}, + }}) + assert result["paths"][legacy] == {"get": {"deprecated": True}, "patch": {"deprecated": True}} + assert result["paths"][conforming] == {"get": {}, "patch": {}} + + def test_the_course_rerun_read_is_marked_and_its_successor_is_not(self): + legacy = "/api/contentstore/v1/course_rerun/{course_id}" + conforming = "/api/authoring/v2/courses/{course_key}/rerun_defaults/" + result = self._run({"paths": {legacy: {"get": {}}, conforming: {"get": {}}}}) + assert result["paths"][legacy] == {"get": {"deprecated": True}} + assert result["paths"][conforming] == {"get": {}} + + def test_the_course_settings_read_is_marked_and_its_successor_is_not(self): + legacy = "/api/contentstore/v1/course_settings/{course_id}" + conforming = "/api/authoring/v2/courses/{course_key}/settings/" + result = self._run({"paths": {legacy: {"get": {}}, conforming: {"get": {}}}}) + assert result["paths"][legacy] == {"get": {"deprecated": True}} + assert result["paths"][conforming] == {"get": {}} def test_an_unrelated_contentstore_path_is_left_unmarked(self): - other = "/api/contentstore/v0/advanced_settings/{course_id}" + other = "/api/contentstore/v0/heartbeat" result = self._run({"paths": {other: {"get": {}}}}) assert "deprecated" not in result["paths"][other]["get"]