From 46d535bd4db744ab6282ac8ad9ae92295d603e30 Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Wed, 14 Oct 2026 13:43:22 +0500 Subject: [PATCH 01/12] feat: serve the course outline from authoring v2 The course index existed only at v1, with Bearer auth, a legacy error body, a slashless address, the whole screen payload as its only shape, and writes on GET (pre-1900 start dates re-saved, a course overview built for the clipboard title). v2 serves GET /api/authoring/v2/courses/{course_key}/outline/. By default it returns a thin outline tree two levels deep, where every cut node carries a followable children_url. ?view=full returns v1's screen payload under v1's names, without the clipboard. Reads write nothing. Authentication is the platform default, the course-view permission and its legacy fallback are kept in CanViewCourse, and errors use the standardized envelope. The v1 course index is marked deprecated in the CMS schema. Co-Authored-By: Claude Opus 5.5 --- .../rest_api/v2/authoring_urls.py | 6 + .../rest_api/v2/course_outline_service.py | 198 ++ .../rest_api/v2/serializers/course_outline.py | 291 +++ .../rest_api/v2/views/course_outline.py | 220 ++ .../v2/views/tests/test_course_outline.py | 1766 +++++++++++++++++ .../contentstore/views/permissions.py | 37 +- cms/lib/spectacular.py | 9 +- cms/lib/tests/test_spectacular.py | 17 +- 8 files changed, 2536 insertions(+), 8 deletions(-) create mode 100644 cms/djangoapps/contentstore/rest_api/v2/course_outline_service.py create mode 100644 cms/djangoapps/contentstore/rest_api/v2/serializers/course_outline.py create mode 100644 cms/djangoapps/contentstore/rest_api/v2/views/course_outline.py create mode 100644 cms/djangoapps/contentstore/rest_api/v2/views/tests/test_course_outline.py diff --git a/cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py b/cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py index 1c0d693dec4f..0f878fa61c73 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py +++ b/cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py @@ -2,6 +2,7 @@ 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.textbooks import CourseTextbooksViewSet from cms.djangoapps.contentstore.rest_api.v2.views.video_settings import CourseVideoSettingsViewSet @@ -18,4 +19,9 @@ CourseVideoSettingsViewSet.as_view({"get": "retrieve"}), name="course_video_settings", ), + path( + "courses//outline/", + CourseOutlineViewSet.as_view({"get": "retrieve"}), + name="course_outline", + ), ] 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/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/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/tests/test_course_outline.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_course_outline.py new file mode 100644 index 000000000000..1a5b6b9404bf --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_course_outline.py @@ -0,0 +1,1766 @@ +"""Tests for the v2 course outline API.""" + +import re +from contextlib import contextmanager +from datetime import 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 +import pytz +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=pytz.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=pytz.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/views/permissions.py b/cms/djangoapps/contentstore/views/permissions.py index 6450a3bfcc3e..031c7c9f1115 100644 --- a/cms/djangoapps/contentstore/views/permissions.py +++ b/cms/djangoapps/contentstore/views/permissions.py @@ -2,14 +2,18 @@ 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 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 +62,34 @@ 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, + ) diff --git a/cms/lib/spectacular.py b/cms/lib/spectacular.py index 662cf80c65f0..e5a76f6e13ee 100644 --- a/cms/lib/spectacular.py +++ b/cms/lib/spectacular.py @@ -4,15 +4,16 @@ 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/ ) # 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..7409849c3b21 100644 --- a/cms/lib/tests/test_spectacular.py +++ b/cms/lib/tests/test_spectacular.py @@ -76,12 +76,23 @@ 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}", } 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_an_unrelated_contentstore_path_is_left_unmarked(self): other = "/api/contentstore/v0/advanced_settings/{course_id}" From ba6c0297bf62b1e3df0078ea6f63b7bc0ddba0dc Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Wed, 14 Oct 2026 13:43:22 +0500 Subject: [PATCH 02/12] docs: mark the v1 course index deprecated in favour of the v2 outline Docstring-only markers naming the v2 successor and DEPR #39128. With docstrings stripped the module matches the base. Co-Authored-By: Claude Opus 5.5 --- .../contentstore/rest_api/v1/views/course_index.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 From 53ea5f44a1abb63dc2a089dfd871c1d6e75bb8b1 Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Wed, 14 Oct 2026 14:20:59 +0500 Subject: [PATCH 03/12] test: use datetime.UTC in the course outline tests mypy checks rest_api/v2/views, and pytz ships no type stubs, so the import failed make check-types. The standard library's UTC gives the same timezone. Co-Authored-By: Claude Opus 5.5 --- .../rest_api/v2/views/tests/test_course_outline.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 index 1a5b6b9404bf..ef7f8c580492 100644 --- 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 @@ -2,7 +2,7 @@ import re from contextlib import contextmanager -from datetime import datetime +from datetime import UTC, datetime from functools import cache from importlib import import_module from unittest.mock import patch @@ -12,7 +12,6 @@ import jsonschema import pymongo import pytest -import pytz from django.conf import settings from django.db import connection from django.db.models.signals import post_save @@ -75,7 +74,7 @@ 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=pytz.UTC) +PRE_1900 = datetime(1800, 1, 1, tzinfo=UTC) def outline_url(course_key): @@ -995,7 +994,7 @@ def test_plain_course(self, relative_dates): 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=pytz.UTC)) + 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" From 15a98864c490ef2d73038d005575bf9324a1f169 Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Sun, 25 Oct 2026 15:09:22 +0500 Subject: [PATCH 04/12] feat: add course existence and advanced settings permission classes The v0 advanced settings view checks course existence with the @verify_course_exists decorator and access with an inline check_course_advanced_settings_access call in each handler. The conforming views need these as permission classes. IsExistingCourse answers 404 for a course key that names no course run: an unknown course, a key that names only a version, or a library key. CanViewAdvancedSettings and CanManageAdvancedSettings make the same check_course_advanced_settings_access(read|write) call the v0 view makes, so the policy-permission and legacy Studio-access branches are unchanged. Existing classes in the module are untouched. --- .../contentstore/views/permissions.py | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/views/permissions.py b/cms/djangoapps/contentstore/views/permissions.py index 6450a3bfcc3e..3701d7149481 100644 --- a/cms/djangoapps/contentstore/views/permissions.py +++ b/cms/djangoapps/contentstore/views/permissions.py @@ -3,13 +3,21 @@ """ from openedx_authz.constants.permissions import 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,60 @@ def has_permission(self, request, view): if course_key is None: return False return has_studio_read_access(request.user, course_key) + + +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") From 4cf3dec0381c18df70645a073ec112c4f66fa2a7 Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Sun, 25 Oct 2026 15:09:22 +0500 Subject: [PATCH 05/12] feat: serve course advanced settings from authoring v1 GET/PATCH /api/contentstore/v0/advanced_settings/{course_id} answers errors with developer_message bodies and some failures with a 500, accepts Bearer tokens, returns every settings field by default, and reports a refused value with the field parser's own text, which can be Python exception text. GET/PATCH /api/authoring/v1/courses/{course_key}/advanced_settings/ is its successor. Errors use the standard envelope; a refused value is reported under its setting name with one fixed message and the original text is logged. The default read is the set of settings course authors edit (v0 with fetch_all=0); view=full returns every field and fields narrows either view. Authentication keeps the session class that admits inactive users, as the legacy endpoint did; Bearer is dropped. Access is IsAuthenticated, IsExistingCourse and a per-action class making v0's read or write check, and an action without a declared permission is never served. Parity tests compare v1 with v0 through the test client; the declared differences are the error envelope and the fixed message. --- .../rest_api/v1/advanced_settings_service.py | 90 + .../rest_api/v1/authoring_urls.py | 6 + .../v1/serializers/advanced_settings.py | 144 ++ .../rest_api/v1/views/advanced_settings.py | 203 +++ .../v1/views/tests/test_advanced_settings.py | 1577 +++++++++++++++++ 5 files changed, 2020 insertions(+) create mode 100644 cms/djangoapps/contentstore/rest_api/v1/advanced_settings_service.py create mode 100644 cms/djangoapps/contentstore/rest_api/v1/serializers/advanced_settings.py create mode 100644 cms/djangoapps/contentstore/rest_api/v1/views/advanced_settings.py create mode 100644 cms/djangoapps/contentstore/rest_api/v1/views/tests/test_advanced_settings.py 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/tests/test_advanced_settings.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_advanced_settings.py new file mode 100644 index 000000000000..38973d752674 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_advanced_settings.py @@ -0,0 +1,1577 @@ +"""Tests for the v1 course advanced settings API.""" + +import json +import uuid +from contextlib import contextmanager +from datetime import UTC, datetime, timedelta +from functools import cache +from types import SimpleNamespace +from unittest.mock import patch + +import ddt +import jsonschema +import pymongo +import pytest +from crum import signals as crum_signals +from django.core import signals as core_signals +from django.core.exceptions import ImproperlyConfigured +from django.db import connection, transaction +from django.db.models import signals as model_signals +from django.dispatch import Signal +from django.test import SimpleTestCase, TestCase +from django.test.utils import CaptureQueriesContext, override_settings +from django.urls import Resolver404, resolve, reverse +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 openedx_events.tooling import OpenEdxPublicSignal +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 import authoring_urls +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.v2.views.tests.helpers import generate_cms_schema +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" +UNKNOWN_COURSE_KEY = "course-v1:NoSuchOrg+NoSuchCourse+NoSuchRun" +VERSION_ONLY_KEY = "course-v1:version@0123456789abcdef01234567" +COURSE_NOT_FOUND = "The requested course does not exist." +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 _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 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)) + + +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}}) + + +# --------------------------------------------------------------------------- +# 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 = _client(user).get(url(course_key)) + legacy = _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 = _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 = _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_refused(self, action): + """Without policy-based permissions, Studio grants global staff and course roles, not the superuser flag.""" + self.assert_access(UserFactory(is_superuser=True, is_staff=False), action, 403) + + 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(_client(user).get(url(VERSION_ONLY_KEY)), 404) + _assert_refused( + _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(_client(user).get(url(library_key)), 404) + _assert_refused(_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 + assert [type(permission) for permission in view.get_permissions()] == 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 = _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 = _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 = _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( + 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 _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"), +] +VOLATILE_FIELDS = {"instance"} + +# 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"}), +] + +_ABSENT = object() + + +def _flatten(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(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 _changed(legacy, new): + """The json paths whose leaf differs between the two bodies.""" + a, b = _flatten(legacy), _flatten(new) + return {key for key in set(a) | set(b) if not _same(a.get(key, _ABSENT), b.get(key, _ABSENT))} + + +def _undeclared(changed): + """The changed paths no declared intentional difference 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) + } + + +def test_the_comparison_sees_types_order_free_members_and_empty_containers(): + assert _changed({"a": {"b": []}}, {"a": {}}) == {"a", "a.b"} + assert _changed({"x": True}, {"x": 1}) == {"x"} + assert _changed({"x": 1.0}, {"x": 1}) == {"x"} + assert _changed({"x": {}}, {"x": []}) == {"x"} + assert _changed({"x": None, "y": 1}, {"y": 1}) == {"x"} + assert _changed({"instance": "/a"}, {"instance": "/b"}) == set() + assert _changed({"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(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 = _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(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 = _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 = _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(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 = [ + (_client(), self.course.id, "get", None, 401), + (_client(self.outsider()), self.course.id, "get", None, 403), + (_client(self.outsider()), UNKNOWN_COURSE_KEY, "get", None, 404), + (_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 = _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(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 +# --------------------------------------------------------------------------- + +# 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", +} + +READS = [("get", {}), ("get", FULL), ("get", {"fields": "display_name"}), ("head", {}), ("head", FULL)] + + +@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"] + + +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 = _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 = _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 +# --------------------------------------------------------------------------- + +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"] + + +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 = _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): + 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(_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 = _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 = _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 = _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 = _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 = _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 == [] From ac6fe16116d073555aa42a1d61884a5a56e7bff5 Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Sun, 25 Oct 2026 15:09:23 +0500 Subject: [PATCH 06/12] docs: mark the v0 advanced settings deprecated in favour of authoring v1 GET/PATCH /api/contentstore/v0/advanced_settings/{course_id} now has a conforming successor at /api/authoring/v1/courses/{course_key}/advanced_settings/. Both v0 operations are marked deprecated in the CMS schema through an exact SUPERSEDED_PATHS entry, and the view's docstrings name the successor, how each v0 read maps to a v1 read, and the deprecation ticket. The view itself is unchanged. The schema hook test that used this path as its example of an unrelated contentstore path now uses the heartbeat, which is not deprecated. --- .../rest_api/v0/views/advanced_settings.py | 12 ++++++++++++ cms/lib/spectacular.py | 10 ++++++---- cms/lib/tests/test_spectacular.py | 13 ++++++++++++- 3 files changed, 30 insertions(+), 5 deletions(-) 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/lib/spectacular.py b/cms/lib/spectacular.py index 662cf80c65f0..b4292d975e96 100644 --- a/cms/lib/spectacular.py +++ b/cms/lib/spectacular.py @@ -4,15 +4,17 @@ 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/authoring/v1/courses/{course_key}/advanced_settings/ + "/api/contentstore/v0/advanced_settings/{course_id}", ) # 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..ba7732a260e6 100644 --- a/cms/lib/tests/test_spectacular.py +++ b/cms/lib/tests/test_spectacular.py @@ -76,6 +76,7 @@ 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/v0/advanced_settings/{course_id}", } def test_the_conforming_paths_are_left_unmarked(self): @@ -83,8 +84,18 @@ def test_the_conforming_paths_are_left_unmarked(self): result = self._run({"paths": {conforming: {"get": {"operationId": "x"}}}}) assert result["paths"][conforming]["get"] == {"operationId": "x"} + 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_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"] From 2376c3811de25e879f29e0a393c5f44f8a62c652 Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Sun, 25 Oct 2026 17:55:19 +0500 Subject: [PATCH 07/12] test: import the authoring URLconf inside the test that needs it Importing cms.djangoapps.contentstore.rest_api.v1.authoring_urls at module level builds its routes before cms/urls.py registers the course_key path converter, so collecting the module failed with an ImproperlyConfigured error. The URL-name test now imports it when it runs, as the youtube transcript tests do. Also wraps a line pylint reported as too long. --- .../rest_api/v1/views/tests/test_advanced_settings.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 index 38973d752674..e2ff158b0059 100644 --- 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 @@ -38,7 +38,6 @@ from rest_framework.test import APIClient, APIRequestFactory, force_authenticate from rest_framework.utils.encoders import JSONEncoder -from cms.djangoapps.contentstore.rest_api.v1 import authoring_urls from cms.djangoapps.contentstore.rest_api.v1.advanced_settings_service import ( INVALID_SETTING_MESSAGE, SETTINGS_NOT_SAVED_MESSAGE, @@ -748,7 +747,10 @@ def test_a_malformed_entry_of_a_setting_authors_do_not_edit_is_ignored(self): 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"): + 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]} @@ -1414,6 +1416,8 @@ def test_v0_still_takes_a_slash_separated_key_that_v1_does_not(self): 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 From 0131b820ceba61217a2da56f603bb0124d818463 Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Sun, 25 Oct 2026 18:33:41 +0500 Subject: [PATCH 08/12] test: align the advanced settings tests with the platform's behaviour The first CI run of the module showed three expectations that did not match the platform: - The legacy role checks consult the policy engine, which admits superusers, so a superuser who is not global staff is served by v0 and v1 alike. The test now expects that. - The proctoring provider field keeps its default unless proctored exams are enabled, so the proctoring tests enable them. - The JWT middleware appends NotJwtRestrictedApplication to the permissions of views that accept JWTs, so the wiring test compares only the classes the view declares. --- .../v1/views/tests/test_advanced_settings.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) 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 index e2ff158b0059..1d95689e94f4 100644 --- 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 @@ -21,6 +21,7 @@ from django.test import SimpleTestCase, TestCase from django.test.utils import CaptureQueriesContext, 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 ( @@ -275,9 +276,9 @@ 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_refused(self, action): - """Without policy-based permissions, Studio grants global staff and course roles, not the superuser flag.""" - self.assert_access(UserFactory(is_superuser=True, is_staff=False), action, 403) + 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.""" @@ -448,7 +449,12 @@ def test_each_action_checks_the_course_then_its_own_permission(self): ("metadata", [IsAuthenticated, IsExistingCourse]), ): view.action = action - assert [type(permission) for permission in view.get_permissions()] == expected, 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() @@ -854,6 +860,7 @@ def test_a_slash_separated_course_key(self): @ddt.ddt @override_settings( + ENABLE_PROCTORED_EXAMS=True, PROCTORING_BACKENDS={"DEFAULT": "test_proctoring_provider", "valid_provider": {}}, PARTNER_SUPPORT_EMAIL="support@example.com", ) From 9b4d60ea0d40e8b5ddbba8bd782980662bd72a5b Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Sun, 25 Oct 2026 21:46:35 +0500 Subject: [PATCH 09/12] test: share the authoring v1 test helpers The side-effect recorder, the body comparison helpers, the schema helpers and the client helpers in the advanced settings tests are not specific to advanced settings. They move unchanged, under public names, into v1/views/tests/helpers.py so the other authoring v1 endpoint tests reuse one copy instead of each carrying its own. --- .../rest_api/v1/views/tests/helpers.py | 254 +++++++++++++ .../v1/views/tests/test_advanced_settings.py | 340 ++++-------------- 2 files changed, 326 insertions(+), 268 deletions(-) create mode 100644 cms/djangoapps/contentstore/rest_api/v1/views/tests/helpers.py 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 index 1d95689e94f4..27dded1e7e28 100644 --- 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 @@ -2,24 +2,17 @@ import json import uuid -from contextlib import contextmanager from datetime import UTC, datetime, timedelta -from functools import cache from types import SimpleNamespace from unittest.mock import patch import ddt -import jsonschema -import pymongo import pytest -from crum import signals as crum_signals -from django.core import signals as core_signals from django.core.exceptions import ImproperlyConfigured -from django.db import connection, transaction -from django.db.models import signals as model_signals +from django.db import transaction from django.dispatch import Signal from django.test import SimpleTestCase, TestCase -from django.test.utils import CaptureQueriesContext, override_settings +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 @@ -34,7 +27,6 @@ COURSE_STAFF, ) from openedx_authz.models.engine import PolicyCacheControl -from openedx_events.tooling import OpenEdxPublicSignal from rest_framework.permissions import IsAuthenticated from rest_framework.test import APIClient, APIRequestFactory, force_authenticate from rest_framework.utils.encoders import JSONEncoder @@ -44,7 +36,21 @@ SETTINGS_NOT_SAVED_MESSAGE, ) from cms.djangoapps.contentstore.rest_api.v1.views.advanced_settings import CourseAdvancedSettingsViewSet -from cms.djangoapps.contentstore.rest_api.v2.views.tests.helpers import generate_cms_schema +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, @@ -76,9 +82,6 @@ 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" -UNKNOWN_COURSE_KEY = "course-v1:NoSuchOrg+NoSuchCourse+NoSuchRun" -VERSION_ONLY_KEY = "course-v1:version@0123456789abcdef01234567" -COURSE_NOT_FOUND = "The requested course does not exist." FULL = {"view": "full"} LEGACY_DEFAULT = {"fetch_all": "0"} # An integer setting authors edit, so a non-numeric value is refused by its field. @@ -97,24 +100,6 @@ def legacy_url(course_key): return reverse(LEGACY_URL_NAME, 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 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))) @@ -135,42 +120,10 @@ def every_setting(course_key): return CourseMetadata.fetch_all(stored_course(course_key)) -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}}) - # --------------------------------------------------------------------------- # Who may call the endpoint @@ -185,13 +138,13 @@ class _AccessMixin: 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 = _client(user).get(url(course_key)) - legacy = _client(user).get(legacy_url(course_key), LEGACY_DEFAULT) + 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_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): @@ -200,17 +153,17 @@ def assert_update(self, user, expected, course_key=None): 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 = _client(user).patch(url(course_key), {"display_name": {"value": new_name}}, format="json") + 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) + 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 = _client(user).patch(legacy_url(course_key), {"display_name": {"value": legacy_name}}, format="json") + 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): @@ -283,16 +236,16 @@ def test_a_superuser_who_is_not_global_staff_is_allowed(self, action): 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(_client(user).get(url(VERSION_ONLY_KEY)), 404) - _assert_refused( - _client(user).patch(url(VERSION_ONLY_KEY), {"display_name": {"value": "x"}}, format="json"), 404, + 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(_client(user).get(url(library_key)), 404) - _assert_refused(_client(user).patch(url(library_key), {}, format="json"), 404) + assert_refused(api_client(user).get(url(library_key)), 404) + assert_refused(api_client(user).patch(url(library_key), {}, format="json"), 404) @ddt.ddt @@ -366,7 +319,7 @@ def test_staff_and_superuser_are_allowed_without_a_role(self, action): 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) + client = session_client(user) user.is_active = False user.save() assert client.get(url(self.course.id)).status_code == 200 @@ -519,7 +472,7 @@ def test_a_jwt_caller_may_read_and_update(self): 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()) + 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 @@ -532,13 +485,13 @@ def test_an_inactive_session_is_authenticated(self): 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) + 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_refused(response, 403) assert client.get(legacy_url(self.course.id)).status_code == expected def test_a_bearer_token_is_refused(self): @@ -546,7 +499,7 @@ def test_a_bearer_token_is_refused(self): 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_refused(APIClient().get(url(self.course.id), **header), 401) assert APIClient().get(legacy_url(self.course.id), **header).status_code == 200 @@ -567,7 +520,7 @@ def setUp(self): super().setUp() reader = UserFactory() CourseStaffRole(self.course.id).add_users(reader) - self.client = _client(reader) + self.client = api_client(reader) def get(self, **params): response = self.client.get(url(self.course.id), params) @@ -645,7 +598,6 @@ def test_head_answers_without_a_body(self): assert response.content == b"" - # --------------------------------------------------------------------------- # Updates # --------------------------------------------------------------------------- @@ -659,7 +611,7 @@ def setUp(self): writer = UserFactory() CourseStaffRole(self.course.id).add_users(writer) self.writer = writer - self.client = _client(writer) + self.client = api_client(writer) def patch(self, body): return self.client.patch(url(self.course.id), body, format="json") @@ -802,7 +754,7 @@ def setUp(self): self.course = CourseFactory.create(display_name="Original") writer = UserFactory() CourseStaffRole(self.course.id).add_users(writer) - self.client = _client(writer) + self.client = api_client(writer) def both(self, method, body=None, **params): """The v0 and v1 responses to the same request.""" @@ -819,7 +771,7 @@ def test_a_course_the_modulestore_returns_nothing_for(self): 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) + assert_refused(new, 404) def test_an_update_whose_body_is_not_an_object(self): legacy, new = self.both("patch", [1, 2]) @@ -844,10 +796,10 @@ def test_a_bearer_token(self): 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) + 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) + 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.""" @@ -883,7 +835,7 @@ def _course_staff(self, course): def _patch(self, version, user, course): address = url(course.id) if version == "v1" else legacy_url(course.id) - return _client(user).patch(address, self.CHANGE, format="json") + 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): @@ -932,7 +884,6 @@ def test_course_staff_may_change_it_before_the_course_starts(self, version): ("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"), ] -VOLATILE_FIELDS = {"instance"} # 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 @@ -947,57 +898,20 @@ def test_course_staff_may_change_it_before_the_course_starts(self, version): ({**FULL, "fields": "tabs,display_name"}, {"filter_fields": "tabs,display_name"}), ] -_ABSENT = object() - - -def _flatten(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(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 _changed(legacy, new): - """The json paths whose leaf differs between the two bodies.""" - a, b = _flatten(legacy), _flatten(new) - return {key for key in set(a) | set(b) if not _same(a.get(key, _ABSENT), b.get(key, _ABSENT))} - def _undeclared(changed): """The changed paths no declared intentional difference 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) - } + return undeclared_paths(changed, INTENTIONAL_DIFFERENCES) def test_the_comparison_sees_types_order_free_members_and_empty_containers(): - assert _changed({"a": {"b": []}}, {"a": {}}) == {"a", "a.b"} - assert _changed({"x": True}, {"x": 1}) == {"x"} - assert _changed({"x": 1.0}, {"x": 1}) == {"x"} - assert _changed({"x": {}}, {"x": []}) == {"x"} - assert _changed({"x": None, "y": 1}, {"y": 1}) == {"x"} - assert _changed({"instance": "/a"}, {"instance": "/b"}) == set() - assert _changed({"x": [1, {"y": False}]}, {"x": [1, {"y": False}]}) == set() + 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(): @@ -1030,26 +944,26 @@ def compare(self, legacy, new, expected_status): 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(legacy_body, new_body) + 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 = _client(self.reader) + 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(legacy_body, new_body) == set(), (new_params, legacy_params) + 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 = _client(self.reader) + 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( @@ -1060,21 +974,21 @@ def test_the_same_parameters_return_different_settings(self): def test_an_update_returns_an_identical_body(self): body = {"display_name": {"value": "Changed by both"}, "days_early_for_beta": {"value": 2.0}} - client = _client(self.reader) + 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(legacy_body, new_body) == set() + 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 = [ - (_client(), self.course.id, "get", None, 401), - (_client(self.outsider()), self.course.id, "get", None, 403), - (_client(self.outsider()), UNKNOWN_COURSE_KEY, "get", None, 404), - (_client(self.reader), self.course.id, "patch", refused, 400), + (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) @@ -1096,12 +1010,12 @@ def test_a_refused_update_names_the_settings_v0_names_with_a_fixed_message(self) 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 = _client(self.reader) + 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(legacy_body, new_body) == set(), body + assert changed_paths(legacy_body, new_body) == set(), body def test_every_declared_difference_occurs(self): for legacy, new, status in self._error_pairs(): @@ -1141,112 +1055,10 @@ def outsider(self): # Side effects of reads # --------------------------------------------------------------------------- -# 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", -} READS = [("get", {}), ("get", FULL), ("get", {"fields": "display_name"}), ("head", {}), ("head", FULL)] -@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"] - - class _ReadsAreSideEffectFreeMixin: """ Every read (default, full, named settings, and HEAD on both views) writes @@ -1266,7 +1078,7 @@ def setUp(self): self.course = CourseFactory.create() def test_reads_have_no_side_effects(self): - client = _client(self.reader_for(self.course)) + 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: @@ -1281,7 +1093,7 @@ def test_reads_have_no_side_effects(self): assert effects["update_item"] == [], read def test_the_v0_read_makes_the_same_first_lookup_write(self): - client = _client(self.reader_for(self.course)) + 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 @@ -1345,14 +1157,6 @@ def reader_for(self, course): # 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"] - class _QueryCountMixin: """Each read costs no more SQL queries or Mongo reads than the v0 read returning the same settings.""" @@ -1362,10 +1166,10 @@ def reader_for(self, course): def test_reads_cost_no_more_than_the_equivalent_v0_reads(self): course = CourseFactory.create() - client = _client(self.reader_for(course)) + 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)) + 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) @@ -1435,11 +1239,11 @@ def test_the_url_names_are_unique_in_the_namespace(self): @ddt.data("post", "put", "delete") def test_other_methods_are_not_allowed(self, method): - response = getattr(_client(UserFactory(is_staff=True)), method)(url(self.course.id), {}, format="json") + 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 = _client(UserFactory(is_staff=True)).get(url(self.course.id), HTTP_ACCEPT="application/xml") + 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 @@ -1447,14 +1251,14 @@ def test_unsupported_accept_is_refused_with_406(self): # here once the library catalogues them. def test_a_malformed_json_body_is_a_400_typed_internal(self): - response = _client(UserFactory(is_staff=True)).patch( + 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 = _client(UserFactory(is_staff=True)).patch( + 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") @@ -1467,14 +1271,14 @@ def test_an_unsupported_media_type_is_a_415_typed_internal(self): @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 = _client(UserFactory(is_staff=True)).get(f"/api/authoring/v1/courses/{key}/advanced_settings/") + 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 = _client(UserFactory(is_staff=True)).get(address) + 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): @@ -1494,7 +1298,7 @@ class AdvancedSettingsSchemaTest(TestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.schema = _published_schema() + cls.schema = published_schema() cls.components = cls.schema["components"]["schemas"] @staticmethod From 83731951d474ef5d5e57a714766fe2e022009284 Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Sun, 25 Oct 2026 23:57:52 +0500 Subject: [PATCH 10/12] feat: serve course rerun defaults from authoring v2 Adds GET /api/authoring/v2/courses/{course_key}/rerun_defaults/, replacing GET /api/contentstore/v1/course_rerun/{course_id}: the values Studio prefills when re-running a course. Access stays limited to accounts with staff status, as on v1. The endpoint uses the standard error envelope, drops Bearer authentication, answers 404 for an unknown course before checking access, and is tagged for the SDK. The legacy path is marked deprecated in the published schema. --- .../rest_api/v2/authoring_urls.py | 6 + .../rest_api/v2/rerun_defaults_service.py | 31 + .../rest_api/v2/serializers/rerun_defaults.py | 30 + .../rest_api/v2/views/rerun_defaults.py | 93 ++ .../v2/views/tests/test_rerun_defaults.py | 802 ++++++++++++++++++ cms/lib/spectacular.py | 1 + cms/lib/tests/test_spectacular.py | 8 + 7 files changed, 971 insertions(+) create mode 100644 cms/djangoapps/contentstore/rest_api/v2/rerun_defaults_service.py create mode 100644 cms/djangoapps/contentstore/rest_api/v2/serializers/rerun_defaults.py create mode 100644 cms/djangoapps/contentstore/rest_api/v2/views/rerun_defaults.py create mode 100644 cms/djangoapps/contentstore/rest_api/v2/views/tests/test_rerun_defaults.py diff --git a/cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py b/cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py index 0f878fa61c73..fc658f64d863 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py +++ b/cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py @@ -3,6 +3,7 @@ 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.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 @@ -24,4 +25,9 @@ CourseOutlineViewSet.as_view({"get": "retrieve"}), name="course_outline", ), + path( + "courses//rerun_defaults/", + CourseRerunDefaultsViewSet.as_view({"get": "retrieve"}), + name="course_rerun_defaults", + ), ] 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/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/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_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/lib/spectacular.py b/cms/lib/spectacular.py index 244219f12438..83aa8badd3d0 100644 --- a/cms/lib/spectacular.py +++ b/cms/lib/spectacular.py @@ -16,6 +16,7 @@ "/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/ ) # 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 e35e7618a635..72b78768d34a 100644 --- a/cms/lib/tests/test_spectacular.py +++ b/cms/lib/tests/test_spectacular.py @@ -78,6 +78,7 @@ def test_both_spellings_of_each_superseded_path_are_listed(self): "/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}", } def test_the_conforming_paths_are_left_unmarked(self): @@ -105,6 +106,13 @@ def test_the_advanced_settings_operations_are_marked_and_their_successor_is_not( 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_an_unrelated_contentstore_path_is_left_unmarked(self): other = "/api/contentstore/v0/heartbeat" result = self._run({"paths": {other: {"get": {}}}}) From 3e37e95248c27d4f8681ea896491c5180775cf61 Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Sun, 25 Oct 2026 23:57:52 +0500 Subject: [PATCH 11/12] feat: serve course settings from authoring v2 Adds GET /api/authoring/v2/courses/{course_key}/settings/, replacing GET /api/contentstore/v1/course_settings/{course_id}. The default view returns the scalar settings; view=full adds the language options, the courses that may be prerequisites and the credit requirements. It gains content_experiments_enabled, the course-level flag the v2 group configurations listing no longer carries. Course-list query parameters no longer narrow or reorder the prerequisite list. The endpoint uses the standard error envelope, drops Bearer authentication, and is tagged for the SDK. The legacy path is marked deprecated in the published schema. --- .../rest_api/v2/authoring_urls.py | 6 + .../rest_api/v2/course_settings_service.py | 65 + .../v2/serializers/course_settings.py | 171 +++ .../rest_api/v2/views/course_settings.py | 157 +++ .../v2/views/tests/test_course_settings.py | 1228 +++++++++++++++++ cms/lib/spectacular.py | 1 + cms/lib/tests/test_spectacular.py | 8 + 7 files changed, 1636 insertions(+) create mode 100644 cms/djangoapps/contentstore/rest_api/v2/course_settings_service.py create mode 100644 cms/djangoapps/contentstore/rest_api/v2/serializers/course_settings.py create mode 100644 cms/djangoapps/contentstore/rest_api/v2/views/course_settings.py create mode 100644 cms/djangoapps/contentstore/rest_api/v2/views/tests/test_course_settings.py diff --git a/cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py b/cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py index fc658f64d863..5a18c5f2ac6a 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py +++ b/cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py @@ -3,6 +3,7 @@ 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 @@ -30,4 +31,9 @@ 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_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/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/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/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/lib/spectacular.py b/cms/lib/spectacular.py index 83aa8badd3d0..359df60c3497 100644 --- a/cms/lib/spectacular.py +++ b/cms/lib/spectacular.py @@ -17,6 +17,7 @@ # → /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 72b78768d34a..99d14e99f248 100644 --- a/cms/lib/tests/test_spectacular.py +++ b/cms/lib/tests/test_spectacular.py @@ -79,6 +79,7 @@ def test_both_spellings_of_each_superseded_path_are_listed(self): "/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): @@ -113,6 +114,13 @@ def test_the_course_rerun_read_is_marked_and_its_successor_is_not(self): 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/heartbeat" result = self._run({"paths": {other: {"get": {}}}}) From aa9ae3100756bc51f498d6e60281308c1cda2b3d Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Sun, 25 Oct 2026 23:57:52 +0500 Subject: [PATCH 12/12] docs: mark the v1 course rerun and settings endpoints deprecated in favour of authoring v2 Docstring-only: names the successor addresses and the deprecation ticket. --- .../contentstore/rest_api/v1/views/course_rerun.py | 6 ++++++ .../contentstore/rest_api/v1/views/settings.py | 10 ++++++++++ 2 files changed, 16 insertions(+) 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}