diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py index aa39f5371329..7db093039e99 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py @@ -32,7 +32,12 @@ @view_auth_classes(is_authenticated=True) class CourseIndexView(DeveloperErrorViewMixin, APIView): - """View for Course Index""" + """ + View for Course Index + + Deprecated: use ``GET /api/authoring/v2/courses/{course_key}/outline/?view=full`` + instead. See https://github.com/openedx/openedx-platform/issues/39128. + """ @extend_schema( parameters=[ @@ -55,6 +60,11 @@ def get(self, request: Request, course_id: str): """ Get an object containing course index for outline. + Deprecated: use ``GET /api/authoring/v2/courses/{course_key}/outline/?view=full``, + which returns these fields except ``initial_user_clipboard``. The clipboard is read + from ``GET /api/content-staging/v1/clipboard/``. + See https://github.com/openedx/openedx-platform/issues/39128. + **Example Request** GET /api/contentstore/v1/course_index/{course_id}?show=block-v1:edx+101+y+type@course+block@course diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py index 02538a7d1e99..fe7537a252e7 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py @@ -25,6 +25,9 @@ class ContainerHandlerView(APIView, ContainerHandlerMixin): """ View for container xblock requests to get vertical data. + + Deprecated: use ``GET /api/authoring/v2/containers/{usage_key}/?view=full`` + instead. See https://github.com/openedx/openedx-platform/issues/39128. """ @extend_schema( @@ -46,6 +49,11 @@ def get(self, request: Request, usage_key_string: str): """ Get an object containing vertical data. + Deprecated: use ``GET /api/authoring/v2/containers/{usage_key}/?view=full``, + which returns these fields except ``user_clipboard`` and ``action``. 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/container_handler/{usage_key_string} diff --git a/cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py b/cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py index 1c0d693dec4f..eddb9ef396fb 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py +++ b/cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py @@ -2,6 +2,8 @@ from django.urls import path +from cms.djangoapps.contentstore.rest_api.v2.views.containers import CourseContainerViewSet +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 +20,14 @@ CourseVideoSettingsViewSet.as_view({"get": "retrieve"}), name="course_video_settings", ), + path( + "courses//outline/", + CourseOutlineViewSet.as_view({"get": "retrieve"}), + name="course_outline", + ), + path( + "containers//", + CourseContainerViewSet.as_view({"get": "retrieve"}), + name="container_detail", + ), ] diff --git a/cms/djangoapps/contentstore/rest_api/v2/container_service.py b/cms/djangoapps/contentstore/rest_api/v2/container_service.py new file mode 100644 index 000000000000..e3f03a07203c --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/container_service.py @@ -0,0 +1,230 @@ +"""Service layer for the v2 container endpoint.""" + +import logging +from urllib.parse import quote_plus + +from django.urls import reverse +from rest_framework.exceptions import NotFound + +from cms.djangoapps.contentstore.helpers import ( + get_parent_xblock, + xblock_studio_url, + xblock_type_display_name, +) +from cms.djangoapps.contentstore.utils import ( + _get_component_templates_for_xblock, + get_library_content_picker_url, + get_lms_link_for_item, + get_sequence_usage_keys, + get_sibling_urls, + reverse_course_url, +) +from cms.djangoapps.contentstore.views.component import LIBRARY_BLOCK_TYPES, get_component_templates, get_unit_tags +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import ( + add_container_page_publishing_info, + create_xblock_info, +) +from openedx.core.djangoapps.content_tagging.toggles import is_tagging_feature_disabled +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__) + +CONTAINER_NOT_FOUND_MESSAGE = "No container in this course has this key." +FULL_VIEW_NOT_AVAILABLE_MESSAGE = "The full view is available only for a unit or a container inside a unit." + + +class _Placement: + """ + Where a container sits: the chain of blocks from it up to the course, and + the unit and subsection it belongs to when it is a unit or inside one. + """ + + def __init__(self, chain, unit_index): + self.chain = chain + self.unit = chain[unit_index] if unit_index is not None else None + self.subsection = chain[unit_index + 1] if unit_index is not None else None + + @property + def container(self): + return self.chain[0] + + @property + def is_unit(self): + return self.unit is self.container + + +def _place(usage_key, block): + """ + Return where ``block`` sits, or raise ``NotFound`` when it is not a container of the course. + + A container is a block other than the course that can hold children and + that the course reaches through its children: a section, a subsection, a + unit (a vertical placed directly in a subsection), or a container placed + in any of them. The course itself, components, and blocks the course does + not reach are not containers. + """ + if not block.has_children or block.category == "course": + log.info("Container %s requested, which is a %s.", usage_key, block.category) + raise NotFound(CONTAINER_NOT_FOUND_MESSAGE) + chain = [block] + parent = get_parent_xblock(block) + while parent is not None: + chain.append(parent) + parent = get_parent_xblock(parent) + if chain[-1].category != "course": + log.info("Container %s requested, which the course does not reach.", usage_key) + raise NotFound(CONTAINER_NOT_FOUND_MESSAGE) + unit_index = next( + ( + index for index, ancestor in enumerate(chain[:-1]) + if ancestor.category == "vertical" and chain[index + 1].category == "sequential" + ), + None, + ) + return _Placement(chain, unit_index) + + +def _load(store, usage_key): + """Return the block ``usage_key`` names and where it sits, or raise ``NotFound``.""" + try: + block = store.get_item(usage_key, depth=1) + except ItemNotFoundError as error: + log.info("Container %s requested, which does not exist: %s", usage_key, error) + raise NotFound(CONTAINER_NOT_FOUND_MESSAGE) from error + return block, _place(usage_key, block) + + +def _position(placement): + """ + The 1-based position of the container's unit among its subsection's + children, as the Studio unit page counts it: children that cannot be + loaded are not counted. + """ + index = 1 + for child in placement.subsection.get_children(): + if child.location == placement.unit.location: + break + index += 1 + return index + + +def get_container(usage_key): + """ + Return the container ``usage_key`` names, with the blocks above it. + + Raises ``NotFound`` when no block has the key or the block is not a + container of the course. ``unit_id`` and ``position`` are None above unit + level. Nothing is written. + + The database and Mongo reads depend on how deeply the container is + nested, not on the size of the course: the structure is read once and the + walk up to the course works on it. That walk, and loading the unit's + siblings to count its position, still take time in proportion to the + structure. + """ + store = modulestore() + with store.bulk_operations(usage_key.course_key): + block, placement = _load(store, usage_key) + unit = placement.unit + return { + "id": str(block.location), + "category": block.category, + "display_name": block.display_name_with_default, + "display_type": xblock_type_display_name(block), + "course_key": str(block.location.course_key), + "is_unit": placement.is_unit, + "unit_id": str(unit.location) if unit is not None else None, + "position": _position(placement) if unit is not None else None, + "ancestors": [ + { + "id": str(ancestor.location), + "category": ancestor.category, + "display_name": ancestor.display_name_with_default, + } + for ancestor in reversed(placement.chain[1:-1]) + ], + } + + +def _ancestor_xblocks(placement): + """ + Each ancestor of the container below the topmost block, outermost first, + with every child of that ancestor's parent. + """ + chain = placement.chain + entries = [ + { + "title": chain[index].display_name_with_default, + "is_last": index == 1, + "children": [ + { + "url": xblock_studio_url(child), + "display_name": child.display_name_with_default, + "usage_key": str(child.location), + } + for child in chain[index + 1].get_children() + ], + } + for index in range(1, len(chain) - 1) + ] + entries.reverse() + return entries + + +def get_container_page(request, usage_key): + """ + Return everything the Studio unit page shows for the container ``usage_key`` names. + + Raises ``NotFound`` as ``get_container`` does, and also for a container + that is neither a unit nor inside one, such as a section or subsection. + The requesting user's clipboard is not part of the result; it is read on + its own. Nothing is written. + """ + store = modulestore() + course_key = usage_key.course_key + with store.bulk_operations(course_key): + block, placement = _load(store, usage_key) + if placement.unit is None: + log.info("Unit page data requested for %s, which is not a unit or inside one.", usage_key) + raise NotFound(FULL_VIEW_NOT_AVAILABLE_MESSAGE) + course = store.get_course(course_key) + is_unit_page = placement.is_unit + + prev_url, next_url = get_sibling_urls(placement.subsection, placement.unit.location) + show_unit_tags = not is_tagging_feature_disabled() + unit_tags = get_unit_tags(usage_key) if show_unit_tags and is_unit_page else None + xblock_info = create_xblock_info(block, include_ancestor_info=is_unit_page, tags=unit_tags) + if is_unit_page: + add_container_page_publishing_info(block, xblock_info) + library_block_types = [block_type["component"] for block_type in LIBRARY_BLOCK_TYPES] + is_library_block = block.location.block_type in library_block_types + + return { + "language_code": request.LANGUAGE_CODE, + "xblock": { + "display_name": block.display_name_with_default, + "display_type": xblock_type_display_name(block), + "category": block.category, + }, + "is_unit_page": is_unit_page, + "is_collapsible": is_library_block, + "position": _position(placement), + # Quoted because the unit page places them in a query string. + "prev_url": quote_plus(prev_url) if prev_url else None, + "next_url": quote_plus(next_url) if next_url else None, + "new_unit_category": "vertical", + "outline_url": f"{reverse_course_url('course_handler', course.id)}?format=concise", + "ancestor_xblocks": _ancestor_xblocks(placement), + "component_templates": _get_component_templates_for_xblock(get_component_templates(course), block), + "xblock_info": xblock_info, + "draft_preview_link": get_lms_link_for_item(block.location, preview=True), + "published_preview_link": get_lms_link_for_item(block.location), + "show_unit_tags": show_unit_tags, + "is_fullwidth_content": is_library_block, + "assets_url": reverse("assets_handler", kwargs={"course_key_string": course.id}), + "unit_block_id": placement.unit.location.block_id, + "subsection_location": str(placement.subsection.location), + "course_sequence_ids": get_sequence_usage_keys(course), + "library_content_picker_url": get_library_content_picker_url(course.id), + } 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/containers.py b/cms/djangoapps/contentstore/rest_api/v2/serializers/containers.py new file mode 100644 index 000000000000..6fcb22cffd75 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/serializers/containers.py @@ -0,0 +1,315 @@ +"""Serializers for a course container.""" + +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +CONTAINER_MINIMAL_VIEW = "minimal" +CONTAINER_FULL_VIEW = "full" + + +class CourseContainerAncestorSerializer(serializers.Serializer): # pylint: disable=abstract-method + """A block above the container, below the course.""" + + id = serializers.CharField(help_text="Usage key of the block.") + category = serializers.CharField( + help_text="Block type, such as chapter (section), sequential (subsection), vertical (unit) or split_test.", + ) + display_name = serializers.CharField(help_text="Name of the block shown to authors.") + + class Meta: + ref_name = "CourseContainerAncestor" + + +class CourseContainerSerializer(serializers.Serializer): # pylint: disable=abstract-method + """A section, subsection, unit or other container of a course, and where it sits in the course.""" + + id = serializers.CharField(help_text="Usage key of the container.") + category = serializers.CharField( + help_text=( + "Block type of the container: chapter for a section, sequential for a subsection, vertical for a " + "unit, or another container type such as split_test." + ), + ) + display_name = serializers.CharField(help_text="Name of the container shown to authors.") + display_type = serializers.CharField( + allow_null=True, + help_text=( + "Name of the container's type shown to authors, such as Section, Subsection, Unit, or Vertical " + "inside a unit. Null for a block type that declares no display name." + ), + ) + course_key = serializers.CharField(help_text="Key of the course the container belongs to.") + is_unit = serializers.BooleanField( + help_text="Whether the container is a unit: a vertical placed directly in a subsection.", + ) + unit_id = serializers.CharField( + allow_null=True, + help_text=( + "Usage key of the unit: the container itself, or the unit it is placed in. Null for a section, a " + "subsection, or a container placed directly in one, which no unit holds." + ), + ) + position = serializers.IntegerField( + min_value=1, + allow_null=True, + help_text=( + "Position of that unit among the children of its subsection, starting at 1. Children that cannot " + "be loaded are not counted. Null when unit_id is null." + ), + ) + ancestors = CourseContainerAncestorSerializer( + many=True, + help_text=( + "The blocks above the container, from its section down to its direct parent, leaving out the " + "course: empty for a section, the section for a subsection, the section and subsection for a unit, " + "and also the unit and any container between for a container inside a unit." + ), + ) + + class Meta: + ref_name = "CourseContainer" + + +class CourseContainerXBlockSerializer(serializers.Serializer): # pylint: disable=abstract-method + """The container's name and type.""" + + display_name = serializers.CharField(help_text="Name of the container shown to authors.") + display_type = serializers.CharField( + allow_null=True, + help_text="Name of the container's type shown to authors. Null for a block type that declares no display name.", + ) + category = serializers.CharField(help_text="Block type of the container.") + + class Meta: + ref_name = "CourseContainerXBlock" + + +class CourseContainerAncestorChildSerializer(serializers.Serializer): # pylint: disable=abstract-method + """A block listed beside one of the container's ancestors.""" + + url = serializers.CharField( + allow_null=True, + help_text=( + "Studio address of the block's page. Null for a component, such as a problem, which has no page " + "of its own." + ), + ) + display_name = serializers.CharField(help_text="Name of the block shown to authors.") + usage_key = serializers.CharField(help_text="Usage key of the block.") + + class Meta: + ref_name = "CourseContainerAncestorChild" + + +class CourseContainerAncestorXBlockSerializer(serializers.Serializer): # pylint: disable=abstract-method + """One ancestor of the container, with the blocks beside it.""" + + children = CourseContainerAncestorChildSerializer( + many=True, + help_text=( + "Every child of this ancestor's parent, in course order, this ancestor among them: every section " + "of the course for the section, every subsection of the section for the subsection, and so on." + ), + ) + title = serializers.CharField(help_text="Name of this ancestor shown to authors.") + is_last = serializers.BooleanField(help_text="Whether this ancestor is the container's direct parent.") + + class Meta: + ref_name = "CourseContainerAncestorXBlock" + + +@extend_schema_field({"oneOf": [{"type": "boolean"}, {"type": "string"}]}) +class _SupportLevelField(serializers.Field): + """A support level, returned as stored: either true, or a level code.""" + + def to_representation(self, value): + return value + + +class CourseContainerComponentTemplateSerializer(serializers.Serializer): # pylint: disable=abstract-method + """A kind of component that authors can add to the container.""" + + display_name = serializers.CharField(help_text="Name of the template shown to authors.") + category = serializers.CharField(help_text="Block type the template creates.") + boilerplate_name = serializers.CharField( + allow_null=True, + help_text="Name of the boilerplate the new block is filled from. Null for a blank block.", + ) + hinted = serializers.BooleanField(help_text="Whether the template is a problem with hints.") + tab = serializers.CharField(help_text="Tab of the add-component menu the template is shown in: common or advanced.") + support_level = _SupportLevelField( + help_text=( + "How well the template is supported: true when support levels are not configured, otherwise fs " + "(fully supported), ps (provisionally supported) or us (unsupported)." + ), + ) + single_instance = serializers.BooleanField( + required=False, + help_text="Present and true when the container may hold only one block of this type.", + ) + disabled = serializers.BooleanField( + required=False, + help_text="Present and true when authors may not add this template to the container.", + ) + disabled_reason = serializers.CharField( + required=False, + help_text="Why the template may not be added. Present only with a reason.", + ) + + class Meta: + ref_name = "CourseContainerComponentTemplate" + + +class CourseContainerSupportLegendSerializer(serializers.Serializer): # pylint: disable=abstract-method + """Whether and how the add-component menu explains support levels.""" + + show_legend = serializers.BooleanField(help_text="Whether the support level legend is shown.") + allow_unsupported_xblocks = serializers.BooleanField( + required=False, + help_text="Whether the course allows unsupported components. Absent for the container's own types.", + ) + documentation_label = serializers.CharField( + required=False, + help_text="Heading of the support level legend. Absent for the container's own types.", + ) + + class Meta: + ref_name = "CourseContainerSupportLegend" + + +class CourseContainerComponentGroupSerializer(serializers.Serializer): # pylint: disable=abstract-method + """A button of the add-component menu and the templates it offers.""" + + type = serializers.CharField(help_text="Block type of the group, or advanced for the advanced components.") + templates = CourseContainerComponentTemplateSerializer(many=True, help_text="Templates the group offers.") + display_name = serializers.CharField(help_text="Name of the group shown to authors.") + support_legend = CourseContainerSupportLegendSerializer(help_text="How support levels are explained.") + beta = serializers.BooleanField( + required=False, + help_text="Whether the group's block type is in beta. Absent on the advanced group and on groups " + "the container defines for itself.", + ) + + class Meta: + ref_name = "CourseContainerComponentGroup" + + +class CourseContainerFullSerializer(serializers.Serializer): # pylint: disable=abstract-method + """Everything the Studio unit page shows for a unit, or for a container inside a unit.""" + + language_code = serializers.CharField(help_text="Language the response is written in.") + xblock = CourseContainerXBlockSerializer(help_text="The container's name and type.") + is_unit_page = serializers.BooleanField( + help_text="Whether the container is a unit: a vertical placed directly in a subsection.", + ) + is_collapsible = serializers.BooleanField( + help_text="Whether the container is a library content block, whose children authors can collapse.", + ) + position = serializers.IntegerField( + min_value=1, + help_text=( + "Position of the container's unit among the children of its subsection, starting at 1. Children " + "that cannot be loaded are not counted." + ), + ) + prev_url = serializers.CharField( + allow_null=True, + help_text=( + "URL-encoded Studio address of the unit before the container's unit, crossing into the previous " + "subsection or section when needed. Null when there is no previous unit to go to: for the first " + "unit of the course, and when the neighbouring subsection or section it would cross into holds no " + "units." + ), + ) + next_url = serializers.CharField( + allow_null=True, + help_text=( + "URL-encoded Studio address of the unit after the container's unit, crossing into the next " + "subsection or section when needed. Null when there is no next unit to go to: for the last unit " + "of the course, and when the neighbouring subsection or section it would cross into holds no " + "units." + ), + ) + new_unit_category = serializers.CharField(help_text="Block type of a new unit: always vertical.") + outline_url = serializers.CharField(help_text="Studio address of the course outline.") + ancestor_xblocks = CourseContainerAncestorXBlockSerializer( + many=True, + help_text=( + "The container's ancestors, outermost first and down to its direct parent, leaving out the topmost " + "one, the course. Each comes with the blocks beside it." + ), + ) + component_templates = CourseContainerComponentGroupSerializer( + many=True, + help_text=( + "The add-component menu: one group per component type the course enables, then the advanced " + "group when it has templates. A container that declares the block types it holds gets one group " + "per declared type instead." + ), + ) + xblock_info = serializers.DictField( + help_text=( + "The container as the Studio unit page reads it. Always carries actions, ancestor_has_staff_lock, " + "category, course_graders, display_name, due, due_date, edited_on, edited_on_raw, embed_lms_url, " + "enable_hide_from_toc_ui, explanatory_message, format, graded, group_access, has_changes, " + "has_children, has_explicit_staff_lock, hide_from_toc, id, is_tagging_feature_disabled, lms_url, " + "published, published_on, relative_weeks_due, release_date, released_to_students, " + "show_correctness, start, studio_url, upstream_info, user_partition_info, user_partitions, " + "visibility_state and xblock_type. " + "A unit also carries ancestor_info, currently_visible_to_students, discussion_enabled, edited_by, " + "enable_copy_paste_units, has_partition_group_components, published_by and staff_lock_from, plus " + "release_date_from when release_date is set. ancestor_info.ancestors lists the unit's subsection, " + "section and course, each as an object of the same kind; the subsection's carries child_info, " + "whose children are the subsection's units. " + "Some keys depend on configuration: course_authoring_url and taxonomy_tags_widget_url are absent " + "when tagging is disabled, and a unit carries tags only while tagging is enabled; " + "video_sharing_enabled, video_sharing_options and video_sharing_doc_url appear when public video " + "sharing is on; a unit carries summary_configuration_enabled when content summaries are configured." + ), + ) + draft_preview_link = serializers.CharField( + allow_null=True, + help_text="Address of the container's draft preview in the LMS. Null when no LMS address is configured.", + ) + published_preview_link = serializers.CharField( + allow_null=True, + help_text="Address of the container's published version in the LMS. Null when no LMS address is configured.", + ) + show_unit_tags = serializers.BooleanField(help_text="Whether tagging is enabled, so unit tags are shown.") + is_fullwidth_content = serializers.BooleanField( + help_text="Whether the container is a library content block, which the page shows at full width.", + ) + assets_url = serializers.CharField(help_text="Studio address of the course's files and uploads page.") + unit_block_id = serializers.CharField(help_text="Block identifier of the container's unit, without its course.") + subsection_location = serializers.CharField(help_text="Usage key of the subsection holding the container's unit.") + course_sequence_ids = serializers.ListField( + child=serializers.CharField(), + help_text="Usage keys of every subsection of the course, in course order.", + ) + library_content_picker_url = serializers.CharField( + allow_null=True, + help_text=( + "Address of the page that picks content from a library into the course. Null when the new content " + "libraries are not enabled." + ), + ) + + class Meta: + ref_name = "CourseContainerFull" + + +class CourseContainerQuerySerializer(serializers.Serializer): # pylint: disable=abstract-method + """Query parameters of a container read. A parameter sent with an empty value counts as omitted.""" + + view = serializers.ChoiceField( + choices=[CONTAINER_MINIMAL_VIEW, CONTAINER_FULL_VIEW], + default=CONTAINER_MINIMAL_VIEW, + error_messages={"invalid_choice": "Must be minimal or full."}, + help_text=( + "minimal (the default) returns the container and the blocks above it. full returns everything " + "the Studio unit page shows for it, and is available only for a unit or a container inside a unit." + ), + ) + + class Meta: + ref_name = "CourseContainerQuery" 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/containers.py b/cms/djangoapps/contentstore/rest_api/v2/views/containers.py new file mode 100644 index 000000000000..b771577d0d47 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/views/containers.py @@ -0,0 +1,259 @@ +"""API views for a course container.""" + +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 UsageKeyConverter +from rest_framework import viewsets +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from cms.djangoapps.contentstore.rest_api.v2.container_service import get_container, get_container_page +from cms.djangoapps.contentstore.rest_api.v2.serializers.containers import ( + CONTAINER_FULL_VIEW, + CONTAINER_MINIMAL_VIEW, + CourseContainerFullSerializer, + CourseContainerQuerySerializer, + CourseContainerSerializer, +) +from cms.djangoapps.contentstore.views.permissions import CanViewCourse + +_USAGE_KEY_DESCRIPTION = ( + "Usage key of a section, subsection, unit or other container of a course, such as " + "block-v1:edX+DemoX+2026+type@vertical+block@welcome." +) + +_USAGE_KEY_PARAMETER = OpenApiParameter( + name="usage_key", + description=_USAGE_KEY_DESCRIPTION, + location=OpenApiParameter.PATH, + type=str, + pattern=f"^{UsageKeyConverter.regex}$", +) + +_VIEW_PARAMETER = OpenApiParameter( + name="view", + description=CourseContainerQuerySerializer().fields["view"].help_text, + required=False, + location=OpenApiParameter.QUERY, + type=str, + enum=[CONTAINER_MINIMAL_VIEW, CONTAINER_FULL_VIEW], + default=CONTAINER_MINIMAL_VIEW, +) + + +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_UNIT = _EXAMPLE_BLOCK.format("vertical", "welcome") +_EXAMPLE_SECTION = _EXAMPLE_BLOCK.format("chapter", "intro") +_EXAMPLE_SUBSECTION = _EXAMPLE_BLOCK.format("sequential", "getting_started") + +MINIMAL_VIEW_EXAMPLE = OpenApiExample( + "Container", + summary="Default view of a unit", + response_only=True, + status_codes=["200"], + value={ + "id": _EXAMPLE_UNIT, + "category": "vertical", + "display_name": "Welcome", + "display_type": "Unit", + "course_key": _EXAMPLE_COURSE, + "is_unit": True, + "unit_id": _EXAMPLE_UNIT, + "position": 1, + "ancestors": [ + {"id": _EXAMPLE_SECTION, "category": "chapter", "display_name": "Introduction"}, + {"id": _EXAMPLE_SUBSECTION, "category": "sequential", "display_name": "Getting started"}, + ], + }, +) + +SUBSECTION_EXAMPLE = OpenApiExample( + "Subsection", + summary="Default view of a subsection", + description="No unit holds a subsection, so unit_id and position are null.", + response_only=True, + status_codes=["200"], + value={ + "id": _EXAMPLE_SUBSECTION, + "category": "sequential", + "display_name": "Getting started", + "display_type": "Subsection", + "course_key": _EXAMPLE_COURSE, + "is_unit": False, + "unit_id": None, + "position": None, + "ancestors": [ + {"id": _EXAMPLE_SECTION, "category": "chapter", "display_name": "Introduction"}, + ], + }, +) + +FULL_VIEW_EXAMPLE = OpenApiExample( + "Unit page data", + summary="view=full, abridged", + description="xblock_info shows a few of its keys and component_templates one of its groups.", + response_only=True, + status_codes=["200"], + value={ + "language_code": "en", + "xblock": {"display_name": "Welcome", "display_type": "Unit", "category": "vertical"}, + "is_unit_page": True, + "is_collapsible": False, + "position": 1, + "prev_url": None, + "next_url": "%2Fcontainer%2Fblock-v1%3AedX%2BDemoX%2B2026%2Btype%40vertical%2Bblock%40first_steps", + "new_unit_category": "vertical", + "outline_url": f"/course/{_EXAMPLE_COURSE}?format=concise", + "ancestor_xblocks": [ + { + "children": [ + { + "url": f"/course/{_EXAMPLE_COURSE}?show=block-v1%3AedX%2BDemoX%2B2026%2Btype%40chapter%2Bblock%40intro", + "display_name": "Introduction", + "usage_key": _EXAMPLE_SECTION, + }, + ], + "title": "Introduction", + "is_last": False, + }, + { + "children": [ + { + "url": ( + f"/course/{_EXAMPLE_COURSE}" + "?show=block-v1%3AedX%2BDemoX%2B2026%2Btype%40sequential%2Bblock%40getting_started" + ), + "display_name": "Getting started", + "usage_key": _EXAMPLE_SUBSECTION, + }, + ], + "title": "Getting started", + "is_last": True, + }, + ], + "component_templates": [ + { + "type": "html", + "templates": [ + { + "display_name": "Text", + "category": "html", + "boilerplate_name": None, + "hinted": False, + "tab": "common", + "support_level": True, + }, + ], + "display_name": "Text", + "support_legend": { + "show_legend": False, + "allow_unsupported_xblocks": False, + "documentation_label": "Open edX Support Levels:", + }, + "beta": False, + }, + ], + "xblock_info": { + "id": _EXAMPLE_UNIT, + "display_name": "Welcome", + "category": "vertical", + "has_children": True, + "published": True, + "visibility_state": "live", + }, + "draft_preview_link": f"https://lms.example.com/courses/{_EXAMPLE_COURSE}/jump_to/{_EXAMPLE_UNIT}?preview=1", + "published_preview_link": f"https://lms.example.com/courses/{_EXAMPLE_COURSE}/jump_to/{_EXAMPLE_UNIT}", + "show_unit_tags": True, + "is_fullwidth_content": False, + "assets_url": f"/assets/{_EXAMPLE_COURSE}/", + "unit_block_id": "welcome", + "subsection_location": _EXAMPLE_SUBSECTION, + "course_sequence_ids": [_EXAMPLE_SUBSECTION], + "library_content_picker_url": None, + }, +) + +_READ_RESPONSE = PolymorphicProxySerializer( + component_name="CourseContainerRead", + serializers=[CourseContainerSerializer, CourseContainerFullSerializer], + resource_type_field_name=None, +) + + +@extend_schema(tags=["openedx-platform-sdk"]) +class CourseContainerViewSet(StandardizedErrorMixin, viewsets.ViewSet): + """ + A container of a course: a section, a subsection, a unit, or a container + placed in one of them, such as a split test inside a unit. + + By default the container is returned with the blocks above it; the + database and Mongo reads this takes depend on how deeply it is nested, + not on the size of the course. With ``view=full`` a unit, or a container + inside a unit, is returned with everything the Studio unit page shows; + the requesting user's clipboard is not included, as it has its own + endpoint. Sections and subsections have no unit page, so ``view=full`` is + answered with 404 for them. + + 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), and only a caller who may view the course + learns whether a block exists in it. + + A plain ``ViewSet``: the container is read from the modulestore, not from + database rows. + """ + + permission_classes = (IsAuthenticated, CanViewCourse) + serializer_class = CourseContainerSerializer + + def get_serializer(self, *args, **kwargs): + """Instantiate the container serializer declared on this viewset.""" + return self.serializer_class(*args, **kwargs) + + @extend_schema( + summary="Get a container", + description=( + "Returns a container of the course, with the blocks above it: a section, a subsection, a unit, or " + "a container placed in one of them, such as a split test or library content block inside a unit. " + "With view=full, available only for a unit or a container inside a unit, the response is " + "everything the Studio unit page shows: the add-component menu, the container's editing state, the " + "neighbouring units and the Studio and LMS addresses the page links to. The course itself, " + "components such as problems, and blocks the course does not reach are not containers and are " + "answered with 404." + ), + parameters=[_USAGE_KEY_PARAMETER, _VIEW_PARAMETER], + responses={ + 200: OpenApiResponse( + response=_READ_RESPONSE, + description="The container, or with view=full the unit page's data.", + ), + 400: _error("view 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, the key does not name a block of a course, no container in the " + "course has this key, or view=full was requested for a container that is not a unit or inside one." + ), + 406: _error("The Accept header admits no JSON media type."), + }, + examples=[MINIMAL_VIEW_EXAMPLE, SUBSECTION_EXAMPLE, FULL_VIEW_EXAMPLE], + ) + def retrieve(self, request, usage_key): + """Return the container, or with ``view=full`` the unit page's data.""" + query = CourseContainerQuerySerializer(data=request.query_params) + query.is_valid(raise_exception=True) + if query.validated_data["view"] == CONTAINER_FULL_VIEW: + return Response(CourseContainerFullSerializer(get_container_page(request, usage_key)).data) + return Response(self.get_serializer(get_container(usage_key)).data) 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/helpers.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/helpers.py index e956150f300d..4cf2e080c4b2 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/helpers.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/helpers.py @@ -1,16 +1,27 @@ """Shared helpers for the v2 authoring endpoint tests.""" +from contextlib import contextmanager from unittest.mock import patch +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.db.models.signals import post_save from django.dispatch import Signal +from django.test.utils import CaptureQueriesContext from drf_spectacular.generators import SchemaGenerator from drf_spectacular.settings import patched_settings +from openedx_authz.models.engine import PolicyCacheControl +from openedx_events.content_authoring.signals import XBLOCK_UPDATED from openedx_events.tooling import OpenEdxPublicSignal from rest_framework.test import APIClient +from xmodule.modulestore.django import SignalHandler, modulestore +from xmodule.modulestore.mixed import MixedModuleStore +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory + PASSWORD = "test" # The schema settings the CMS publishes its authoring schema with. The test @@ -95,3 +106,119 @@ def resolve_ref(schema, node): if ref is None: return node return schema["components"]["schemas"][ref.rsplit("/", 1)[1]] + + +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) + + +@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) + + +# 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 + + +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 diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_containers.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_containers.py new file mode 100644 index 000000000000..744922cfaf59 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_containers.py @@ -0,0 +1,1523 @@ +"""Tests for the v2 container API.""" + +import re +from collections import Counter +from functools import cache +from types import SimpleNamespace +from unittest import TestCase +from unittest.mock import patch +from urllib.parse import quote_plus + +import ddt +import jsonschema +import pytest +from django.db import connection +from django.db.models.signals import post_save, pre_save +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 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 rest_framework.exceptions import NotFound +from rest_framework.test import APIClient +from xblock.core import XBlock +from xblock.utils.studio_editable import NestedXBlockSpec, StudioContainerWithNestedXBlocksMixin + +from cms.djangoapps.contentstore.rest_api.v1.views.vertical_block import ContainerHandlerView +from cms.djangoapps.contentstore.rest_api.v2 import authoring_urls +from cms.djangoapps.contentstore.rest_api.v2.views.containers import CourseContainerViewSet +from cms.djangoapps.contentstore.rest_api.v2.views.tests.helpers import ( + FIRST_LOOKUP_WRITES, + PASSWORD, + SignalRecorder, + as_json_schema, + build_course, + count_mongo_calls, + generate_cms_schema, + logged_in_client, + record_effects, +) +from cms.djangoapps.contentstore.tests.utils import routed_actions +from cms.djangoapps.contentstore.utils import reverse_usage_url +from cms.djangoapps.contentstore.views.permissions import CanViewCourse +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 common.djangoapps.xblock_django.models import XBlockStudioConfiguration, XBlockStudioConfigurationFlag +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.content.course_overviews.signals import IMPORT_COURSE_DETAILS +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 openedx.core.djangoapps.video_config.toggles import PUBLIC_VIDEO_SHARE +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory + +UNKNOWN_COURSE_UNIT = "block-v1:NoSuchOrg+NoSuchCourse+NoSuchRun+type@vertical+block@unit" +VERSION_GUID = "0123456789abcdef01234567" +LIBRARY_KEYS = ( + "lb:Org:lib:problem:1", # a block of a content library + "lib-block-v1:Org+Lib+type@problem+block@x", # a block of a legacy library + f"lib-block-v1:version@{VERSION_GUID}+type@problem+block@x", # a legacy library key naming only a version +) +COURSE_NOT_FOUND = "The requested course does not exist." +CONTAINER_NOT_FOUND = "No container in this course has this key." +FULL_VIEW_NOT_AVAILABLE = "The full view is available only for a unit or a container inside a unit." +INVALID_VIEW = "Must be minimal or full." +VIEWS = ("default", "full") + +DEFAULT_SHAPE = "CourseContainer" +FULL_SHAPE = "CourseContainerFull" +READ_SHAPE = "CourseContainerRead" + + +def container_url(usage_key): + """Return the address of the container ``usage_key``.""" + return reverse("authoring_v2:container_detail", kwargs={"usage_key": usage_key}) + + +def v1_url(usage_key): + """Return the deprecated container handler address of ``usage_key``.""" + return reverse("cms.djangoapps.contentstore:v1:container_handler", kwargs={"usage_key_string": str(usage_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() + return logged_in_client(user) + + +def _params(view): + return {"view": "full"} if view == "full" else {} + + +def units_of(course_key): + """Every unit of the course, in course order.""" + course = modulestore().get_course(course_key) + return [ + unit + for section in course.get_children() + for subsection in section.get_children() + for unit in subsection.get_children() + ] + + +def _ccx_key(usage_key): + """The key of ``usage_key``'s block in custom course (CCX) 1 of its course.""" + run = usage_key.course_key + return UsageKey.from_string( + f"ccx-block-v1:{run.org}+{run.course}+{run.run}+ccx@1+type@{usage_key.block_type}+block@{usage_key.block_id}" + ) + + +def _block(parent, category, display_name, **kwargs): + """Create a block under ``parent`` and return it as stored.""" + location = BlockFactory.create( + parent_location=parent.location, category=category, display_name=display_name, **kwargs, + ).location + return modulestore().get_item(location) + + +def _without_instance(envelope): + """An error envelope without ``instance``, the request path, which holds the key the caller sent.""" + return {name: value for name, value in envelope.items() if name != "instance"} + + +@cache +def _published_schema(): + """The CMS authoring schema, generated once.""" + return generate_cms_schema() + + +@cache +def _published_components(): + """The schema's components as JSON Schema.""" + return as_json_schema(_published_schema()["components"]["schemas"]) + + +def assert_matches_published_schema(body, component): + """Assert ``body`` is valid against the published schema component ``component``.""" + jsonschema.validate( + body, {"$ref": f"#/components/schemas/{component}", "components": {"schemas": _published_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) + + +class NestedContainerBlock(StudioContainerWithNestedXBlocksMixin, XBlock): + """A container that declares the block types it holds, as some third-party containers do.""" + + @property + def allowed_nested_blocks(self): + return [ + NestedXBlockSpec(None, category="html", label="HTML", single_instance=True), + NestedXBlockSpec(None, category="video", label="Video", disabled=True, disabled_reason="Not available"), + ] + + +# --------------------------------------------------------------------------- +# Who may call the endpoint +# --------------------------------------------------------------------------- + +def assert_inactive_jwt_reads(user, usage_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(container_url(usage_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(usage_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, on GET and HEAD, against the expected one and the v1 decision.""" + + def assert_access(self, user, view, expected, usage_key=None, v1_expected=None): + """ + Assert the status, the error envelope when refused, and the v1 status: + ``v1_expected`` where v1 answers differently, otherwise ``expected``. + """ + usage_key = usage_key or self.unit.location + response = _client(user).get(container_url(usage_key), _params(view)) + head = _client(user).head(container_url(usage_key), _params(view)) + v1_status = _client(user).get(v1_url(usage_key)).status_code + assert response.status_code == expected, response.content + assert head.status_code == expected + 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) + v1_expected = expected if v1_expected is None else v1_expected + assert v1_status == v1_expected, f"v1 decided {v1_status} for the same caller" + + +@ddt.ddt +class ContainerAccessLegacyPermissionsTest(_AccessMatrixMixin, SharedModuleStoreTestCase): + """Who may read a container 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) + cls.unit = units_of(cls.course.id)[0] + + 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) + response = _client().get(container_url(self.unit.location), _params(view)) + assert response["WWW-Authenticate"] == 'JWT realm="api"' + + @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): + """v1 checks permission first, so it refuses a caller without a role and fails to find the block for staff.""" + self.assert_access(UserFactory(), view, 404, usage_key=UNKNOWN_COURSE_UNIT, v1_expected=403) + self.assert_access(UserFactory(is_staff=True), view, 404, usage_key=UNKNOWN_COURSE_UNIT, v1_expected=400) + + @ddt.data(*VIEWS) + def test_anonymous_caller_of_an_unknown_course_is_refused_with_401(self, view): + self.assert_access(None, view, 401, usage_key=UNKNOWN_COURSE_UNIT) + + @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_a_missing_block_is_404_for_a_reader_and_403_for_anyone_else(self, view): + """v1 answers a reader with an empty 400.""" + missing = self.unit.location.replace(block_id="missing") + reader = self._user_with(CourseStaffRole(self.course.id)) + self.assert_access(reader, view, 404, usage_key=missing, v1_expected=400) + self.assert_access(UserFactory(), view, 403, usage_key=missing) + assert _client(reader).get(v1_url(missing)).content == b"" + + @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(container_url(self.unit.location), _params(view)).status_code == 200, "active control" + user.is_active = False + user.save() + response = client.get(container_url(self.unit.location), _params(view)) + assert_error_envelope(response, expected_status=401, expected_type_slug="authn") + assert client.get(v1_url(self.unit.location)).status_code == v1_expected + + @ddt.data(*VIEWS) + def test_jwt_caller_may_read(self, view): + user = self._user_with(CourseStaffRole(self.course.id)) + response = APIClient().get( + container_url(self.unit.location), _params(view), HTTP_AUTHORIZATION=f"JWT {create_jwt_for_user(user)}", + ) + assert response.status_code == 200, response.content + + @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.unit.location, 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( + container_url(self.unit.location), _params(view), HTTP_AUTHORIZATION=f"Bearer {token}", + ) + assert_error_envelope(response, expected_status=401, expected_type_slug="authn") + assert APIClient().get(v1_url(self.unit.location), HTTP_AUTHORIZATION=f"Bearer {token}").status_code == 200 + + @ddt.data(*VIEWS) + def test_a_custom_course_is_refused_to_everyone(self, view): + """ + Studio read access is never granted on a custom course (CCX), global + staff included. Its key names the course run, so the course is found + and the caller is refused, on v2 as on v1. + """ + key = _ccx_key(self.unit.location) + self.assert_access(UserFactory(is_staff=True), view, 403, usage_key=key) + self.assert_access(self._user_with(CourseStaffRole(self.course.id)), view, 403, usage_key=key) + + @ddt.data("post", "put", "patch", "delete") + def test_other_methods_are_refused_after_authentication(self, method): + """Anonymous callers are refused before the method is; signed-in readers learn it is not allowed.""" + url = container_url(self.unit.location) + anonymous = getattr(_client(), method)(url, {}, format="json") + assert_error_envelope(anonymous, expected_status=401, expected_type_slug="authn") + reader = self._user_with(CourseStaffRole(self.course.id)) + refused = getattr(_client(reader), method)(url, {}, format="json") + # The error library does not catalogue MethodNotAllowed, so it is typed + # internal. Change the expected type here once it does. + assert_error_envelope(refused, expected_status=405, expected_type_slug="internal") + + +@ddt.ddt +class ContainerAccessAuthzTest(CourseAuthoringAuthzTestMixin, _AccessMatrixMixin, SharedModuleStoreTestCase): + """Who may read a container 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) + cls.unit = units_of(cls.course.id)[0] + cls.other_unit = units_of(cls.other_course.id)[0] + + 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, usage_key=self.other_unit.location) + 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, usage_key=UNKNOWN_COURSE_UNIT, v1_expected=403) + + @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_a_missing_block_is_404_for_a_reader_and_403_for_anyone_else(self, view): + missing = self.unit.location.replace(block_id="missing") + self.assert_access(self._user_with(COURSE_AUDITOR.external_key), view, 404, usage_key=missing, v1_expected=400) + self.assert_access(self.unauthorized_user, view, 403, usage_key=missing) + + @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(container_url(self.unit.location), _params(view)).status_code == 200, "active control" + user.is_active = False + user.save() + response = client.get(container_url(self.unit.location), _params(view)) + assert_error_envelope(response, expected_status=401, expected_type_slug="authn") + assert client.get(v1_url(self.unit.location)).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.""" + assert_inactive_jwt_reads(self._user_with(COURSE_AUDITOR.external_key), self.unit.location, view) + + @ddt.data(*VIEWS) + def test_a_custom_course_is_refused_to_staff_and_course_staff(self, view): + """ + The permission policy grants staff and superusers every course, but a + custom course (CCX) is not one of those scopes, and a role on the + course does not extend to its custom courses: refused on v2 as on v1. + """ + key = _ccx_key(self.unit.location) + self.assert_access(self.staff_user, view, 403, usage_key=key) + self.assert_access(self._user_with(COURSE_STAFF.external_key), view, 403, usage_key=key) + + @ddt.data(*VIEWS) + def test_staff_and_superuser_without_a_role_are_decided_as_v1_decides(self, view): + """Only the permission policy is consulted; it admits them, on v2 as on v1.""" + self.assert_access(self.staff_user, view, 200) + self.assert_access(self.super_user, view, 200) + + +class CanViewCourseFromAUsageKeyTest(TestCase): + """The permission class on a route that names a block rather than a course.""" + + def test_a_route_with_neither_key_is_refused(self): + assert CanViewCourse().has_permission(None, SimpleNamespace(kwargs={})) is False + + def test_a_key_outside_any_course_is_not_found_without_a_course_lookup(self): + with patch.object(CourseOverview, "course_exists") as course_exists: + for key in LIBRARY_KEYS: + view = SimpleNamespace(kwargs={"usage_key": UsageKey.from_string(key)}) + with pytest.raises(NotFound) as raised: + CanViewCourse().has_permission(None, view) + assert str(raised.value.detail) == COURSE_NOT_FOUND + course_exists.assert_not_called() + + def test_a_key_naming_only_a_course_version_is_not_found_without_a_course_lookup(self): + key = UsageKey.from_string(f"block-v1:version@{VERSION_GUID}+type@vertical+block@x") + with patch.object(CourseOverview, "course_exists") as course_exists: + with pytest.raises(NotFound): + CanViewCourse().has_permission(None, SimpleNamespace(kwargs={"usage_key": key})) + course_exists.assert_not_called() + + +# --------------------------------------------------------------------------- +# What is a container +# --------------------------------------------------------------------------- + +class ContainerMembershipTest(ModuleStoreTestCase): + """ + A container is a block other than the course that can hold children and + that the course reaches. Anything else is answered with 404 without + naming the block. The unit page data is offered only for a unit or a + container inside a unit. + """ + + def setUp(self): + super().setUp() + course = build_course(sections=1, subsections=1, units=1) + self.course_key = course.id + store = modulestore() + self.course = store.get_course(course.id) + self.section = self.course.get_children()[0] + self.subsection = self.section.get_children()[0] + self.unit = self.subsection.get_children()[0] + self.problem = self.unit.get_children()[0] + self.split_in_subsection = _block(self.subsection, "split_test", "Split test in a subsection") + self.group_in_subsection_split = _block(self.split_in_subsection, "vertical", "Group") + self.vertical_in_section = _block(self.section, "vertical", "Vertical in a section") + self.orphan_unit = store.create_item(self.user.id, course.id, "vertical", "orphan_unit") + self.orphan_section = store.create_item(self.user.id, course.id, "chapter", "orphan_section") + orphan_subsection = store.create_item(self.user.id, course.id, "sequential", "orphan_subsection") + self.unit_of_orphan_subsection = _block(orphan_subsection, "vertical", "Unit of an orphan subsection") + subsection_of_orphan_section = _block(self.orphan_section, "sequential", "Subsection of an orphan section") + self.unit_of_orphan_section = _block(subsection_of_orphan_section, "vertical", "Unit of an orphan section") + self.reader = UserFactory() + CourseStaffRole(course.id).add_users(self.reader) + self.client = _client(self.reader) + + def _assert_not_a_container(self, key, v1_status): + for params in ({}, {"view": "full"}): + response = self.client.get(container_url(key), params) + envelope = assert_error_envelope(response, expected_status=404, expected_type_slug="not-found") + assert envelope["detail"] == CONTAINER_NOT_FOUND + assert envelope["instance"] == container_url(key) + assert str(key) not in str(_without_instance(envelope)) + assert self.client.get(v1_url(key)).status_code == v1_status + + def _assert_no_unit_page(self, key, v1_status): + """The default view answers; view=full is refused with 404, as no unit holds the container.""" + default = self.client.get(container_url(key)) + assert default.status_code == 200, default.content + assert_matches_one_read_shape(default.json(), DEFAULT_SHAPE) + assert default.json()["unit_id"] is None + assert default.json()["position"] is None + assert default.json()["is_unit"] is False + full = self.client.get(container_url(key), {"view": "full"}) + envelope = assert_error_envelope(full, expected_status=404, expected_type_slug="not-found") + assert envelope["detail"] == FULL_VIEW_NOT_AVAILABLE + assert str(key) not in str(_without_instance(envelope)) + assert self.client.get(v1_url(key)).status_code == v1_status + + def test_sections_and_subsections_have_no_unit_page(self): + """v1 fails on each with a server error.""" + for block in (self.section, self.subsection): + self._assert_no_unit_page(block.location, 500) + + def test_containers_placed_directly_in_a_section_or_subsection_have_no_unit_page(self): + """v1 fails on each with a server error.""" + for block in (self.split_in_subsection, self.group_in_subsection_split, self.vertical_in_section): + self._assert_no_unit_page(block.location, 500) + + def test_the_course_is_not_a_container(self): + """v1 fails on it with a server error.""" + self._assert_not_a_container(self.course.location, 500) + + def test_a_component_is_not_a_container(self): + """v1 answers a component's key with the unit page data of the component.""" + self._assert_not_a_container(self.problem.location, 200) + + def test_blocks_the_course_does_not_reach_are_not_containers(self): + """v1 fails on each with a server error.""" + for block in (self.orphan_section, self.orphan_unit, self.unit_of_orphan_subsection): + self._assert_not_a_container(block.location, 500) + + def test_a_unit_of_a_section_the_course_does_not_reach_is_not_a_container(self): + """v1 needs only a section above the unit's subsection, so it answers with the unit page data.""" + self._assert_not_a_container(self.unit_of_orphan_section.location, 200) + + def test_a_missing_block_is_not_a_container(self): + """v1 answers it with an empty 400.""" + self._assert_not_a_container(self.unit.location.replace(block_id="missing"), 400) + + def test_keys_outside_any_course_are_not_found(self): + for key in (*LIBRARY_KEYS, f"block-v1:version@{VERSION_GUID}+type@vertical+block@x"): + for params in ({}, {"view": "full"}): + response = self.client.get(container_url(key), params) + envelope = assert_error_envelope(response, expected_status=404, expected_type_slug="not-found") + assert envelope["detail"] == COURSE_NOT_FOUND + assert VERSION_GUID not in str(_without_instance(envelope)) + assert_error_envelope(_client().get(container_url(key)), expected_status=401, expected_type_slug="authn") + # v1 passes these keys to the modulestore and the permission check + # unguarded; only that it does not answer with a body is pinned. + assert self.client.get(v1_url(key)).status_code != 200, key + + def test_a_unit_and_containers_inside_it_have_a_unit_page(self): + split = _block(self.unit, "split_test", "Split test in a unit") + group = _block(split, "vertical", "Group") + library_content = _block(self.unit, "library_content", "Library content in a unit") + nested_unit = _block(self.unit, "vertical", "Unit in a unit") + empty_unit = _block(self.subsection, "vertical", "Empty unit") + for block in (self.unit, split, group, library_content, nested_unit, empty_unit): + for params, shape in (({}, DEFAULT_SHAPE), ({"view": "full"}, FULL_SHAPE)): + response = self.client.get(container_url(block.location), params) + assert response.status_code == 200, (block.location, response.content) + assert_matches_one_read_shape(response.json(), shape) + + +# --------------------------------------------------------------------------- +# The default view +# --------------------------------------------------------------------------- + +@ddt.ddt +class ContainerDefaultViewTest(ModuleStoreTestCase): + """The container and the blocks above it.""" + + def setUp(self): + super().setUp() + course = build_course(sections=2, subsections=2, units=2) + self.course_key = course.id + self.reader = UserFactory() + CourseStaffRole(course.id).add_users(self.reader) + self.client = _client(self.reader) + self.units = units_of(course.id) + + def _read(self, usage_key, **params): + response = self.client.get(container_url(usage_key), params) + assert response.status_code == 200, response.content + return response.json() + + @staticmethod + def _node(block): + return {"id": str(block.location), "category": block.category, "display_name": block.display_name} + + @ddt.data(0, 1, 5, 7) + def test_a_unit(self, index): + unit = self.units[index] + subsection = unit.get_parent() + section = subsection.get_parent() + assert self._read(unit.location) == { + "id": str(unit.location), + "category": "vertical", + "display_name": unit.display_name, + "display_type": "Unit", + "course_key": str(self.course_key), + "is_unit": True, + "unit_id": str(unit.location), + "position": index % 2 + 1, + "ancestors": [self._node(section), self._node(subsection)], + } + + def test_a_section(self): + section = self.units[4].get_parent().get_parent() + body = self._read(section.location) + assert body == { + "id": str(section.location), + "category": "chapter", + "display_name": "Section 1", + "display_type": "Section", + "course_key": str(self.course_key), + "is_unit": False, + "unit_id": None, + "position": None, + "ancestors": [], + } + assert_matches_one_read_shape(body, DEFAULT_SHAPE) + + def test_a_subsection(self): + subsection = self.units[6].get_parent() + body = self._read(subsection.location) + assert body == { + "id": str(subsection.location), + "category": "sequential", + "display_name": "Subsection 1.1", + "display_type": "Subsection", + "course_key": str(self.course_key), + "is_unit": False, + "unit_id": None, + "position": None, + "ancestors": [self._node(subsection.get_parent())], + } + assert_matches_one_read_shape(body, DEFAULT_SHAPE) + + def test_a_container_two_levels_inside_a_unit(self): + unit = self.units[3] + split = _block(unit, "split_test", "Split test") + group = _block(split, "vertical", "Group") + subsection = unit.get_parent() + assert self._read(group.location) == { + "id": str(group.location), + "category": "vertical", + "display_name": "Group", + "display_type": "Vertical", + "course_key": str(self.course_key), + "is_unit": False, + "unit_id": str(unit.location), + "position": 2, + "ancestors": [ + self._node(subsection.get_parent()), self._node(subsection), self._node(unit), self._node(split), + ], + } + + def test_bodies_match_the_published_shape(self): + unit = self.units[0] + group = _block(_block(unit, "split_test", "Split test"), "vertical", "Group") + for key in (unit.location, group.location): + assert_matches_one_read_shape(self._read(key), DEFAULT_SHAPE) + + def test_minimal_view_is_the_default(self): + key = self.units[0].location + assert self._read(key, view="minimal") == self._read(key) + + def test_a_blank_view_is_the_default_view(self): + key = self.units[0].location + assert self._read(key, view="") == self._read(key) + + def test_unknown_parameters_are_ignored(self): + """action, a v1 parameter, is not one of this endpoint's parameters.""" + key = self.units[0].location + assert self._read(key, action="edit", foo="bar") == self._read(key) + full = self._read(key, view="full", action="edit") + assert "action" not in full + + def test_an_unknown_view_is_refused_with_400(self): + response = self.client.get(container_url(self.units[0].location), {"view": "bogus"}) + envelope = assert_error_envelope(response, expected_status=400, expected_type_slug="validation") + assert envelope["errors"] == {"view": [INVALID_VIEW]} + assert "bogus" not in response.content.decode() + + def test_head_answers_without_a_body(self): + for params in ({}, {"view": "full"}): + response = self.client.head(container_url(self.units[0].location), params) + assert response.status_code == 200 + assert response.content == b"" + + +# --------------------------------------------------------------------------- +# The full view against v1 +# --------------------------------------------------------------------------- + +# Differences between the v1 read and view=full: (json path pattern, reason). +# The comparison asserts that each occurs and that nothing else differs. +INTENTIONAL_DIFFERENCES = [ + (r"user_clipboard([.\[].*)?", "the clipboard is its own resource at /api/content-staging/v1/clipboard/"), + (r"action", "v1 echoes its unvalidated action parameter, which v2 does not take"), +] + +_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): + """ + Compare two bodies. Return the json paths that differ, those among them no + declared difference explains, and the 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))} + declared = [pattern for pattern, _ in INTENTIONAL_DIFFERENCES] + unexpected = {key for key in changed if not any(re.fullmatch(pattern, key) for pattern in declared)} + missing = {pattern for pattern in declared 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_each_declared_difference_must_occur_and_nothing_else_may(): + legacy = {"action": "view", "user_clipboard": {"content": None}, "position": 1} + _, unexpected, missing = _differences(legacy, {"position": 1}) + assert (unexpected, missing) == (set(), set()) + _, unexpected, missing = _differences(legacy, {"action": "view", "position": 1}) + assert (unexpected, missing) == (set(), {"action"}) + _, unexpected, missing = _differences(legacy, {"position": 2}) + assert (unexpected, missing) == ({"position"}, set()) + + +class _FullViewParityMixin: + """Compares view=full against the v1 read of a container.""" + + def _pair(self, user, usage_key, **params): + """ + The v1 read and the view=full read of ``usage_key`` by ``user``, as + JSON. view=full is read first, so nothing the v1 read stores can reach it. + """ + client = _client(user) + new = client.get(container_url(usage_key), {"view": "full", **params}) + legacy = client.get(v1_url(usage_key), params) + assert legacy.status_code == new.status_code == 200, (legacy.content, new.content) + return legacy.json(), new.json() + + def assert_parity(self, user, usage_key, **params): + """Assert the two reads differ exactly by the declared differences and return the v2 body.""" + legacy, new = self._pair(user, usage_key, **params) + _, unexpected, missing = _differences(legacy, new) + assert unexpected == set(), sorted(unexpected) + assert missing == set(), sorted(missing) + assert set(legacy) - set(new) == {"user_clipboard", "action"} + assert set(new) <= set(legacy) + assert_matches_one_read_shape(new, FULL_SHAPE) + return new + + +@ddt.ddt +class ContainerFullViewParityTest(_FullViewParityMixin, ModuleStoreTestCase): + """view=full returns what v1 returns for the same caller, except the declared differences.""" + + def setUp(self): + super().setUp() + course = build_course(sections=2, subsections=2, units=2) + self.course_key = course.id + self.units = units_of(course.id) + self.reader = UserFactory() + CourseStaffRole(course.id).add_users(self.reader) + + @staticmethod + def _studio_url(block): + return quote_plus(reverse_usage_url("container_handler", block.location)) + + def test_every_unit_links_its_neighbours_across_subsections_and_sections(self): + """The first unit has no previous unit and the last no next; the others link across boundaries.""" + for index, unit in enumerate(self.units): + body = self.assert_parity(self.reader, unit.location) + previous = self.units[index - 1] if index > 0 else None + following = self.units[index + 1] if index + 1 < len(self.units) else None + assert body["prev_url"] == (self._studio_url(previous) if previous else None), index + assert body["next_url"] == (self._studio_url(following) if following else None), index + assert body["position"] == index % 2 + 1 + assert body["is_unit_page"] is True + assert body["unit_block_id"] == unit.location.block_id + assert body["subsection_location"] == str(unit.get_parent().location) + assert [entry["title"] for entry in body["ancestor_xblocks"]] == [ + unit.get_parent().get_parent().display_name, unit.get_parent().display_name, + ] + assert [entry["is_last"] for entry in body["ancestor_xblocks"]] == [False, True] + + def test_containers_inside_a_unit(self): + """ + A split test, a group in it and a library content block: not unit pages, + so neither ancestor information nor publishing information is built, + and a component listed beside an ancestor has no page address. + """ + unit = self.units[2] + split = _block(unit, "split_test", "Split test") + group = _block(split, "vertical", "Group") + _block(group, "problem", "Problem in a group") + library_content = _block(unit, "library_content", "Library content") + unit = modulestore().get_item(unit.location) + for block in (split, group, library_content): + body = self.assert_parity(self.reader, block.location) + assert body["is_unit_page"] is False + assert body["position"] == 1 + assert body["unit_block_id"] == unit.location.block_id + assert "ancestor_info" not in body["xblock_info"] + assert "edited_by" not in body["xblock_info"] + assert body["is_collapsible"] is body["is_fullwidth_content"] is (block is library_content) + group_body = self.assert_parity(self.reader, group.location) + beside_split = group_body["ancestor_xblocks"][-1]["children"] + assert [child["usage_key"] for child in beside_split] == [str(key) for key in unit.children] + assert {child["usage_key"]: child["url"] for child in beside_split}[str(unit.children[0])] is None + + @XBlock.register_temp_plugin(NestedContainerBlock, identifier="nested-container-test") + def test_a_container_that_declares_its_block_types_offers_only_those(self): + container = _block(self.units[0], "nested-container-test", "Nested container") + body = self.assert_parity(self.reader, container.location) + # The block type declares no display name. + assert body["xblock"]["display_type"] is None + default = _client(self.reader).get(container_url(container.location)).json() + assert default["display_type"] is None + assert_matches_one_read_shape(default, DEFAULT_SHAPE) + assert body["component_templates"] == [ + { + "type": "html", + "templates": [{ + "display_name": "HTML", "category": "html", "boilerplate_name": None, "hinted": False, + "tab": "common", "support_level": True, "single_instance": True, + }], + "display_name": "HTML", + "support_legend": {"show_legend": False}, + }, + { + "type": "video", + "templates": [{ + "display_name": "Video", "category": "video", "boilerplate_name": None, "hinted": False, + "tab": "common", "support_level": True, "disabled": True, "disabled_reason": "Not available", + }], + "display_name": "Video", + "support_legend": {"show_legend": False}, + }, + ] + + def test_the_course_component_templates(self): + body = self.assert_parity(self.reader, self.units[0].location) + groups = {group["type"]: group for group in body["component_templates"]} + assert {"html", "video", "problem", "advanced"} <= set(groups) + assert "beta" not in groups["advanced"] + assert groups["html"]["beta"] is False + assert set(groups["html"]["support_legend"]) == {"show_legend", "allow_unsupported_xblocks", "documentation_label"} + assert {template["support_level"] for group in groups.values() for template in group["templates"]} == {True} + + def test_configured_support_levels_are_returned_as_stored(self): + XBlockStudioConfigurationFlag.objects.create(enabled=True) + XBlockStudioConfiguration.objects.create(name="html", enabled=True, support_level="fs") + XBlockStudioConfiguration.objects.create(name="video", enabled=True, support_level="ps") + body = self.assert_parity(self.reader, self.units[0].location) + levels = { + group["type"]: {template["support_level"] for template in group["templates"]} + for group in body["component_templates"] + } + assert levels["html"] == {"fs"} + assert levels["video"] == {"ps"} + assert all(group["support_legend"]["show_legend"] is True for group in body["component_templates"]) + + def test_library_content_picker_follows_the_new_libraries(self): + key = self.units[0].location + assert self.assert_parity(self.reader, key)["library_content_picker_url"] is None + with override_settings(MEILISEARCH_ENABLED=True): + picker = self.assert_parity(self.reader, key)["library_content_picker_url"] + assert picker.endswith("/component-picker?variant=published") + + @ddt.data(False, True) + def test_unit_tags_follow_the_tagging_feature(self, tagging_disabled): + taxonomy = tagging_api.create_taxonomy(name="Topics", export_id="topics") + tagging_api.set_taxonomy_orgs(taxonomy, all_orgs=True) + tagging_api.add_tag_to_taxonomy(taxonomy, "Algebra") + unit = self.units[0] + tagging_api.tag_object(str(unit.location), taxonomy, ["Algebra"]) + with override_waffle_flag(DISABLE_TAGGING_FEATURE, active=tagging_disabled): + body = self.assert_parity(self.reader, unit.location) + assert body["show_unit_tags"] is not tagging_disabled + if tagging_disabled: + assert "tags" not in body["xblock_info"] + else: + assert body["xblock_info"]["tags"]["count"] == 1 + assert [t["value"] for t in body["xblock_info"]["tags"]["taxonomies"]] == ["Topics"] + + @ddt.data(False, True) + def test_video_sharing_keys_follow_public_video_sharing(self, enabled): + with patch.object(PUBLIC_VIDEO_SHARE, "is_enabled", return_value=enabled): + body = self.assert_parity(self.reader, self.units[0].location) + sharing = {"video_sharing_enabled", "video_sharing_options", "video_sharing_doc_url"} + assert (sharing & set(body["xblock_info"])) == (sharing if enabled else set()) + if enabled: + assert body["xblock_info"]["video_sharing_enabled"] is True + + def test_no_neighbour_across_a_section_without_units(self): + """ + Sections A and C hold one unit each; section B between them holds only + an empty subsection. Neither unit has a neighbour to go to across B. + """ + course = CourseFactory.create() + unit_a = _block(_block(_block(course, "chapter", "A"), "sequential", "A1"), "vertical", "Unit A") + _block(_block(course, "chapter", "B"), "sequential", "B1, without units") + unit_c = _block(_block(_block(course, "chapter", "C"), "sequential", "C1"), "vertical", "Unit C") + reader = UserFactory() + CourseStaffRole(course.id).add_users(reader) + body_a = self.assert_parity(reader, unit_a.location) + body_c = self.assert_parity(reader, unit_c.location) + assert (body_a["prev_url"], body_a["next_url"]) == (None, None) + assert (body_c["prev_url"], body_c["next_url"]) == (None, None) + + def test_a_key_pinned_to_a_course_version_reads_as_on_v1(self): + version = modulestore().get_course(self.course_key).course_version + pinned_course = self.course_key.for_branch(ModuleStoreEnum.BranchName.draft).replace(version_guid=version) + key = self.units[1].location.map_into_course(pinned_course) + assert key.version_guid == version + body = self.assert_parity(UserFactory(is_staff=True), key) + assert body["unit_block_id"] == self.units[1].location.block_id + + def test_a_child_that_cannot_be_loaded_is_not_counted_in_the_position(self): + """ + A subsection lists a child that no longer exists between its two + units. The second unit is third in the list but second among the + children that load, which is the position the unit page shows. + """ + store = modulestore() + unit = self.units[1] + subsection = store.get_item(unit.get_parent().location) + subsection.children.insert(1, self.course_key.make_usage_key("vertical", "dangling")) + store.update_item(subsection, self.user.id) + assert store.get_item(subsection.location).children.index(unit.location) == 2 + body = self.assert_parity(self.reader, unit.location) + assert body["position"] == 2 + default = _client(self.reader).get(container_url(unit.location)).json() + assert default["position"] == 2 + + def test_preview_links(self): + unit = self.units[0] + body = self.assert_parity(self.reader, unit.location) + assert body["published_preview_link"].endswith(f"/courses/{self.course_key}/jump_to/{unit.location}") + assert body["draft_preview_link"] == f"{body['published_preview_link']}?preview=1" + + @override_settings(LMS_ROOT_URL=None) + def test_preview_links_are_null_without_an_lms_address(self): + body = self.assert_parity(self.reader, self.units[0].location) + assert body["draft_preview_link"] is None + assert body["published_preview_link"] is None + + def test_a_key_pinned_to_the_draft_branch_reads_as_on_v1(self): + key = self.units[1].location.for_branch(ModuleStoreEnum.BranchName.draft) + body = self.assert_parity(UserFactory(is_staff=True), key) + assert body["unit_block_id"] == self.units[1].location.block_id + + def test_the_comparison_fails_when_a_field_changes(self): + """The harness itself: an undeclared change in view=full is reported.""" + service = "cms.djangoapps.contentstore.rest_api.v2.container_service" + with patch(f"{service}.get_lms_link_for_item", return_value="https://changed.example.com/"): + legacy, new = self._pair(self.reader, self.units[0].location) + _, unexpected, missing = _differences(legacy, new) + assert unexpected == {"draft_preview_link", "published_preview_link"} + assert missing == set() + + +# The top-level keys of view=full xblock_info, by block role, with tagging on +# and every other configuration-dependent key off. They come from the shared +# create_xblock_info builder; v2 publishes them as documented in +# CourseContainerFullSerializer.xblock_info. +CONTAINER_XBLOCK_INFO_KEYS = frozenset({ + "actions", "ancestor_has_staff_lock", "category", "course_authoring_url", "course_graders", "display_name", + "due", "due_date", "edited_on", "edited_on_raw", "embed_lms_url", "enable_hide_from_toc_ui", + "explanatory_message", "format", "graded", "group_access", "has_changes", "has_children", + "has_explicit_staff_lock", "hide_from_toc", "id", "is_tagging_feature_disabled", "lms_url", "published", + "published_on", "relative_weeks_due", "release_date", "released_to_students", "show_correctness", "start", + "studio_url", "taxonomy_tags_widget_url", "upstream_info", "user_partition_info", "user_partitions", + "visibility_state", "xblock_type", +}) +UNIT_XBLOCK_INFO_KEYS = CONTAINER_XBLOCK_INFO_KEYS | { + "ancestor_info", "currently_visible_to_students", "discussion_enabled", "edited_by", "enable_copy_paste_units", + "has_partition_group_components", "published_by", "staff_lock_from", "tags", +} +TAGGING_KEYS = frozenset({"course_authoring_url", "taxonomy_tags_widget_url", "tags"}) +CONDITIONAL_KEYS = frozenset({ + "release_date_from", "summary_configuration_enabled", "video_sharing_doc_url", "video_sharing_enabled", + "video_sharing_options", "child_info", "ancestors", +}) + + +@ddt.ddt +class ContainerFullViewXBlockInfoKeysTest(ModuleStoreTestCase): + """The keys view=full xblock_info carries are the published contract.""" + + def setUp(self): + super().setUp() + course = build_course(sections=1, subsections=1, units=2) + self.unit = units_of(course.id)[0] + self.split = _block(self.unit, "split_test", "Split test") + self.group = _block(self.split, "vertical", "Group") + self.reader = UserFactory() + CourseStaffRole(course.id).add_users(self.reader) + + def _xblock_info(self, block): + response = _client(self.reader).get(container_url(block.location), {"view": "full"}) + assert response.status_code == 200, response.content + return response.json()["xblock_info"] + + @ddt.data(False, True) + def test_every_role_carries_exactly_the_documented_keys(self, tagging_disabled): + removed = TAGGING_KEYS if tagging_disabled else set() + with override_waffle_flag(DISABLE_TAGGING_FEATURE, active=tagging_disabled): + for role, block, expected in ( + ("unit", self.unit, UNIT_XBLOCK_INFO_KEYS), + ("container in a unit", self.split, CONTAINER_XBLOCK_INFO_KEYS), + ("container two levels inside a unit", self.group, CONTAINER_XBLOCK_INFO_KEYS), + ): + info = self._xblock_info(block) + keys = set(info) - {"release_date_from"} + assert keys == expected - removed, ( + f"The keys of a {role} in view=full xblock_info changed: added {sorted(keys - expected)}, " + f"removed {sorted((expected - removed) - keys)}. They are built by the shared " + "create_xblock_info and are part of the published v2 container contract. A removed or " + "renamed key breaks v2 callers and needs a new version; an added key must be documented in " + "CourseContainerFullSerializer.xblock_info and listed here." + ) + if role == "unit": + assert ("release_date_from" in info) is (info["release_date"] is not None) + else: + assert "release_date_from" not in info + + def test_a_units_ancestor_info_lists_its_subsection_with_its_units(self): + info = self._xblock_info(self.unit) + assert list(info["ancestor_info"]) == ["ancestors"] + ancestors = info["ancestor_info"]["ancestors"] + assert [ancestor["category"] for ancestor in ancestors] == ["sequential", "chapter", "course"] + subsection = self.unit.get_parent() + assert [child["id"] for child in ancestors[0]["child_info"]["children"]] == [ + str(key) for key in subsection.children + ] + assert all("child_info" not in ancestor for ancestor in ancestors[1:]) + + def test_the_documented_keys_are_named_in_the_published_description(self): + description = _published_schema()["components"]["schemas"][FULL_SHAPE]["properties"]["xblock_info"][ + "description" + ] + named = set(re.findall(r"[a-z]+(?:_[a-z]+)+|\b[a-z]{2,}\b", description)) + assert (UNIT_XBLOCK_INFO_KEYS | CONDITIONAL_KEYS) - named == set() + + +# --------------------------------------------------------------------------- +# Reads write nothing +# --------------------------------------------------------------------------- + +class _ReadIsSideEffectFreeMixin: + """ + Every read (both views, GET and HEAD, of a unit, a container inside it, a + subsection and a section) by a reader whose clipboard holds a block of a + course without an overview record writes no row and no Mongo document, + stores nothing through the modulestore, saves no model and sends no Django + signal or openedx-event. The v1 read of the same unit builds that course's + overview, which announces the import. + + The one exception: the reads start on a database that has never served a + role or permission lookup, and the first lookup stores the permission + library's policy version row (``PolicyCacheControl``), sending that + model's pre_save and post_save. Nothing else is written or sent. + """ + + def reader_for(self, course): + """Return a user who may read ``course``.""" + raise NotImplementedError + + def _reader_with_a_clipboard_from_a_course_without_an_overview(self, course): + reader = self.reader_for(course) + source = build_course(sections=1, subsections=1, units=1) + content_staging_api.save_xblock_to_user_clipboard(units_of(source.id)[0], reader.id) + CourseOverview.objects.filter(id=source.id).delete() + PolicyCacheControl.objects.all().delete() + return reader, source + + def _record_imports(self): + imported = [] + + def on_import(sender, **kwargs): # pylint: disable=unused-argument + imported.append(kwargs["courserun_key"]) + + IMPORT_COURSE_DETAILS.connect(on_import, weak=False) + self.addCleanup(IMPORT_COURSE_DETAILS.disconnect, on_import) + return imported + + def _assert_reads_have_no_side_effects(self, **course_kwargs): + """Make every read of a course built with ``course_kwargs``; return the unit's full view.""" + course = build_course(sections=1, subsections=1, units=2, **course_kwargs) + unit = units_of(course.id)[0] + group = _block(_block(unit, "split_test", "Split test"), "vertical", "Group") + subsection = unit.get_parent() + section = subsection.get_parent() + reader, source = self._reader_with_a_clipboard_from_a_course_without_an_overview(course) + client = _client(reader) + imported = self._record_imports() + full = {"view": "full"} + reads = [ + ("get", unit, {}), ("get", unit, full), ("head", unit, {}), ("head", unit, full), + ("get", group, {}), ("get", group, full), ("head", group, full), + ("get", subsection, {}), ("get", section, {}), ("head", section, {}), + ] + assert not PolicyCacheControl.objects.exists() + for index, (method, block, params) in enumerate(reads): + # The signal recorder is outermost, so callbacks run on commit as + # the effects recorder exits are still seen. + with SignalRecorder() as signals, record_effects(self) as effects: + response = getattr(client, method)(container_url(block.location), params) + first = index == 0 + request = (index, method, block.location.block_type, params) + assert response.status_code == 200, (request, response.content) + # The permission library's policy version row, stored on the first + # lookup, is the only write and the only signal sender allowed. + assert effects["sql_writes"] == (FIRST_LOOKUP_WRITES if first else []), request + assert effects["saves"] == (["PolicyCacheControl"] if first else []), request + assert signals.unexpected() == ([("send", pre_save), ("send", post_save)] if first else []), request + assert effects["mongo_writes"] == 0, request + assert effects["update_item"] == [], request + assert effects["xblock_updated"] == [], request + assert effects["published"] == [], request + assert imported == [] + assert not CourseOverview.objects.filter(id=source.id).exists() + return client.get(container_url(unit.location), full).json() + + def test_reads_have_no_side_effects(self): + self._assert_reads_have_no_side_effects() + + @override_settings(ENABLE_SPECIAL_EXAMS=True) + def test_reads_of_a_course_with_prerequisites_and_special_exams_have_no_side_effects(self): + """The unit page looks up the subsection's prerequisites and exam settings.""" + body = self._assert_reads_have_no_side_effects(enable_subsection_gating=True, enable_proctored_exams=True) + subsection, _, course = body["xblock_info"]["ancestor_info"]["ancestors"] + assert {"is_prereq", "prereqs", "prereq", "is_proctored_exam", "supports_onboarding"} <= set(subsection) + assert course["enable_proctored_exams"] is True + + def test_v1_still_builds_the_clipboard_overview(self): + course = build_course(sections=1, subsections=1, units=1) + unit = units_of(course.id)[0] + reader, source = self._reader_with_a_clipboard_from_a_course_without_an_overview(course) + imported = self._record_imports() + with record_effects(self) as effects: + response = _client(reader).get(v1_url(unit.location)) + assert response.status_code == 200, response.content + assert response.json()["user_clipboard"]["source_usage_key"] == str(units_of(source.id)[0].location) + assert ("INSERT", CourseOverview._meta.db_table) in effects["sql_writes"] # pylint: disable=protected-access + assert imported == [str(source.id)] + assert CourseOverview.objects.filter(id=source.id).exists() + + +class ContainerReadSideEffectsLegacyPermissionsTest(_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 ContainerReadSideEffectsAuthzTest( + CourseAuthoringAuthzTestMixin, _ReadIsSideEffectFreeMixin, ModuleStoreTestCase, +): + """Reads 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 + + +# --------------------------------------------------------------------------- +# Query counts +# --------------------------------------------------------------------------- + +def _measure(request): + """SQL statements, 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 [query["sql"] for query in queries.captured_queries], mongo["reads"], mongo["writes"] + + +def _statement_shape(sql): + """A SQL statement with its literal values and savepoint names blanked, so two requests compare.""" + return re.sub(r"'(?:[^']|'')*'|\bs\d+_x\d+\b|\b\d+(?:\.\d+)?\b", "?", sql) + + +def _is_course_existence_lookup(sql): + return sql.lstrip().upper().startswith("SELECT") and CourseOverview._meta.db_table in sql # pylint: disable=protected-access + + +# (sections, subsections per section, units per subsection) +COURSE_SHAPES = { + "one unit": (1, 1, 1), + "ten units in the subsection": (1, 1, 10), + "five sections": (5, 1, 1), +} + + +class _QueryCountMixin: + """ + Request costs of the last unit of each course shape, against the v1 read of + it. The course has an overview record, as every published course does. + """ + + def reader_for(self, course): + raise NotImplementedError + + def costs(self, shape): + """``{read: (sql statements, mongo reads, mongo writes)}`` for v1, view=full and the default view.""" + course = build_course(*shape) + CourseOverview.get_from_id(course.id) + client = _client(self.reader_for(course)) + key = units_of(course.id)[-1].location + return { + "v1": _measure(lambda: client.get(v1_url(key))), + "full": _measure(lambda: client.get(container_url(key), {"view": "full"})), + "default": _measure(lambda: client.get(container_url(key))), + } + + def test_full_view_costs_no_more_than_v1(self): + for shape in COURSE_SHAPES.values(): + costs = self.costs(shape) + # view=full adds the course existence lookup and leaves out v1's clipboard query, so the + # statements are compared rather than their count. + extra = Counter(map(_statement_shape, costs["full"][0])) - Counter(map(_statement_shape, costs["v1"][0])) + assert sum(extra.values()) <= 1, (shape, extra) + assert all(_is_course_existence_lookup(sql) for sql in extra), (shape, extra) + assert costs["full"][1] <= costs["v1"][1], (shape, costs) + assert costs["v1"][2] == costs["full"][2] == costs["default"][2] == 0, (shape, costs) + + def test_the_default_view_costs_the_same_on_every_course_shape(self): + default = {} + for name, shape in COURSE_SHAPES.items(): + statements, reads, writes = self.costs(shape)["default"] + default[name] = (len(statements), reads, writes) + assert len(set(default.values())) == 1, default + + +class ContainerQueryCountTest(_QueryCountMixin, ModuleStoreTestCase): + """Request costs with policy-based authoring permissions off, read by course staff.""" + + def reader_for(self, course): + reader = UserFactory() + CourseStaffRole(course.id).add_users(reader) + return reader + + +class ContainerQueryCountAuthzTest(CourseAuthoringAuthzTestMixin, _QueryCountMixin, ModuleStoreTestCase): + """Request costs with policy-based authoring permissions on, read by a course auditor.""" + + def reader_for(self, course): + reader = UserFactory() + self.add_user_to_role_in_course(reader, COURSE_AUDITOR.external_key, course.id) + return reader + + +# --------------------------------------------------------------------------- +# Addresses, methods and the published schema +# --------------------------------------------------------------------------- + +UNIT_KEY = "block-v1:edX+ToyX+Toy_Course+type@vertical+block@unit" + + +@ddt.ddt +class ContainerRoutingTest(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) + cls.unit = units_of(cls.course.id)[0] + + def test_the_address(self): + assert container_url(UNIT_KEY) == f"/api/authoring/v2/containers/{UNIT_KEY}/" + match = resolve(container_url(UNIT_KEY)) + assert match.func.cls is CourseContainerViewSet + assert routed_actions(match.func) == {"get": "retrieve"} + assert match.kwargs == {"usage_key": UsageKey.from_string(UNIT_KEY)} + + def test_the_v1_address_is_unchanged(self): + assert v1_url(UNIT_KEY) == f"/api/contentstore/v1/container_handler/{UNIT_KEY}" + assert resolve(v1_url(UNIT_KEY)).func.cls is ContainerHandlerView + + def test_the_url_name_is_unique_in_its_namespace(self): + names = [pattern.name for pattern in authoring_urls.urlpatterns] + assert names.count("container_detail") == 1 + assert len(names) == len(set(names)) + + def test_unsupported_accept_is_refused_with_406(self): + # The error library does not catalogue NotAcceptable, so it is typed + # internal. Change the expected type here once it does. + response = _client(UserFactory(is_staff=True)).get( + container_url(self.unit.location), 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(container_url(self.unit.location)) + assert response.status_code == 200 + assert response.json()["name"] == "Course Container" + + # 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-usage-key", "i4x://edX/ToyX/vertical/unit", "edX/ToyX/Toy_Course/vertical/unit") + def test_unparsable_or_deprecated_key_is_404(self, key): + response = _client(UserFactory(is_staff=True)).get(f"/api/authoring/v2/containers/{key}/") + 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/containers/{self.unit.location}") + assert response.status_code == 301 + assert response["Location"] == container_url(self.unit.location) + with pytest.raises(Resolver404): + resolve(f"/api/authoring/v2/containers/{self.unit.location}") + + def test_v1_keeps_its_own_error_body(self): + response = _client().get(v1_url(self.unit.location)) + assert response.status_code == 401 + assert response.json() == {"detail": "Authentication credentials were not provided."} + + +class ContainerSchemaTest(SharedModuleStoreTestCase): + """The container read as published, generated from the real contentstore and authoring routes.""" + + PATH = "/api/authoring/v2/containers/{usage_key}/" + V1 = "/api/contentstore/v1/container_handler/{usage_key_string}" + V1_CHILDREN = "/api/contentstore/v1/container/{usage_key_string}/children" + + @staticmethod + def _ref(response): + return response["content"]["application/json"]["schema"]["$ref"].rsplit("/", 1)[-1] + + @staticmethod + def _nullable(component): + return {name for name, field in component["properties"].items() if field.get("nullable")} + + 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_containers_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) == {"usage_key", "view"} + assert parameters["usage_key"] == { + "in": "path", + "name": "usage_key", + "schema": {"type": "string", "pattern": "^[^/]+$"}, + "description": ( + "Usage key of a section, subsection, unit or other container of a course, such as " + "block-v1:edX+DemoX+2026+type@vertical+block@welcome." + ), + "required": True, + } + assert parameters["view"]["in"] == "query" + assert not parameters["view"].get("required") + assert parameters["view"]["schema"] == {"type": "string", "enum": ["full", "minimal"], "default": "minimal"} + assert set(operation["responses"]) == {"200", "400", "401", "403", "404", "406"} + assert self._ref(operation["responses"]["200"]) == READ_SHAPE + for status in ("400", "401", "403", "404", "406"): + assert self._ref(operation["responses"][status]) == "ErrorResponse", status + + def test_the_read_offers_the_default_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}"}, + ]} + default = components[DEFAULT_SHAPE] + assert set(default["required"]) == set(default["properties"]) == { + "id", "category", "display_name", "display_type", "course_key", "is_unit", "unit_id", "position", + "ancestors", + } + assert self._nullable(default) == {"display_type", "unit_id", "position"} + assert self._nullable(components["CourseContainerXBlock"]) == {"display_type"} + full = components[FULL_SHAPE] + assert set(full["required"]) == set(full["properties"]) + assert len(full["properties"]) == 21 + assert self._nullable(full) == { + "prev_url", "next_url", "draft_preview_link", "published_preview_link", "library_content_picker_url", + } + assert {"user_clipboard", "action"} & set(full["properties"]) == set() + assert self._nullable(components["CourseContainerAncestorChild"]) == {"url"} + assert set(components["CourseContainerComponentTemplate"]["required"]) == { + "display_name", "category", "boilerplate_name", "hinted", "tab", "support_level", + } + assert set(components["CourseContainerComponentGroup"]["required"]) == { + "type", "templates", "display_name", "support_legend", + } + assert components["CourseContainerSupportLegend"]["required"] == ["show_legend"] + + 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, "CourseContainerAncestor", "CourseContainerXBlock", + "CourseContainerAncestorXBlock", "CourseContainerAncestorChild", "CourseContainerComponentGroup", + "CourseContainerComponentTemplate", "CourseContainerSupportLegend", + } + 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_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) == {"Container", "Subsection", "UnitPageData"} + assert_matches_one_read_shape(examples["Container"]["value"], DEFAULT_SHAPE) + subsection = examples["Subsection"]["value"] + assert_matches_one_read_shape(subsection, DEFAULT_SHAPE) + assert (subsection["unit_id"], subsection["position"]) == (None, None) + page = examples["UnitPageData"]["value"] + assert_matches_one_read_shape(page, FULL_SHAPE) + assert set(page["xblock_info"]) < UNIT_XBLOCK_INFO_KEYS + + def test_the_v1_read_is_deprecated_and_the_children_read_is_not(self): + paths = _published_schema()["paths"] + operation = paths[self.V1]["get"] + assert operation["deprecated"] is True + assert operation["operationId"] == "v1_container_handler_retrieve" + assert "deprecated" not in paths[self.V1_CHILDREN]["get"] 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..33dd6ce2a336 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_course_outline.py @@ -0,0 +1,1651 @@ +"""Tests for the v2 course outline API.""" + +import re +from datetime import UTC, datetime +from functools import cache +from importlib import import_module +from unittest.mock import patch +from urllib.parse import parse_qs, urlencode, urlsplit + +import ddt +import jsonschema +import pytest +from django.conf import settings +from django.db import connection +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 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.rest_api.v2.views.tests.helpers import ( + FIRST_LOOKUP_WRITES, + as_json_schema, + build_course, + count_mongo_calls, + record_effects, +) +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 modulestore +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, LibraryFactory + +UNKNOWN_COURSE_KEY = "course-v1:NoSuchOrg+NoSuchCourse+NoSuchRun" +VERSION_GUID = "0123456789abcdef01234567" +COURSE_NOT_FOUND = "The requested course does not exist." +NOT_AN_OUTLINE_CONTAINER = "No container in this course's outline has this key." +VIEWS = ("default", "full") +PRE_1900 = datetime(1800, 1, 1, tzinfo=UTC) + + +def outline_url(course_key): + """Return the address of the outline of ``course_key``.""" + return reverse("authoring_v2:course_outline", kwargs={"course_key": course_key}) + + +def v1_url(course_key): + """Return the deprecated course index address of ``course_key``.""" + return reverse("cms.djangoapps.contentstore:v1:course_index", kwargs={"course_id": str(course_key)}) + + +def _client(user=None): + """An API client that returns server errors as responses, authenticated as ``user`` if given.""" + client = APIClient() + client.raise_request_exception = False + if user is not None: + client.force_authenticate(user=user) + return client + + +def _session_client(user): + """A client signed in to a browser session as ``user``.""" + user.set_password("password") + user.save() + client = APIClient() + assert client.login(username=user.username, password="password") + return client + + +def _params(view): + return {"view": "full"} if view == "full" else {} + + +def 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 +# --------------------------------------------------------------------------- + +@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 +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# Who may call the endpoint +# --------------------------------------------------------------------------- + +def assert_inactive_jwt_reads(user, course_key, view): + """Deactivate ``user``; assert a JWT for it reads both views of v2 and the v1 read.""" + user.is_active = False + user.save() + header = {"HTTP_AUTHORIZATION": f"JWT {create_jwt_for_user(user)}"} + response = APIClient().get(outline_url(course_key), _params(view), **header) + assert response.status_code == 200, response.content + assert_matches_one_read_shape(response.json(), FULL_SHAPE if view == "full" else DEFAULT_SHAPE) + assert APIClient().get(v1_url(course_key), **header).status_code == 200 + user.refresh_from_db() + assert user.is_active is False + + +class _AccessMatrixMixin: + """Checks a caller's status on both views against the expected one and the v1 decision.""" + + def _call(self, user, view, course_key): + return _client(user).get(outline_url(course_key), _params(view)) + + def assert_access(self, user, view, expected, course_key=None): + """Assert the status, the error envelope when refused, and agreement with v1.""" + course_key = course_key or self.course.id + response = self._call(user, view, course_key) + v1_status = _client(user).get(v1_url(course_key)).status_code + assert response.status_code == expected, response.content + if expected != 200: + slug = {401: "authn", 403: "authz", 404: "not-found"}[expected] + assert_error_envelope(response, expected_status=expected, expected_type_slug=slug) + else: + assert_matches_one_read_shape(response.json(), FULL_SHAPE if view == "full" else DEFAULT_SHAPE) + assert v1_status == expected, f"v1 decided {v1_status} for the same caller" + + +@ddt.ddt +class OutlineAccessLegacyPermissionsTest(_AccessMatrixMixin, SharedModuleStoreTestCase): + """Who may read the outline when policy-based authoring permissions are off.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = build_course(sections=1, subsections=1, units=1) + cls.other_course = build_course(sections=1, subsections=1, units=1) + + def _user_with(self, role): + user = UserFactory() + role.add_users(user) + return user + + @ddt.data(*VIEWS) + def test_anonymous_caller_is_refused_with_401(self, view): + self.assert_access(None, view, 401) + + @ddt.data(*VIEWS) + def test_user_without_a_role_is_refused_with_403(self, view): + self.assert_access(UserFactory(), view, 403) + + @ddt.data(*VIEWS) + def test_unknown_course_is_404_before_permission_is_checked(self, view): + self.assert_access(UserFactory(), view, 404, course_key=UNKNOWN_COURSE_KEY) + + @ddt.data(*VIEWS) + def test_anonymous_caller_of_an_unknown_course_is_refused_with_401(self, view): + self.assert_access(None, view, 401, course_key=UNKNOWN_COURSE_KEY) + + @ddt.data(*VIEWS) + def test_limited_staff_is_refused(self, view): + self.assert_access(self._user_with(CourseLimitedStaffRole(self.course.id)), view, 403) + + @ddt.data(*VIEWS) + def test_staff_of_another_course_is_refused(self, view): + self.assert_access(self._user_with(CourseStaffRole(self.other_course.id)), view, 403) + + @ddt.data(*VIEWS) + def test_course_staff_may_read(self, view): + self.assert_access(self._user_with(CourseStaffRole(self.course.id)), view, 200) + + @ddt.data(*VIEWS) + def test_course_instructor_may_read(self, view): + self.assert_access(self._user_with(CourseInstructorRole(self.course.id)), view, 200) + + @ddt.data(*VIEWS) + def test_org_staff_and_org_instructor_may_read(self, view): + self.assert_access(self._user_with(OrgStaffRole(self.course.id.org)), view, 200) + self.assert_access(self._user_with(OrgInstructorRole(self.course.id.org)), view, 200) + + @ddt.data(*VIEWS) + def test_global_staff_and_superuser_may_read(self, view): + self.assert_access(UserFactory(is_staff=True), view, 200) + self.assert_access(UserFactory(is_superuser=True), view, 200) + + @ddt.data(*VIEWS) + def test_an_inactive_session_is_not_authenticated(self, view): + """ + A session whose account is not activated is refused with 401, global + staff included. v1 authenticates such a session and decides access + as for an active account, so it admits inactive global staff and + refuses inactive course staff, whose course role does not count while + inactive. + """ + for user, v1_expected in ( + (UserFactory(is_staff=True), 200), + (self._user_with(CourseStaffRole(self.course.id)), 403), + ): + client = _session_client(user) + assert client.get(outline_url(self.course.id), _params(view)).status_code == 200, "active control" + user.is_active = False + user.save() + response = client.get(outline_url(self.course.id), _params(view)) + assert_error_envelope(response, expected_status=401, expected_type_slug="authn") + assert client.get(v1_url(self.course.id)).status_code == v1_expected + + @ddt.data(*VIEWS) + def test_jwt_caller_may_read(self, view): + user = UserFactory() + CourseStaffRole(self.course.id).add_users(user) + response = APIClient().get( + outline_url(self.course.id), _params(view), HTTP_AUTHORIZATION=f"JWT {create_jwt_for_user(user)}", + ) + assert response.status_code == 200 + + @ddt.data(*VIEWS) + def test_an_inactive_jwt_caller_who_is_global_staff_may_read(self, view): + """A JWT is not checked for an activated account, on v2 as on v1.""" + assert_inactive_jwt_reads(UserFactory(is_staff=True), self.course.id, view) + + @ddt.data(*VIEWS) + def test_bearer_token_is_refused(self, view): + """Bearer tokens are not accepted here, though the v1 read takes them.""" + user = UserFactory(is_staff=True) + token = AccessTokenFactory(user=user, application=ApplicationFactory(user=user)).token + response = APIClient().get(outline_url(self.course.id), _params(view), HTTP_AUTHORIZATION=f"Bearer {token}") + assert_error_envelope(response, expected_status=401, expected_type_slug="authn") + assert APIClient().get(v1_url(self.course.id), HTTP_AUTHORIZATION=f"Bearer {token}").status_code == 200 + + def test_a_subtree_of_another_course_is_refused_without_naming_it(self): + """A caller allowed on one course cannot read another course's blocks through it.""" + user = self._user_with(CourseStaffRole(self.course.id)) + other_section = modulestore().get_course(self.other_course.id).get_children()[0] + response = _client(user).get(outline_url(self.course.id), {"usage_key": str(other_section.location)}) + envelope = assert_error_envelope(response, expected_status=400, expected_type_slug="validation") + assert envelope["errors"] == {"usage_key": [NOT_AN_OUTLINE_CONTAINER]} + body = response.content.decode() + assert str(self.other_course.id.course) not in body + assert other_section.location.block_id not in body + # The same caller is refused the other course outright. + assert _client(user).get(outline_url(self.other_course.id)).status_code == 403 + + +@ddt.ddt +class OutlineAccessAuthzTest(CourseAuthoringAuthzTestMixin, _AccessMatrixMixin, SharedModuleStoreTestCase): + """Who may read the outline when policy-based authoring permissions are on.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = build_course(sections=1, subsections=1, units=1) + cls.other_course = build_course(sections=1, subsections=1, units=1) + + def _user_with(self, role, course_key=None): + user = UserFactory() + self.add_user_to_role_in_course(user, role, course_key or self.course.id) + return user + + @ddt.data(*VIEWS) + def test_anonymous_caller_is_refused_with_401(self, view): + self.assert_access(None, view, 401) + + @ddt.data(*VIEWS) + def test_user_without_a_role_is_refused_with_403(self, view): + self.assert_access(self.unauthorized_user, view, 403) + + @ddt.data(*[(view, role) for view in VIEWS for role in (COURSE_AUDITOR.external_key, COURSE_ADMIN.external_key)]) + @ddt.unpack + def test_a_role_on_another_course_is_refused(self, view, role): + user = self._user_with(role, self.other_course.id) + self.assert_access(user, view, 200, course_key=self.other_course.id) + self.assert_access(user, view, 403) + + @ddt.data(*VIEWS) + def test_unknown_course_is_404_before_permission_is_checked(self, view): + self.assert_access(self.unauthorized_user, view, 404, course_key=UNKNOWN_COURSE_KEY) + + @ddt.data(*[(view, role) for view in VIEWS for role in ( + COURSE_AUDITOR.external_key, COURSE_EDITOR.external_key, COURSE_STAFF.external_key, COURSE_ADMIN.external_key, + )]) + @ddt.unpack + def test_roles_that_may_view_the_course_may_read(self, view, role): + self.assert_access(self._user_with(role), view, 200) + + @ddt.data(*[(view, role) for view in VIEWS for role in ( + COURSE_LIMITED_STAFF.external_key, COURSE_DATA_RESEARCHER.external_key, COURSE_BETA_TESTER.external_key, + )]) + @ddt.unpack + def test_other_roles_are_refused(self, view, role): + self.assert_access(self._user_with(role), view, 403) + + @ddt.data(*VIEWS) + def test_legacy_course_staff_without_a_policy_role_is_refused(self, view): + user = UserFactory() + CourseAccessRole.objects.create(user=user, role="staff", course_id=self.course.id, org=self.course.id.org) + self.assert_access(user, view, 403) + + @ddt.data(*VIEWS) + def test_an_inactive_session_with_a_role_is_not_authenticated(self, view): + """v1 admits it, as the permission policy does not look at whether the account is activated.""" + user = self._user_with(COURSE_AUDITOR.external_key) + client = _session_client(user) + assert client.get(outline_url(self.course.id), _params(view)).status_code == 200, "active control" + user.is_active = False + user.save() + response = client.get(outline_url(self.course.id), _params(view)) + assert_error_envelope(response, expected_status=401, expected_type_slug="authn") + assert client.get(v1_url(self.course.id)).status_code == 200 + + @ddt.data(*VIEWS) + def test_an_inactive_jwt_caller_with_a_role_may_read(self, view): + """A JWT is not checked for an activated account, on v2 as on v1.""" + user = self._user_with(COURSE_AUDITOR.external_key) + assert_inactive_jwt_reads(user, self.course.id, view) + + @ddt.data(*VIEWS) + def test_staff_and_superuser_may_read_without_a_role(self, view): + self.assert_access(self.staff_user, view, 200) + self.assert_access(self.super_user, view, 200) + + +# --------------------------------------------------------------------------- +# The default view +# --------------------------------------------------------------------------- + +@ddt.ddt +class OutlineDefaultViewTest(ModuleStoreTestCase): + """The outline tree, its depth cap and the child addresses.""" + + def setUp(self): + super().setUp() + self.reader = UserFactory() + self.course = build_course(sections=2, subsections=2, units=2) + CourseStaffRole(self.course.id).add_users(self.reader) + self.client = _client(self.reader) + + def _read(self, course_key=None, **params): + response = self.client.get(outline_url(course_key or self.course.id), params) + assert response.status_code == 200, response.content + return response.json() + + def _expected_tree(self, block, levels_left): + """The node the outline shows for ``block``, built from the modulestore.""" + node = { + "id": str(block.location), "category": block.category, "display_name": block.display_name_with_default, + } + if block.category == "vertical": + return node + if levels_left > 0: + node["children"] = [self._expected_tree(child, levels_left - 1) for child in block.get_children()] + elif block.children: + query = urlencode({"usage_key": str(block.location), "depth": 1}) + node["children_url"] = f"http://testserver{outline_url(self.course.id)}?{query}" + else: + node["children"] = [] + return node + + @ddt.data(1, 2, 3) + def test_each_depth_cuts_at_its_level(self, depth): + course = modulestore().get_course(self.course.id) + assert self._read(depth=depth) == self._expected_tree(course, depth) + + def test_the_default_depth_is_sections_and_subsections(self): + body = self._read() + assert body == self._read(depth=2) + assert [node["category"] for node in tree_nodes(body)] == [ + "course", "chapter", "sequential", "sequential", "chapter", "sequential", "sequential", + ] + subsections = [node for node in tree_nodes(body) if node["category"] == "sequential"] + assert all("children" not in node and node["children_url"] for node in subsections) + + def test_minimal_view_is_the_default(self): + assert self._read(view="minimal") == self._read() + + def test_children_url_is_on_exactly_the_cut_nodes_with_children(self): + body = self._read(depth=1) + assert "children_url" not in body + sections = body["children"] + assert [("children" in s, "children_url" in s) for s in sections] == [(False, True), (False, True)] + units = [n for n in tree_nodes(self._read(depth=3)) if n["category"] == "vertical"] + assert len(units) == 8 + assert all(set(unit) == {"id", "category", "display_name"} for unit in units) + + def test_following_children_url_returns_that_blocks_children(self): + for depth in (1, 2): + for node in tree_nodes(self._read(depth=depth)): + if "children_url" not in node: + continue + query = parse_qs(urlsplit(node["children_url"]).query) + assert query == {"usage_key": [node["id"]], "depth": ["1"]} + followed = self.client.get(node["children_url"]) + assert followed.status_code == 200, followed.content + block = modulestore().get_item(UsageKey.from_string(node["id"])) + assert followed.json() == self._expected_tree(block, 1) + + def test_the_tree_follows_the_full_outline_order(self): + """The default tree to unit depth lists the same blocks in the same order as the full view.""" + default = self._read(depth=3) + full = self.client.get(outline_url(self.course.id), {"view": "full"}).json() + assert tree_ids(default) == full_structure_ids(full["course_structure"]) + assert tree_ids(default) == [block_id for block_id, _ in outline_blocks(self.course.id)] + + @ddt.data("chapter", "sequential", "vertical") + def test_usage_key_returns_the_tree_under_that_block(self, category): + block_id = next(block_id for block_id, cat in outline_blocks(self.course.id) if cat == category) + block = modulestore().get_item(UsageKey.from_string(block_id)) + assert self._read(usage_key=block_id) == self._expected_tree(block, 2) + assert self._read(usage_key=block_id, depth=1) == self._expected_tree(block, 1) + + def test_empty_course_and_empty_section(self): + empty = CourseFactory.create() + CourseStaffRole(empty.id).add_users(self.reader) + root = self._read(course_key=empty.id) + assert root == { + "id": str(empty.location), "category": "course", "display_name": empty.display_name, "children": [], + } + assert_matches_one_read_shape(root, DEFAULT_SHAPE) + section = BlockFactory.create(parent=empty, category="chapter", display_name="Empty") + tree = self._read(course_key=empty.id, depth=1) + assert tree["children"] == [ + {"id": str(section.location), "category": "chapter", "display_name": "Empty", "children": []}, + ] + assert_matches_one_read_shape(tree, DEFAULT_SHAPE) + + def test_a_component_placed_directly_in_the_outline_is_a_leaf(self): + subsection = modulestore().get_course(self.course.id).get_children()[0].get_children()[0] + problem = BlockFactory.create(parent_location=subsection.location, category="problem", display_name="Loose") + tree = self._read(usage_key=str(subsection.location), depth=1) + assert tree["children"][-1] == {"id": str(problem.location), "category": "problem", "display_name": "Loose"} + assert tree_ids(self._read(depth=3)) == [block_id for block_id, _ in outline_blocks(self.course.id)] + + def test_bodies_match_the_published_shape(self): + for depth in (1, 2, 3): + assert_matches_one_read_shape(self._read(depth=depth), DEFAULT_SHAPE) + + def test_blank_parameters_are_omitted_parameters(self): + default = self._read() + assert self._read(view="", depth="", usage_key="", show="") == default + full = self.client.get(outline_url(self.course.id), {"view": "full", "show": ""}) + assert full.status_code == 200 + assert full.json()["initial_state"] is None + + def test_unknown_parameters_are_ignored(self): + """format=concise, a v1 parameter, is not one of this endpoint's parameters; the tree is returned.""" + assert self._read(format="concise", foo="bar") == self._read() + + @ddt.data( + ({"view": "bogus"}, ["view"]), + ({"depth": "0"}, ["depth"]), + ({"depth": "4"}, ["depth"]), + ({"depth": "x"}, ["depth"]), + ({"depth": "1.5"}, ["depth"]), + ({"view": "full", "depth": "1"}, ["depth"]), + ({"view": "full", "usage_key": "SECTION"}, ["usage_key"]), + ({"view": "full", "depth": "1", "usage_key": "SECTION"}, ["depth", "usage_key"]), + ({"show": "x"}, ["show"]), + ({"view": "minimal", "show": "x"}, ["show"]), + ({"usage_key": "not-a-key"}, ["usage_key"]), + ) + @ddt.unpack + def test_invalid_parameters_are_refused_with_400(self, params, fields): + section = str(modulestore().get_course(self.course.id).get_children()[0].location) + params = {name: section if value == "SECTION" else value for name, value in params.items()} + response = self.client.get(outline_url(self.course.id), params) + envelope = assert_error_envelope(response, expected_status=400, expected_type_slug="validation") + assert sorted(envelope["errors"]) == fields + + def test_usage_keys_that_are_not_an_outline_block_of_this_course_are_refused(self): + course = modulestore().get_course(self.course.id) + problem = next(b for b in modulestore().get_items(self.course.id) if b.category == "problem") + section = course.get_children()[0] + missing = section.location.replace(block_id="missing") + for key in ( + course.location, # the course itself + problem.location, # a component, not a container + missing, # a section that does not exist + "lb:Org:lib:problem:1", # a library block + "i4x://Org/C/chapter/x", # a deprecated key + f"block-v1:version@{VERSION_GUID}+type@chapter+block@x", # a key naming only a version + f"lib-block-v1:version@{VERSION_GUID}+type@problem+block@x", # a library key naming only a version + ): + response = self.client.get(outline_url(self.course.id), {"usage_key": str(key)}) + envelope = assert_error_envelope(response, expected_status=400, expected_type_slug="validation") + assert envelope["errors"] == {"usage_key": [NOT_AN_OUTLINE_CONTAINER]} + assert VERSION_GUID not in response.content.decode() + + def test_head_answers_without_a_body(self): + for params in ({}, {"view": "full"}): + response = self.client.head(outline_url(self.course.id), params) + assert response.status_code == 200 + assert response.content == b"" + + def test_a_course_missing_from_the_modulestore_is_404(self): + """A course known only to its overview record is reported missing, not a server error.""" + with patch.object(CourseOverview, "course_exists", return_value=True): + for params in ({}, {"view": "full"}): + response = _client(UserFactory(is_staff=True)).get(outline_url(UNKNOWN_COURSE_KEY), params) + envelope = assert_error_envelope(response, expected_status=404, expected_type_slug="not-found") + assert envelope["detail"] == COURSE_NOT_FOUND + + +@ddt.ddt +class OutlineOtherContainersTest(ModuleStoreTestCase): + """ + Outlines holding containers other than sections, subsections and units, + and blocks of the course that are not in its outline. + """ + + def setUp(self): + super().setUp() + self.reader = UserFactory() + self.client = _client(self.reader) + course = CourseFactory.create() + CourseStaffRole(course.id).add_users(self.reader) + + def block(parent, category, name): + return BlockFactory.create(parent_location=parent, category=category, display_name=name).location + + section_a = block(course.location, "chapter", "Section A") + subsection = block(section_a, "sequential", "Subsection A1") + block(block(subsection, "vertical", "Unit A1.1"), "problem", "Problem") + block(block(subsection, "conditional", "Conditional in a subsection"), "html", "Shown A") + block(block(subsection, "library_content", "Library content in a subsection"), "problem", "Picked A") + split = block(subsection, "split_test", "Split test in a subsection") + block(block(split, "vertical", "Group 0"), "problem", "Problem 0") + block(block(split, "vertical", "Group 1"), "html", "Text 1") + block(block(section_a, "split_test", "Split test in a section"), "vertical", "Group") + section_b = block(course.location, "chapter", "Section B") + block(block(section_b, "conditional", "Conditional in a section"), "html", "Shown B") + block(block(section_b, "library_content", "Library content in a section"), "problem", "Picked B") + unit = block(block(section_b, "sequential", "Subsection B1"), "vertical", "Unit B1.1") + self.nested_unit = block(unit, "vertical", "Unit inside a unit") + block(self.nested_unit, "problem", "Nested problem") + self.orphan_section = modulestore().create_item(self.user.id, course.id, "chapter", "orphan").location + self.course_key = course.id + + def _read(self, **params): + response = self.client.get(outline_url(self.course_key), params) + assert response.status_code == 200, response.content + return response.json() + + def _full_structure(self): + return self._read(view="full")["course_structure"] + + def _follow(self, node): + """Follow ``node``'s children_url; assert it returns that block with exactly its children.""" + response = self.client.get(node["children_url"]) + assert response.status_code == 200, (node["category"], response.content) + followed = response.json() + block = modulestore().get_item(UsageKey.from_string(node["id"])) + assert {key: followed[key] for key in ("id", "category", "display_name")} == { + key: node[key] for key in ("id", "category", "display_name") + } + assert [(child["id"], child["category"]) for child in followed["children"]] == [ + (str(child), child.block_type) for child in block.children + ] + return followed["children"] + + def _walk(self, **params): + """Ids of every node reached from a read by following each children_url, recursively.""" + seen = [] + + def visit(node): + for each in tree_nodes(node): + seen.append(each["id"]) + if "children_url" in each: + for child in self._follow(each): + visit(child) + + visit(self._read(**params)) + return seen + + def test_the_fixture_cuts_other_containers_at_every_depth(self): + """Each kind of container is cut with a children_url at some depth, so the walks below follow one.""" + cut = { + node["category"] + for depth in (1, 2, 3) + for node in tree_nodes(self._read(depth=depth)) + if "children_url" in node + } + assert cut == {"chapter", "sequential", "conditional", "library_content", "split_test"} + + @ddt.data(1, 2, 3) + def test_every_children_url_from_the_course_is_followable(self, depth): + seen = self._walk(depth=depth) + assert sorted(seen) == sorted(full_structure_ids(self._full_structure())) + + @ddt.data(1, 2, 3) + def test_every_children_url_from_each_reroot_is_followable(self, depth): + structure = {node["id"]: node for node in structure_nodes(self._full_structure())} + containers = [ + block_id for block_id, category in outline_blocks(self.course_key) + if category != "course" and modulestore().get_item(UsageKey.from_string(block_id)).has_children + ] + assert len(containers) == 15 + for block_id in containers: + seen = self._walk(usage_key=block_id, depth=depth) + assert sorted(seen) == sorted(full_structure_ids(structure[block_id])), block_id + + def test_usage_key_accepts_exactly_the_containers_in_the_outline(self): + """Every block of the course is tried; only containers the outline walk reaches are accepted.""" + outline = {block_id for block_id, _ in outline_blocks(self.course_key)} + everything = modulestore().get_items(self.course_key) + assert str(self.orphan_section) in {str(block.location) for block in everything} + accepted = [] + for block in everything: + response = self.client.get(outline_url(self.course_key), {"usage_key": str(block.location)}) + if response.status_code == 200: + assert response.json()["id"] == str(block.location) + accepted.append(str(block.location)) + else: + envelope = assert_error_envelope(response, expected_status=400, expected_type_slug="validation") + assert envelope["errors"] == { + "usage_key": [NOT_AN_OUTLINE_CONTAINER], + } + expected = sorted( + str(block.location) for block in everything + if str(block.location) in outline and block.has_children and block.category != "course" + ) + assert sorted(accepted) == expected + assert len(expected) == 15 + + def test_blocks_outside_the_outline_are_refused(self): + """A section no block links to, and a unit inside a unit, are not in the outline.""" + for key in (self.orphan_section, self.nested_unit): + response = self.client.get(outline_url(self.course_key), {"usage_key": str(key)}) + envelope = assert_error_envelope(response, expected_status=400, expected_type_slug="validation") + assert envelope["errors"] == {"usage_key": [NOT_AN_OUTLINE_CONTAINER]} + assert key.block_id not in response.content.decode() + assert str(self.orphan_section) not in full_structure_ids(self._full_structure()) + assert str(self.nested_unit) not in full_structure_ids(self._full_structure()) + + +# --------------------------------------------------------------------------- +# The full view against v1 +# --------------------------------------------------------------------------- + +PRE_1900_DATES = "a start date before 1900" + +# A course_structure node at any depth. +_NODE = r"course_structure(\.child_info\.children\[\d+\])*" + +# Differences between the v1 read and view=full: (json path pattern, the +# condition under which it occurs, or None when it always does, the reason). +# The comparison asserts that each difference whose condition holds occurs and +# that nothing else differs. +INTENTIONAL_DIFFERENCES = [ + (r"initial_user_clipboard([.\[].*)?", None, + "the clipboard is its own resource at /api/content-staging/v1/clipboard/"), + (r"course_release_date", PRE_1900_DATES, + "v1 stores the unset start date over a date before 1900 and reports 'Set Date'; view=full reports the date"), + (rf"{_NODE}\.start", PRE_1900_DATES, "view=full reports the stored start date, v1 the unset date it stores"), + (rf"{_NODE}\.release_date", PRE_1900_DATES, "the release date is formatted from the start date"), + (rf"{_NODE}\.released_to_students", PRE_1900_DATES, + "a block dated 1800 has been released; one dated with the unset date has not"), + (rf"{_NODE}\.visibility_state", PRE_1900_DATES, "it follows whether the block has been released"), +] + +_ABSENT = object() + + +def _flatten(obj, prefix=""): + """ + A ``{json_path: value}`` map of every leaf of ``obj``. An empty object or + array is a leaf, so a container that empties or changes kind is seen. + """ + flat = {} + if isinstance(obj, dict) and obj: + for key in sorted(obj): + flat.update(_flatten(obj[key], f"{prefix}.{key}" if prefix else key)) + elif isinstance(obj, list) and obj: + for index, item in enumerate(obj): + flat.update(_flatten(item, f"{prefix}[{index}]")) + else: + flat[prefix] = obj + return flat + + +def _same(a, b): + """Whether two leaves are equal and of the same JSON type: true is not 1, 1.0 is not 1, {} is not [].""" + return type(a) is type(b) and a == b + + +def _differences(legacy, new, conditions=frozenset()): + """ + Compare two bodies under ``conditions``. Return the json paths that + differ, those among them no applicable declared difference explains, and + the applicable declared differences that did not occur. + """ + a, b = _flatten(legacy), _flatten(new) + changed = {key for key in set(a) | set(b) if not _same(a.get(key, _ABSENT), b.get(key, _ABSENT))} + applicable = [pattern for pattern, when, _ in INTENTIONAL_DIFFERENCES if when is None or when in conditions] + unexpected = {key for key in changed if not any(re.fullmatch(pattern, key) for pattern in applicable)} + missing = {pattern for pattern in applicable if not any(re.fullmatch(pattern, key) for key in changed)} + return changed, unexpected, missing + + +def test_the_comparison_sees_types_and_empty_containers(): + assert _differences({"a": {"b": []}}, {"a": {}})[0] == {"a", "a.b"} + assert _differences({"x": True}, {"x": 1})[0] == {"x"} + assert _differences({"x": 1.0}, {"x": 1})[0] == {"x"} + assert _differences({"x": {}}, {"x": []})[0] == {"x"} + assert _differences({"x": None, "y": 1}, {"y": 1})[0] == {"x"} + assert _differences({"x": [1, {"y": False}]}, {"x": [1, {"y": False}]})[0] == set() + + +def test_a_declared_difference_is_allowed_only_when_its_condition_holds(): + legacy = {"course_release_date": "Set Date", "initial_user_clipboard": {}} + new = {"course_release_date": "Jan 01, 1800 at 00:00 UTC"} + _, unexpected, missing = _differences(legacy, new) + assert unexpected == {"course_release_date"} + assert missing == set() + _, unexpected, missing = _differences(legacy, new, {PRE_1900_DATES}) + assert unexpected == set() + assert missing == {pattern for pattern, when, _ in INTENTIONAL_DIFFERENCES if when == PRE_1900_DATES} - { + "course_release_date", + } + + +class _FullViewParityMixin: + """Compares view=full against the v1 read of ``self.course``.""" + + def _pair(self, user, **params): + """ + The v1 read and the view=full read of the course by ``user``, as JSON. + view=full is read first, so nothing the v1 read stores can reach it. + """ + client = _client(user) + new = client.get(outline_url(self.course.id), {"view": "full", **params}) + legacy = client.get(v1_url(self.course.id), params) + assert legacy.status_code == new.status_code == 200, (legacy.content, new.content) + return legacy.json(), new.json() + + def compare(self, user, conditions=frozenset(), **params): + """ + Assert that the two reads differ exactly by the declared differences + that apply under ``conditions``, each of which occurs. Return the v1 + body, the v2 body and the json paths that differ. + """ + legacy, new = self._pair(user, **params) + changed, unexpected, missing = _differences(legacy, new, conditions) + assert unexpected == set(), sorted(unexpected) + assert missing == set(), sorted(missing) + assert set(legacy) - set(new) == {"initial_user_clipboard"} + assert set(new) <= set(legacy) + assert_matches_one_read_shape(new, FULL_SHAPE) + return legacy, new, changed + + def assert_parity(self, user, **params): + """Assert parity with no condition-dependent difference and return the v2 body.""" + return self.compare(user, **params)[1] + + +@ddt.ddt +class OutlineFullViewParityTest(_FullViewParityMixin, ModuleStoreTestCase): + """view=full returns what v1 returns for the same caller, except the declared differences.""" + + def setUp(self): + super().setUp() + self.course = build_course(sections=2, subsections=1, units=2) + + def _course_staff(self): + user = UserFactory() + CourseStaffRole(self.course.id).add_users(user) + return user + + @ddt.data(True, False) + def test_plain_course(self, relative_dates): + with override_waffle_flag(CUSTOM_RELATIVE_DATES, active=relative_dates): + body = self.assert_parity(self._course_staff()) + assert body["is_custom_relative_dates_active"] is relative_dates + assert body["created_on"] is None + assert body["rerun_notification_id"] is None + assert body["notification_dismiss_url"] is None + assert body["initial_state"] is None + assert body["reindex_link"] is None + + def test_course_with_a_start_date(self): + self.course = build_course(sections=1, subsections=1, units=1, start=datetime(2021, 3, 4, tzinfo=UTC)) + body = self.assert_parity(self._course_staff()) + assert body["course_release_date"] == "Mar 04, 2021 at 00:00 UTC" + + def test_course_without_a_start_date(self): + self.course = build_course(sections=1, subsections=1, units=1, start=DEFAULT_START_DATE) + assert self.assert_parity(self._course_staff())["course_release_date"] == "Set Date" + + def test_dates_before_1900_are_reported_as_stored(self): + """ + v1 replaces each start date before 1900 with the unset date and reports + that; view=full reports the stored date. + """ + self.course = build_course(sections=1, subsections=1, units=1, start=PRE_1900) + reader = self._course_staff() + legacy, new, changed = self.compare(reader, conditions={PRE_1900_DATES}) + assert new["course_release_date"] == "Jan 01, 1800 at 00:00 UTC" + assert structure_nodes(new["course_structure"])[0]["start"] == "1800-01-01T00:00:00+00:00" + assert legacy["course_release_date"] == "Set Date" + assert {key.rsplit(".", 1)[-1] for key in changed if not key.startswith("initial_user_clipboard")} == { + "course_release_date", "start", "release_date", "released_to_students", "visibility_state", + } + + def test_course_with_an_overview_and_a_rerun_notification(self): + CourseOverview.load_from_module_store(self.course.id) + CourseRerunState.objects.initiated(self.course.id, self.course.id, self.user, "Rerun") + state = CourseRerunState.objects.find_first(course_key=self.course.id) + body = self.assert_parity(self._course_staff()) + created = CourseOverview.objects.get(id=self.course.id).created + assert body["created_on"] == created.isoformat().replace("+00:00", "Z") + assert body["rerun_notification_id"] == state.id + assert body["notification_dismiss_url"] == f"/course_notifications/{self.course.id}/{state.id}" + + @ddt.data("section", "unit", "unknown") + def test_show(self, which): + course = modulestore().get_course(self.course.id) + section = course.get_children()[0] + unit = section.get_children()[0].get_children()[0] + locator = { + "section": str(section.location), + "unit": str(unit.location), + "unknown": str(section.location.replace(block_id="missing")), + }[which] + body = self.assert_parity(self._course_staff(), show=locator) + if which == "unknown": + assert body["initial_state"] is None + elif which == "unit": + assert body["initial_state"] == {"locator_to_show": locator, "expanded_locators": [locator]} + else: + expected = [b for b, _ in outline_blocks(self.course.id)] + start = expected.index(locator) + assert body["initial_state"] == { + "locator_to_show": locator, "expanded_locators": expected[start:start + 4], + } + + def test_empty_show_is_no_show(self): + assert self.assert_parity(self._course_staff(), show="")["initial_state"] is None + + @override_settings(ENABLE_COURSEWARE_INDEX=True) + @override_waffle_flag(core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, active=False) + def test_reindex_link_follows_the_reindex_permission(self): + """Global staff may reindex; course staff may not, while policy-based permissions are off.""" + reindex = f"/course/{self.course.id}/search_reindex" + assert self.assert_parity(UserFactory(is_staff=True))["reindex_link"] == reindex + assert self.assert_parity(self._course_staff())["reindex_link"] is None + with override_settings(ENABLE_COURSEWARE_INDEX=False): + assert self.assert_parity(UserFactory(is_staff=True))["reindex_link"] is None + + @override_settings(LMS_ROOT_URL=None) + def test_lms_link_is_null_without_an_lms_address(self): + assert self.assert_parity(self._course_staff())["lms_link"] is None + + def test_every_node_carries_the_course_tag_counts(self): + taxonomy = tagging_api.create_taxonomy("Topics", allow_free_text=True) + tagging_api.set_taxonomy_orgs(taxonomy, all_orgs=True) + unit = modulestore().get_course(self.course.id).get_children()[0].get_children()[0].get_children()[0] + tagging_api.tag_object(str(unit.location), taxonomy, ["Algebra", "Geometry"]) + body = self.assert_parity(self._course_staff()) + nodes = structure_nodes(body["course_structure"]) + assert len(nodes) == len(outline_blocks(self.course.id)) == 9 + assert {str(node["tag_counts_by_block"]) for node in nodes} == {str({str(unit.location): 2})} + + def test_deprecated_blocks_are_reported(self): + course = modulestore().get_course(self.course.id) + course.advanced_modules = ["poll", "survey"] + modulestore().update_item(course, self.user.id) + unit = course.get_children()[0].get_children()[0].get_children()[0] + BlockFactory.create(parent=unit, category="poll", display_name="Old poll") + with patch("cms.djangoapps.contentstore.rest_api.v2.course_outline_service.deprecated_xblocks") as v2_types, \ + patch("cms.djangoapps.contentstore.utils.deprecated_xblocks") as v1_types: + v2_types.return_value = v1_types.return_value = [type("Deprecated", (), {"name": "poll"})] + body = self.assert_parity(self._course_staff()) + assert body["deprecated_blocks_info"]["deprecated_enabled_block_types"] == ["poll"] + assert [name for _, name in body["deprecated_blocks_info"]["blocks"]] == ["Old poll"] + + @ddt.data("unavailable_provider", "missing_escalation_email") + def test_proctoring_errors_are_reported_as_v1_reports_them(self, problem): + """ + Each kind of stored proctoring problem is reported with the same bytes the + shared proctoring error serializers produce for v1. + """ + course = modulestore().get_course(self.course.id) + course.enable_proctored_exams = True + backends = settings.PROCTORING_BACKENDS + if problem == "unavailable_provider": + available = ["lti_external"] + message = ( + f"The proctoring provider configured for this course, '{course.proctoring_provider}', is not valid." + ) + else: + backends = {"DEFAULT": "proctortrack", "proctortrack": {"requires_escalation_email": True}} + course.proctoring_provider = "proctortrack" + available = ["proctortrack"] + message = "Provider 'proctortrack' requires an exam escalation contact." + modulestore().update_item(course, self.user.id) + reader = self._course_staff() + with override_settings(PROCTORING_BACKENDS=backends), patch( + "cms.djangoapps.models.settings.course_metadata.get_available_providers", return_value=available, + ): + body = self.assert_parity(reader) + course = modulestore().get_course(self.course.id) + stored = CourseMetadata.validate_proctoring_settings(course, CourseMetadata.fetch(course), reader) + assert [(error["key"], error["message"]) for error in body["proctoring_errors"]] == [ + ("proctoring_provider", message), + ] + assert body["proctoring_errors"][0]["model"]["value"] == course.proctoring_provider + shared = JSONRenderer().render(ProctoringErrorListSerializer(stored, many=True).data) + local = JSONRenderer().render(CourseOutlineProctoringErrorSerializer(stored, many=True).data) + assert local == shared + assert JSONRenderer().render(body["proctoring_errors"]) == shared + + def test_empty_course(self): + self.course = CourseFactory.create() + body = self.assert_parity(self._course_staff()) + assert body["course_structure"]["id"] == str(self.course.location) + assert body["course_structure"]["child_info"] == { + "category": "chapter", "display_name": "Section", "children": [], + } + + +@ddt.ddt +@override_settings(ENABLE_COURSEWARE_INDEX=True) +class OutlineFullViewReindexLinkAuthzTest(CourseAuthoringAuthzTestMixin, _FullViewParityMixin, ModuleStoreTestCase): + """ + With policy-based authoring permissions on, the reindex link is shown to + callers allowed to publish the course's content, as v1 shows it. + """ + + def setUp(self): + super().setUp() + self.course = build_course(sections=1, subsections=1, units=1) + + def _user_with(self, role): + user = UserFactory() + self.add_user_to_role_in_course(user, role, self.course.id) + return user + + @ddt.data( + (COURSE_AUDITOR.external_key, False), + (COURSE_EDITOR.external_key, False), + (COURSE_STAFF.external_key, True), + (COURSE_ADMIN.external_key, True), + ) + @ddt.unpack + def test_the_link_follows_the_publish_permission(self, role, may_publish): + """Every one of these roles may view the course; only those that may publish get the link.""" + reindex = f"/course/{self.course.id}/search_reindex" + body = self.assert_parity(self._user_with(role)) + assert body["reindex_link"] == (reindex if may_publish else None) + + def test_global_staff_and_superuser_get_the_link_without_a_role(self): + reindex = f"/course/{self.course.id}/search_reindex" + assert self.assert_parity(self.staff_user)["reindex_link"] == reindex + assert self.assert_parity(self.super_user)["reindex_link"] == reindex + + def test_a_role_that_may_publish_another_course_does_not_get_the_link(self): + other = build_course(sections=1, subsections=1, units=1) + user = self._user_with(COURSE_AUDITOR.external_key) + self.add_user_to_role_in_course(user, COURSE_ADMIN.external_key, other.id) + assert self.assert_parity(user)["reindex_link"] is None + + @override_settings(ENABLE_COURSEWARE_INDEX=False) + def test_no_link_while_course_indexing_is_off(self): + assert self.assert_parity(self._user_with(COURSE_ADMIN.external_key))["reindex_link"] is None + + +# The keys of each full-view course_structure node, by block type, on a course +# with every configuration-dependent key off except tagging. The nodes come +# from the shared create_xblock_info builder; v2 publishes them as documented +# in CourseOutlineFullSerializer.course_structure. +_EVERY_NODE_KEYS = frozenset({ + "actions", "ancestor_has_staff_lock", "category", "course_authoring_url", "course_graders", + "course_tags_count", "display_name", "due", "due_date", "edited_on", "edited_on_raw", "embed_lms_url", + "enable_copy_paste_units", "enable_hide_from_toc_ui", "explanatory_message", "format", "graded", + "group_access", "has_changes", "has_children", "has_explicit_staff_lock", "has_partition_group_components", + "hide_from_toc", "hide_from_toc_message", "id", "is_tagging_feature_disabled", "lms_url", "published", + "published_on", "relative_weeks_due", "release_date", "released_to_students", "show_correctness", + "staff_only_message", "start", "studio_url", "tag_counts_by_block", "taxonomy_tags_widget_url", + "upstream_info", "user_partition_info", "user_partitions", "visibility_state", "xblock_type", +}) +_HIGHLIGHTS_KEYS = frozenset({"highlights_doc_url", "highlights_enabled", "highlights_preview_only"}) +FULL_VIEW_NODE_KEYS = { + "course": _EVERY_NODE_KEYS | _HIGHLIGHTS_KEYS | { + "child_info", "end", "highlights_enabled_for_messaging", "unit_level_discussions", + }, + "chapter": _EVERY_NODE_KEYS | _HIGHLIGHTS_KEYS | {"child_info", "highlights"}, + "sequential": _EVERY_NODE_KEYS | {"child_info", "hide_after_due"}, + "vertical": _EVERY_NODE_KEYS | {"discussion_enabled"}, + "split_test": _EVERY_NODE_KEYS | {"child_info"}, + "vertical outside a subsection": _EVERY_NODE_KEYS, + "problem": _EVERY_NODE_KEYS, +} +_TAGGING_KEYS = frozenset({ + "course_authoring_url", "course_tags_count", "tag_counts_by_block", "taxonomy_tags_widget_url", +}) +FULL_VIEW_CHILD_INFO_KEYS = { + "course": {"category", "display_name", "children"}, + "chapter": {"category", "display_name", "children"}, + "sequential": {"category", "display_name", "children"}, + "split_test": {"children"}, +} + + +@ddt.ddt +class OutlineFullViewNodeKeysTest(ModuleStoreTestCase): + """The keys each view=full course_structure node carries are the published contract.""" + + @ddt.data(False, True) + def test_every_node_carries_exactly_the_documented_keys(self, tagging_disabled): + course = build_course(sections=1, subsections=1, units=1) + subsection = modulestore().get_course(course.id).get_children()[0].get_children()[0] + BlockFactory.create(parent_location=subsection.location, category="problem", display_name="Loose") + split = BlockFactory.create(parent_location=subsection.location, category="split_test", display_name="Split") + BlockFactory.create(parent_location=split.location, category="vertical", display_name="Group") + reader = UserFactory() + CourseStaffRole(course.id).add_users(reader) + with override_waffle_flag(DISABLE_TAGGING_FEATURE, active=tagging_disabled): + body = _client(reader).get(outline_url(course.id), {"view": "full"}).json() + + def kinds(node, parent_category=None): + """``(kind, node)`` of every node; a vertical is a unit only directly in a subsection.""" + kind = node["category"] + if kind == "vertical" and parent_category != "sequential": + kind = "vertical outside a subsection" + yield kind, node + for child in node.get("child_info", {}).get("children", []): + yield from kinds(child, node["category"]) + + nodes = list(kinds(body["course_structure"])) + assert sorted({kind for kind, _ in nodes}) == sorted(FULL_VIEW_NODE_KEYS) + for category, node in nodes: + keys = set(node) + expected = FULL_VIEW_NODE_KEYS[category] - (_TAGGING_KEYS if tagging_disabled else set()) + assert keys == expected, ( + f"The keys of a {category} node in view=full course_structure changed: added " + f"{sorted(keys - expected)}, removed {sorted(expected - keys)}. These nodes are built by the " + "shared create_xblock_info and are part of the published v2 outline contract. A removed or " + "renamed key breaks v2 callers and needs a new version; an added key must be documented in " + "CourseOutlineFullSerializer.course_structure and listed here." + ) + assert set(node.get("child_info", {})) == FULL_VIEW_CHILD_INFO_KEYS.get(category, set()), category + + def test_the_documented_keys_are_named_in_the_published_description(self): + description = _published_schema()["components"]["schemas"][FULL_SHAPE]["properties"]["course_structure"][ + "description" + ] + named = set(re.findall(r"[a-z]+(?:_[a-z]+)+|\b[a-z]{2,}\b", description)) + missing = set().union(*FULL_VIEW_NODE_KEYS.values(), *FULL_VIEW_CHILD_INFO_KEYS.values()) - named + assert missing == set() + + +# --------------------------------------------------------------------------- +# Reads write nothing +# --------------------------------------------------------------------------- + +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..32ac170c6415 100644 --- a/cms/djangoapps/contentstore/views/permissions.py +++ b/cms/djangoapps/contentstore/views/permissions.py @@ -2,14 +2,19 @@ Custom permissions for the content store views. """ -from openedx_authz.constants.permissions import COURSES_VIEW_PAGES_AND_RESOURCES +from opaque_keys.edx.locator import CourseLocator +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 +63,46 @@ 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 or, on a route without one, + the course of the parsed ``usage_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. So is a usage + key whose context is not a course run, such as a content library. A key of + a custom course (CCX) does name a course run, so the permission check + decides it: Studio read access is never granted on a custom course, and + the permission policy grants only roles assigned on that custom course. + 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: + usage_key = view.kwargs.get("usage_key") + if usage_key is None: + return False + course_key = usage_key.context_key + # A library's context key is not a course key, and the existence + # lookup below fails on it. + if not isinstance(course_key, CourseLocator): + raise NotFound(COURSE_NOT_FOUND_MESSAGE) + # 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..8e16c60be024 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/contentstore/v1/course_index/{course_id}", # → /api/authoring/v2/courses/{course_key}/outline/ + "/api/contentstore/v1/container_handler/{usage_key_string}", # → /api/authoring/v2/containers/{usage_key}/ ) # 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..3487fac2b1cc 100644 --- a/cms/lib/tests/test_spectacular.py +++ b/cms/lib/tests/test_spectacular.py @@ -76,12 +76,34 @@ def test_both_spellings_of_each_superseded_path_are_listed(self): "/api/contentstore/v0/youtube_transcripts/{course_id}/check", "/api/contentstore/v0/youtube_transcripts/{course_id}/uploa", "/api/contentstore/v0/youtube_transcripts/{course_id}/upload", + "/api/contentstore/v1/course_index/{course_id}", + "/api/contentstore/v1/container_handler/{usage_key_string}", } 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/", + "/api/authoring/v2/containers/{usage_key}/", + ): + result = self._run({"paths": {conforming: {"get": {"operationId": "x"}}}}) + assert result["paths"][conforming]["get"] == {"operationId": "x"} + + def test_the_course_index_read_is_marked_and_its_sibling_container_route_is_not(self): + index = "/api/contentstore/v1/course_index/{course_id}" + container = "/api/contentstore/v1/container/{usage_key_string}/children" + result = self._run({"paths": {index: {"get": {}}, container: {"get": {}}}}) + assert result["paths"][index]["get"] == {"deprecated": True} + assert result["paths"][container]["get"] == {} + + def test_the_container_read_is_marked_and_its_children_routes_are_not(self): + handler = "/api/contentstore/v1/container_handler/{usage_key_string}" + children = "/api/contentstore/v1/container/{usage_key_string}/children" + redirect = "/api/contentstore/v1/container/vertical/{usage_key_string}/children" + result = self._run({"paths": {handler: {"get": {}}, children: {"get": {}}, redirect: {"get": {}}}}) + assert result["paths"][handler]["get"] == {"deprecated": True} + assert result["paths"][children]["get"] == {} + assert result["paths"][redirect]["get"] == {} def test_an_unrelated_contentstore_path_is_left_unmarked(self): other = "/api/contentstore/v0/advanced_settings/{course_id}"