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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions cms/djangoapps/contentstore/rest_api/pagination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Pagination shared by the contentstore REST API views."""

from edx_rest_framework_extensions.paginators import DefaultPagination

ENVELOPE_KEYS = ("count", "num_pages", "current_page", "start", "next", "previous", "results")


class DocumentedDefaultPagination(DefaultPagination):
"""
The standard page envelope, with a response schema describing all seven of its keys.

``DefaultPagination`` returns ``count``, ``num_pages``, ``current_page``,
``start``, ``next``, ``previous`` and ``results``, but inherits a response
schema that documents only four of them, so generated clients would drop
``num_pages``, ``current_page`` and ``start``. Pages, page sizes and the
body are exactly ``DefaultPagination``'s; only the published schema
differs. Every member carries an example, so the schema generator can wrap
a view's example item in a whole page. Once ``DefaultPagination``
documents all seven keys, use it directly instead.
"""

def get_paginated_response_schema(self, schema):
"""Describe a page whose ``results`` are items described by ``schema``."""
return {
"type": "object",
"required": list(ENVELOPE_KEYS),
"properties": {
"count": {"type": "integer", "description": "Total number of items.", "example": 25},
"num_pages": {"type": "integer", "description": "Total number of pages.", "example": 3},
"current_page": {
"type": "integer",
"description": "Number of this page, starting at 1.",
"example": 2,
},
"start": {
"type": "integer",
"description": "Zero-based index of the first item on this page.",
"example": 10,
},
"next": {
"type": "string",
"format": "uri",
"nullable": True,
"description": "Address of the next page, or null on the last page.",
"example": "https://example.com/items/?page=3",
},
"previous": {
"type": "string",
"format": "uri",
"nullable": True,
"description": "Address of the previous page, or null on the first page.",
"example": "https://example.com/items/?page=1",
},
"results": schema,
},
}
12 changes: 12 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v0/views/advanced_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
class AdvancedCourseSettingsView(DeveloperErrorViewMixin, APIView):
"""
View for getting and setting the advanced settings for a course.

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

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

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

**Example Request**

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

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

**Example Request**

PATCH /api/contentstore/v0/advanced_settings/{course_id} {
Expand Down
21 changes: 21 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v0/views/tabs.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
class CourseTabListView(DeveloperErrorViewMixin, APIView):
"""
API view to list course tabs.

Deprecated: use ``GET /api/authoring/v1/custom_pages/?course_key={course_key}`` instead.
See https://github.com/openedx/openedx-platform/issues/39128.
"""

@extend_schema(
Expand All @@ -43,6 +46,10 @@ def get(self, request: Request, course_id: str) -> Response:
"""
Get a list of all the tabs in a course including hidden tabs.

Deprecated: use ``GET /api/authoring/v1/custom_pages/?course_key={course_key}``, which
returns the same custom pages, paginated (``page``, ``page_size`` up to 100).
See https://github.com/openedx/openedx-platform/issues/39128.

**Example Request**

GET /api/contentstore/v0/tabs/{course_id}
Expand Down Expand Up @@ -110,6 +117,9 @@ def get(self, request: Request, course_id: str) -> Response:
class CourseTabSettingsView(DeveloperErrorViewMixin, APIView):
"""
API view for course tabs settings.

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

def handle_exception(self, exc):
Expand Down Expand Up @@ -139,6 +149,10 @@ def post(self, request: Request, course_id: str) -> Response:
"""
Change visibility of tabs in a course.

Deprecated: use ``PATCH /api/authoring/v1/courses/{course_key}/tabs/{tab_id}/`` with
the same body, which names the tab by ``tab_id`` in the path and returns the tab.
See https://github.com/openedx/openedx-platform/issues/39128.

**Example Requests**

You can provide either a tab_id or a tab_location.
Expand Down Expand Up @@ -191,6 +205,9 @@ def post(self, request: Request, course_id: str) -> Response:
class CourseTabReorderView(DeveloperErrorViewMixin, APIView):
"""
API view for reordering course tabs.

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

def handle_exception(self, exc: Exception) -> Response:
Expand Down Expand Up @@ -218,6 +235,10 @@ def post(self, request: Request, course_id: str) -> Response:
"""
Reorder tabs in a course.

Deprecated: use ``PUT /api/authoring/v1/courses/{course_key}/custom_page_order/`` with
``{"custom_pages": [<usage key>, ...]}``, the usage keys this endpoint takes as
``tab_locator``. See https://github.com/openedx/openedx-platform/issues/39128.

**Example Requests**

Move course tabs:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Reads and updates of a course's advanced settings."""

import logging

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

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

log = logging.getLogger(__name__)

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


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


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


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

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


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

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


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

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

app_name = "authoring_v1"

Expand Down Expand Up @@ -39,4 +45,25 @@
),
name="xblock_detail",
),
path(
"courses/<course_key:course_key>/advanced_settings/",
CourseAdvancedSettingsViewSet.as_view({"get": "retrieve", "patch": "partial_update"}),
name="course_advanced_settings",
),
# The course is named by the required course_key query parameter.
path(
"custom_pages/",
CourseCustomPagesViewSet.as_view({"get": "list"}),
name="custom_page_list",
),
path(
"courses/<course_key:course_key>/custom_page_order/",
CourseCustomPageOrderViewSet.as_view({"put": "update"}),
name="course_custom_page_order",
),
path(
"courses/<course_key:course_key>/tabs/<str:tab_id>/",
CourseTabsViewSet.as_view({"patch": "partial_update"}),
name="course_tab_detail",
),
]
110 changes: 110 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v1/custom_pages_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Reads and changes of a course's custom pages and tabs."""

import logging

from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.settings import api_settings

from cms.djangoapps.contentstore.views.permissions import COURSE_NOT_FOUND_MESSAGE
from cms.djangoapps.contentstore.views.tabs import edit_tab_handler, get_course_tabs, reorder_tabs_handler
from xmodule.modulestore.django import modulestore
from xmodule.tabs import CourseTabList, StaticTab

log = logging.getLogger(__name__)

# Reported under custom_pages when an item names no custom page of the course.
UNKNOWN_CUSTOM_PAGE_MESSAGE = "Each item must be the usage key of a custom page of this course."
# Reported under custom_pages when the same custom page is named twice.
REPEATED_CUSTOM_PAGE_MESSAGE = "Each custom page may be listed only once."
# Reported when the course's tabs could not be saved in the new order.
ORDER_NOT_SAVED_MESSAGE = "The custom pages could not be reordered. Their order is unchanged."
# Reported when the course has no tab with the requested identifier.
TAB_NOT_FOUND_MESSAGE = "The requested tab does not exist."
# Reported under is_hidden when the tab may not be hidden or shown.
TAB_NOT_HIDEABLE_MESSAGE = "This tab cannot be hidden or shown."


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


def _custom_page_slug(course_key, usage_key):
"""
Return the ``url_slug`` a custom page of ``course_key`` named by ``usage_key`` would have, or None.

The key may carry a branch or version of the course. A key of another
course or library, or of a block that is not a custom page, gives None.
"""
if not isinstance(usage_key, BlockUsageLocator) or usage_key.block_type != "static_tab":
return None
context = usage_key.course_key
if not isinstance(context, CourseLocator):
return None
if (context.org, context.course, context.run) != (course_key.org, course_key.course, course_key.run):
return None
return usage_key.block_id


def list_custom_pages(course_key, user):
"""
Return the course's custom pages in the order of the course's tabs.

Each page carries its usage key as ``locator``. Built-in tabs are never
returned, whether or not hidden.
"""
course_block = _course_block(course_key)
return [tab for tab in get_course_tabs(course_block, user) if isinstance(tab, StaticTab)]


def reorder_custom_pages(course_key, usage_keys, user):
"""
Put the course's custom pages in the order of ``usage_keys``, as ``user``.

Custom pages not named follow the named ones in their current order. As
when Studio reorders pages, the course's tabs are then sorted by kind, so
built-in tabs take their usual places around the custom pages. Raises
``ValidationError`` when a usage key names no custom page of the course or
names one twice, and when the reordered tabs could not be saved; nothing
is stored then.
"""
course_block = _course_block(course_key)
pages = {tab.url_slug: tab for tab in course_block.tabs if isinstance(tab, StaticTab)}
named = []
for usage_key in usage_keys:
page = pages.get(_custom_page_slug(course_key, usage_key))
if page is None:
raise ValidationError({"custom_pages": [UNKNOWN_CUSTOM_PAGE_MESSAGE]})
if any(page is other for other in named):
raise ValidationError({"custom_pages": [REPEATED_CUSTOM_PAGE_MESSAGE]})
named.append(page)
rest = [page for page in pages.values() if all(page is not other for other in named)]
try:
reorder_tabs_handler(course_block, [{"tab_id": page.tab_id} for page in named + rest], user)
except ValidationError as error:
log.warning("The custom pages of course %s were not reordered: %s", course_key, error.detail)
raise ValidationError({api_settings.NON_FIELD_ERRORS_KEY: [ORDER_NOT_SAVED_MESSAGE]}) from error


def update_tab(course_key, tab_id, changes, user):
"""
Apply ``changes`` (``{"is_hidden": bool}`` or nothing) to the course tab ``tab_id``, as ``user``.

Returns the tab as stored. Raises ``NotFound`` when the course has no such
tab, and ``ValidationError`` on ``is_hidden`` when the tab may not be
hidden or shown.
"""
course_block = _course_block(course_key)
tab = CourseTabList.get_tab_by_id(course_block.tabs, tab_id)
if tab is None:
raise NotFound(TAB_NOT_FOUND_MESSAGE)
if "is_hidden" not in changes:
return tab
if not tab.is_hideable:
raise ValidationError({"is_hidden": [TAB_NOT_HIDEABLE_MESSAGE]})
edit_tab_handler(course_block, {"tab_id_locator": {"tab_id": tab_id}, "is_hidden": changes["is_hidden"]}, user)
return tab
Loading
Loading