Skip to content
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
29 changes: 29 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
class LinkCheckView(DeveloperErrorViewMixin, APIView):
"""
View for queueing a celery task to scan a course for broken links.

Deprecated: use ``POST /api/authoring/v1/courses/{course_key}/link_check/`` instead.
See https://github.com/openedx/openedx-platform/issues/39128.
"""
@extend_schema(
parameters=[
Expand All @@ -50,6 +53,10 @@ def post(self, request: Request, course_id: str):
"""
Queue celery task to scan a course for broken links.

Deprecated: use ``POST /api/authoring/v1/courses/{course_key}/link_check/``, which answers
202 with ``{"status": "Pending"}``.
See https://github.com/openedx/openedx-platform/issues/39128.

**Example Request**
POST /api/contentstore/v0/link_check/{course_id}

Expand Down Expand Up @@ -77,6 +84,9 @@ def post(self, request: Request, course_id: str):
class LinkCheckStatusView(DeveloperErrorViewMixin, APIView):
"""
View for checking the status of the celery task and returning the results.

Deprecated: use ``GET /api/authoring/v1/courses/{course_key}/link_check/`` instead.
See https://github.com/openedx/openedx-platform/issues/39128.
"""
@extend_schema(
parameters=[
Expand All @@ -103,6 +113,11 @@ def get(self, request: Request, course_id: str):
This function adds a status for when status from UserTaskStatus is None:
'Uninitiated'

Deprecated: use ``GET /api/authoring/v1/courses/{course_key}/link_check/``. Its default
view returns ``status``, ``created_at`` and ``error``; ``view=full`` adds ``output``, the
report this endpoint returns, with snake_case member names.
See https://github.com/openedx/openedx-platform/issues/39128.

**Example Request**

GET /api/contentstore/v0/link_check_status/{course_id}
Expand Down Expand Up @@ -233,6 +248,9 @@ def get(self, request: Request, course_id: str):
class RerunLinkUpdateView(DeveloperErrorViewMixin, APIView):
"""
View for queueing a celery task to update course links to the latest re-run.

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

@extend_schema(
Expand All @@ -255,6 +273,10 @@ def post(self, request: Request, course_id: str):
"""
Queue celery task to update course links to the latest re-run.

Deprecated: use ``POST /api/authoring/v1/courses/{course_key}/previous_run_link_update/``,
which takes this body and answers 202 with ``{"status": "Pending"}``.
See https://github.com/openedx/openedx-platform/issues/39128.

**Example Request - Update All Links**
POST /api/contentstore/v0/rerun_link_update/{course_id}
```json
Expand Down Expand Up @@ -341,6 +363,9 @@ def post(self, request: Request, course_id: str):
class RerunLinkUpdateStatusView(DeveloperErrorViewMixin, APIView):
"""
View for checking the status of the course link update task and returning the results.

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

@extend_schema(
Expand All @@ -367,6 +392,10 @@ def get(self, request: Request, course_id: str):
Possible statuses:
'pending', 'in_progress', 'completed', 'failed', 'uninitiated'

Deprecated: use ``GET /api/authoring/v1/courses/{course_key}/previous_run_link_update/``.
Its default view returns ``status``; ``view=full`` adds ``results``.
See https://github.com/openedx/openedx-platform/issues/39128.

**Example Request**

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

import logging

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

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

log = logging.getLogger(__name__)

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


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


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


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

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


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

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


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

The settings validator reports one entry per refused setting, carrying the
setting's name and its own explanation, which can be the text of a parsing
exception. The explanation is logged with the course and setting; the
caller gets ``INVALID_SETTING_MESSAGE`` under the setting's name. Any
failure not tied to a setting is logged and reported with
``SETTINGS_NOT_SAVED_MESSAGE``.
"""
detail = error.detail if isinstance(error.detail, list) else [error.detail]
by_setting = {}
unattributed = []
for entry in detail:
name = entry.get("key") if isinstance(entry, dict) else None
if name:
log.warning(
"Advanced setting %s of course %s was refused: %s", name, course_key, entry.get("message", ""),
)
by_setting[str(name)] = [INVALID_SETTING_MESSAGE]
else:
unattributed.append(entry)
if unattributed:
log.warning("Advanced settings of course %s were not saved: %s", course_key, unattributed)
by_setting.setdefault(api_settings.NON_FIELD_ERRORS_KEY, []).append(SETTINGS_NOT_SAVED_MESSAGE)
return ValidationError(by_setting)
20 changes: 20 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,11 @@
YoutubeTranscriptChecksViewSet,
YoutubeTranscriptImportsViewSet,
)
from cms.djangoapps.contentstore.rest_api.v1.views.advanced_settings import CourseAdvancedSettingsViewSet
from cms.djangoapps.contentstore.rest_api.v1.views.course_optimizer import (
CourseLinkCheckViewSet,
CoursePreviousRunLinkUpdateViewSet,
)

app_name = "authoring_v1"

Expand Down Expand Up @@ -39,4 +44,19 @@
),
name="xblock_detail",
),
path(
"courses/<course_key:course_key>/advanced_settings/",
CourseAdvancedSettingsViewSet.as_view({"get": "retrieve", "patch": "partial_update"}),
name="course_advanced_settings",
),
path(
"courses/<course_key:course_key>/link_check/",
CourseLinkCheckViewSet.as_view({"get": "retrieve", "post": "create"}),
name="course_link_check",
),
path(
"courses/<course_key:course_key>/previous_run_link_update/",
CoursePreviousRunLinkUpdateViewSet.as_view({"get": "retrieve", "post": "create"}),
name="course_previous_run_link_update",
),
]
139 changes: 139 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v1/course_optimizer_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""Runs of the course optimizer: link checks and updates of links to a previous course run."""

import json

from rest_framework.exceptions import NotFound
from user_tasks.models import UserTaskArtifact, UserTaskStatus

from cms.djangoapps.contentstore.core.course_optimizer_provider import (
_latest_course_link_update_task_status,
_latest_task_status,
generate_broken_links_descriptor,
get_course_link_update_data,
sort_course_sections,
)
from cms.djangoapps.contentstore.tasks import check_broken_links, update_course_rerun_links
from cms.djangoapps.contentstore.toggles import enable_course_optimizer_check_prev_run_links

# The state of a resource no run of which the caller may see.
UNINITIATED = "Uninitiated"
# The stages the link update task reports while it runs.
SCANNING = "Scanning"
UPDATING = "Updating"

# Every state a link check or a link update can report. Scanning and Updating
# are reported by link updates only.
RUN_STATES = [
UNINITIATED,
UserTaskStatus.PENDING,
UserTaskStatus.IN_PROGRESS,
SCANNING,
UPDATING,
UserTaskStatus.SUCCEEDED,
UserTaskStatus.FAILED,
UserTaskStatus.CANCELED,
UserTaskStatus.RETRYING,
]

ALL_LINKS = "all"
SINGLE_LINKS = "single"

# Reported for a link check that failed or was canceled with an error recorded.
# The recorded text can be an exception's, so it is not returned.
LINK_CHECK_ERROR_MESSAGE = "The link check could not be completed."
# Reported for each link an update run could not change, in place of the
# recorded reason, which can be an exception's text.
LINK_NOT_UPDATED_MESSAGE = "The link could not be updated."
PREVIOUS_RUN_LINK_UPDATES_DISABLED_MESSAGE = "Updating links to a previous course run is not enabled for this course."

_FAILED_STATES = (UserTaskStatus.FAILED, UserTaskStatus.CANCELED)


def start_link_check(course_key, user, language):
"""Queue a check of the links in the course, as ``user``; return the state of the queued run."""
check_broken_links.delay(user.id, str(course_key), language)
return {"status": UserTaskStatus.PENDING}


def link_check_status(request, course_key, full=False):
"""
Return the latest link check of the course that the requester may see.

Callers see their own runs; superusers see everyone's. With ``full``, the
report of a run that succeeded is added under ``output`` (``None``
otherwise).
"""
run = _latest_task_status(request, str(course_key))
if run is None:
status = {"status": UNINITIATED, "created_at": None, "error": None}
else:
failed = run.state in _FAILED_STATES and (
UserTaskArtifact.objects.filter(status=run, name="Error").exclude(text="").exists()
)
status = {
"status": run.state,
"created_at": run.created,
"error": LINK_CHECK_ERROR_MESSAGE if failed else None,
}
if full:
succeeded = run is not None and run.state == UserTaskStatus.SUCCEEDED
status["output"] = _link_check_report(request.user, course_key, run) if succeeded else None
return status


def _link_check_report(user, course_key, run):
"""The flagged links a run found, sections in course order."""
artifact = UserTaskArtifact.objects.get(status=run, name="BrokenLinks")
with artifact.file as file:
links = json.loads(file.read())
report = generate_broken_links_descriptor(links, user, course_key)
return sort_course_sections(course_key, {"LinkCheckOutput": report})["LinkCheckOutput"]


def ensure_previous_run_link_updates_enabled(course_key):
"""Raise ``NotFound`` unless links to a previous run may be updated in the course."""
if not enable_course_optimizer_check_prev_run_links(course_key):
raise NotFound(PREVIOUS_RUN_LINK_UPDATES_DISABLED_MESSAGE)


def start_previous_run_link_update(course_key, user, action, links, language):
"""
Queue an update of the course's links to its previous run, as ``user``.

``action`` is ``all`` (every such link the course has) or ``single`` (the
``links`` given). Returns the state of the queued run. Callers check
``ensure_previous_run_link_updates_enabled`` first.
"""
update_course_rerun_links.delay(user.id, str(course_key), action, [dict(link) for link in links], language)
return {"status": UserTaskStatus.PENDING}


def previous_run_link_update_status(request, course_key, full=False):
"""
Return the latest update of links to the previous run that the requester may see.

Visibility is as for link checks. With ``full``, the outcome of each link
of a run that succeeded is added under ``results`` (empty otherwise).
Callers check ``ensure_previous_run_link_updates_enabled`` first.
"""
if not full:
run = _latest_course_link_update_task_status(request, str(course_key))
return {"status": UNINITIATED if run is None else run.state}
data = get_course_link_update_data(request, str(course_key))
return {
"status": UNINITIATED if data["status"] == "uninitiated" else data["status"],
"results": [_link_update_result(result) for result in data["results"]],
}


def _link_update_result(result):
"""One link's outcome, with a fixed message in place of the recorded failure reason."""
success = bool(result["success"])
return {
"id": result["id"],
"type": result["type"],
"original_url": result.get("original_url"),
"new_url": result["new_url"],
"success": success,
"error_message": None if success else LINK_NOT_UPDATED_MESSAGE,
}
Loading
Loading