diff --git a/src/ol_openedx_course_outline_api/CHANGELOG.rst b/src/ol_openedx_course_outline_api/CHANGELOG.rst new file mode 100644 index 00000000..ed0ba516 --- /dev/null +++ b/src/ol_openedx_course_outline_api/CHANGELOG.rst @@ -0,0 +1,12 @@ +Change Log +########## + +.. + All enhancements and patches to ol_openedx_course_outline_api will be documented + in this file. It adheres to the structure of https://keepachangelog.com/ , + but in reStructuredText instead of Markdown (for ease of incorporation into + Sphinx documentation and the PyPI description). + + This project adheres to Semantic Versioning (https://semver.org/). + +.. There should always be an "Unreleased" section for changes pending release. diff --git a/src/ol_openedx_course_outline_api/LICENSE.txt b/src/ol_openedx_course_outline_api/LICENSE.txt new file mode 100644 index 00000000..88b9f046 --- /dev/null +++ b/src/ol_openedx_course_outline_api/LICENSE.txt @@ -0,0 +1,28 @@ +Copyright (C) 2023 MIT Open Learning + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/ol_openedx_course_outline_api/MANIFEST.in b/src/ol_openedx_course_outline_api/MANIFEST.in new file mode 100644 index 00000000..f06114ed --- /dev/null +++ b/src/ol_openedx_course_outline_api/MANIFEST.in @@ -0,0 +1,2 @@ +include README.rst +recursive-include ol_openedx_course_outline_api *.py diff --git a/src/ol_openedx_course_outline_api/README.rst b/src/ol_openedx_course_outline_api/README.rst new file mode 100644 index 00000000..f80d4bcf --- /dev/null +++ b/src/ol_openedx_course_outline_api/README.rst @@ -0,0 +1,79 @@ +Course Outline API Plugin +========================= + +A django app plugin to add a new API to Open edX that returns a course outline summary (one entry +per chapter) for a given course. + +Installation +------------ + +For detailed installation instructions, please refer to the +`plugin installation guide <../../docs#installation-guide>`_. + +Installation required in: + +* LMS + +How To Use +---------- + +The API supports a GET call to: + +- ``/api/course-outline/v0//`` + +The endpoint is protected by the platform API auth and requires an **admin** user (DRF ``IsAdminUser``). + +The successful response for ``http://local.openedx.io:8000/api/course-outline/v0/course-v1:edX+DemoX+Demo_Course/`` would look like: + +.. code-block:: + + { + "course_id": "course-v1:edX+DemoX+Demo_Course", + "generated_at": "2026-03-17T12:34:56Z", + "modules": [ + { + "id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@1414ffd5143b4b5", + "title": "Example Week 1: Getting Started", + "effort_time": 121, + "effort_activities": 1, + "counts": { + "videos": 5, + "readings": 3, + "problems": 2, + "assignments": 1, + "app_items": 0 + } + } + ] + } + +Notes +----- + +- ``generated_at`` is the timestamp when the outline was built (cached responses return the same value). +- ``effort_time`` and ``effort_activities`` come from the platform Effort Estimation transformer via the Blocks API. +- ``counts`` are computed by walking the Blocks API tree under each chapter (staff-only blocks are excluded): + - ``videos``: blocks with type ``video`` + - ``readings``: blocks with type ``html`` + - ``problems``: blocks with type ``problem`` + - ``assignments``: sequential blocks that are ``graded`` or have a non-empty ``format`` (except ``notgraded``) + - ``app_items``: leaf blocks that are not ``video``, ``html``, or ``problem`` (and not container types) + +Caching +------- + +This endpoint caches the full JSON response using Django's configured cache backend. + +- **TTL**: 24 hours. +- **Cache key**: ``ol_course_outline_api:outline:v0:s::``. + - ``schema_version`` is a plugin constant (bump it when you change the response shape or computation logic). + - ``content_version`` is ``course.course_version`` when present; otherwise the key uses ``na``. +- **Invalidation**: + - Publishing a course that updates ``course.course_version`` produces a new cache key, effectively invalidating old entries. + +Troubleshooting +--------------- + +- **Page not found (404)**: ensure the plugin is installed in the LMS and the URLs are registered. +- **Course ID in the URL**: course keys contain ``+``. Use URL-encoded form (``%2B``) when needed, e.g. + ``/api/course-outline/v0/course-v1:OpenedX%2BDemoX%2BDemoCourse/``. diff --git a/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/__init__.py b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/__init__.py new file mode 100644 index 00000000..dad0c5d2 --- /dev/null +++ b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/__init__.py @@ -0,0 +1,3 @@ +""" +ol_openedx_course_outline_api +""" diff --git a/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/app.py b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/app.py new file mode 100644 index 00000000..a0fd1b85 --- /dev/null +++ b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/app.py @@ -0,0 +1,34 @@ +""" +Course Outline API Application Configuration +""" + +from django.apps import AppConfig +from edx_django_utils.plugins import PluginSettings, PluginURLs +from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType + + +class CourseOutlineAPIConfig(AppConfig): + """ + Configuration class for Course Outline API (Learn product page modules). + """ + + name = "ol_openedx_course_outline_api" + verbose_name = "OL Course Outline API" + + plugin_app = { + PluginURLs.CONFIG: { + ProjectType.LMS: { + PluginURLs.NAMESPACE: "", + PluginURLs.REGEX: "^api/course-outline/v0/", + PluginURLs.RELATIVE_PATH: "urls", + } + }, + PluginSettings.CONFIG: { + ProjectType.LMS: { + SettingsType.PRODUCTION: { + PluginSettings.RELATIVE_PATH: "settings.production" + }, + SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: "settings.common"}, + } + }, + } diff --git a/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/constants.py b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/constants.py new file mode 100644 index 00000000..f792acd0 --- /dev/null +++ b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/constants.py @@ -0,0 +1,28 @@ +""" +Constants for the Course Outline API plugin. +""" + +# Block type groupings used when summarizing course content. +CONTAINER_TYPES = {"course", "chapter", "sequential", "vertical"} +KNOWN_LEAF_TYPES = {"video", "html", "problem"} + +# When format is this value (or empty), the subsection is not linked to an assignment. +NOT_GRADED_FORMAT = "notgraded" + +# Keys the Blocks API may use for staff-only visibility. +# The Blocks API serializer returns visible_to_staff_only, backed by +# VisibilityTransformer.MERGED_VISIBLE_TO_STAFF_ONLY in the block structure. +# Some environments may expose the merged field name directly, so we check both. +VISIBLE_TO_STAFF_ONLY_KEYS = ("visible_to_staff_only", "merged_visible_to_staff_only") + +# Response cache for `CourseOutlineView`. +# Key format is: +# {prefix}s{schema_version}:{course_key}:{content_version} +# where content_version is derived from `course.course_version`. +COURSE_OUTLINE_CACHE_KEY_PREFIX = "ol_course_outline_api:outline:v0:" +COURSE_OUTLINE_CACHE_TIMEOUT_SECONDS = 60 * 60 * 24 # 24 hours + +# Schema version embedded in the cache key. +# Increment this when the response shape or computation logic changes so old cache +# entries won't be reused. +COURSE_OUTLINE_CACHE_SCHEMA_VERSION = 1 diff --git a/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/settings/__init__.py b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/settings/common.py b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/settings/common.py new file mode 100644 index 00000000..ce3ce089 --- /dev/null +++ b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/settings/common.py @@ -0,0 +1,5 @@ +"""Common settings unique to the course outline API plugin.""" + + +def plugin_settings(settings): + """Settings for the course outline API plugin""" # noqa: D401 diff --git a/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/settings/production.py b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/settings/production.py new file mode 100644 index 00000000..bcf245c2 --- /dev/null +++ b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/settings/production.py @@ -0,0 +1,5 @@ +"""Production settings unique to the course outline API.""" + + +def plugin_settings(settings): + """Settings for the course outline API.""" # noqa: D401 diff --git a/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/urls.py b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/urls.py new file mode 100644 index 00000000..ab9b2d32 --- /dev/null +++ b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/urls.py @@ -0,0 +1,16 @@ +""" +Course outline endpoint urls. +""" + +from django.conf import settings +from django.urls import re_path + +from ol_openedx_course_outline_api.views import CourseOutlineView + +urlpatterns = [ + re_path( + rf"^{settings.COURSE_ID_PATTERN}/$", + CourseOutlineView.as_view(), + name="course_outline_api", + ), +] diff --git a/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/utils.py b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/utils.py new file mode 100644 index 00000000..6fd557e0 --- /dev/null +++ b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/utils.py @@ -0,0 +1,160 @@ +""" +Utility functions for working with Blocks API responses in the Course Outline API. +""" + +from ol_openedx_course_outline_api.constants import ( + CONTAINER_TYPES, + KNOWN_LEAF_TYPES, + NOT_GRADED_FORMAT, + VISIBLE_TO_STAFF_ONLY_KEYS, +) + + +def is_visible_to_staff_only(block): + """ + Return True if the block is staff-only, based on any known visibility key. + """ + return any(block.get(key) is True for key in VISIBLE_TO_STAFF_ONLY_KEYS) + + +def is_hidden_from_toc(block): + """ + Return True if the block should be hidden from the outline/table of contents. + """ + return block.get("hide_from_toc") is True + + +def iter_descendant_ids(blocks_data, root_id): + """ + Yield all descendant block ids (including root_id) from blocks_data. + + Implemented iteratively to avoid recursion depth issues on very large courses. + """ + seen = set() + stack = [root_id] + while stack: + block_id = stack.pop() + if block_id in seen: + continue + seen.add(block_id) + yield block_id + block = blocks_data.get(block_id, {}) or {} + if is_hidden_from_toc(block): + # If a block is hidden from the TOC, exclude it and its descendants. + continue + children = block.get("children") or [] + stack.extend(children) + + +def is_graded_sequential(block): + """ + Return True if this block is a sequential that counts as an assignment. + + The Blocks API returns the block's raw `graded` field (default False). + Studio can show a subsection as "linked" to an assignment type (format) + while the block still has graded=False. We treat a sequential as an + assignment if graded is True OR if it has a non-empty assignment format + (e.g. Homework, Lab, Midterm Exam, Final Exam, or custom names). + """ + if block.get("type") != "sequential": + return False + if block.get("graded") is True: + return True + format_val = (block.get("format") or "").strip() + return bool(format_val) and format_val.lower() != NOT_GRADED_FORMAT.lower() + + +def count_blocks_by_type_under_chapter(blocks_data, chapter_id, block_type): + """ + Count blocks of the given type under the chapter (excludes staff-only). + """ + count = 0 + for block_id in iter_descendant_ids(blocks_data, chapter_id): + block = blocks_data.get(block_id, {}) + if is_visible_to_staff_only(block): + continue + if is_hidden_from_toc(block): + continue + if block.get("type") == block_type: + count += 1 + return count + + +def count_assignments_under_chapter(blocks_data, chapter_id): + """ + Count sequential blocks that are graded or have an assignment format + (excludes staff-only). + """ + count = 0 + for block_id in iter_descendant_ids(blocks_data, chapter_id): + block = blocks_data.get(block_id, {}) + if is_visible_to_staff_only(block): + continue + if is_hidden_from_toc(block): + continue + if is_graded_sequential(block): + count += 1 + return count + + +def count_app_items_under_chapter(blocks_data, chapter_id): + """ + Count leaf blocks that are not video, html, or problem (custom/app items; + excludes staff-only). + """ + count = 0 + for block_id in iter_descendant_ids(blocks_data, chapter_id): + block = blocks_data.get(block_id, {}) + if is_visible_to_staff_only(block): + continue + if is_hidden_from_toc(block): + continue + block_type = block.get("type") or "" + children = block.get("children") or [] + is_leaf = len(children) == 0 + if ( + is_leaf + and block_type not in CONTAINER_TYPES + and block_type not in KNOWN_LEAF_TYPES + ): + count += 1 + return count + + +def build_modules_from_blocks(blocks_data, root_id): + """ + Build list of module dicts (one per chapter) from get_blocks response. + """ + modules = [] + root_block = blocks_data.get(root_id, {}) + for child_id in root_block.get("children") or []: + block = blocks_data.get(child_id, {}) + if block.get("type") != "chapter": + continue + if is_visible_to_staff_only(block): + continue + if is_hidden_from_toc(block): + continue + + counts = { + "videos": count_blocks_by_type_under_chapter( + blocks_data, child_id, "video" + ), + "readings": count_blocks_by_type_under_chapter( + blocks_data, child_id, "html" + ), + "problems": count_blocks_by_type_under_chapter( + blocks_data, child_id, "problem" + ), + "assignments": count_assignments_under_chapter(blocks_data, child_id), + "app_items": count_app_items_under_chapter(blocks_data, child_id), + } + module = { + "id": child_id, + "title": block.get("display_name") or "", + "effort_time": block.get("effort_time") or 0, + "effort_activities": block.get("effort_activities") or 0, + "counts": counts, + } + modules.append(module) + return modules diff --git a/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/views.py b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/views.py new file mode 100644 index 00000000..a3eab603 --- /dev/null +++ b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/views.py @@ -0,0 +1,127 @@ +""" +Views for the public Course Outline API (Learn product page modules). +""" + +from datetime import UTC, datetime + +from django.core.cache import cache +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from lms.djangoapps.course_api.blocks.api import get_blocks +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from openedx.core.lib.api.authentication import BearerAuthentication +from openedx.core.lib.api.view_utils import ( + DeveloperErrorViewMixin, + verify_course_exists, +) +from openedx.features.effort_estimation.block_transformers import ( + EffortEstimationTransformer, +) +from rest_framework import status +from rest_framework.authentication import SessionAuthentication +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import IsAdminUser +from rest_framework.response import Response +from xmodule.modulestore.django import modulestore + +from ol_openedx_course_outline_api.constants import ( + COURSE_OUTLINE_CACHE_KEY_PREFIX, + COURSE_OUTLINE_CACHE_SCHEMA_VERSION, + COURSE_OUTLINE_CACHE_TIMEOUT_SECONDS, +) +from ol_openedx_course_outline_api.utils import build_modules_from_blocks + + +class CourseOutlineView(DeveloperErrorViewMixin, GenericAPIView): + """ + Public API that returns course outline (modules) for the Learn product page. + + GET api/course-outline/v0/{course_id}/ + + Returns course_id, generated_at, and a list of modules (chapters) + with title, effort_time, effort_activities, and counts (videos, readings, + assignments, app_items). + + effort_time comes from the platform's EffortEstimationTransformer (see + openedx/features/effort_estimation). Configure course publish, video + durations, and waffle flag so the Blocks API returns non-zero effort_time; + see plugin README. + """ + + http_method_names = ["get"] + permission_classes = [IsAdminUser] + authentication_classes = ( + JwtAuthentication, + BearerAuthentication, + SessionAuthentication, + ) + + @verify_course_exists() + def get(self, request, course_id): + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError as err: + raise DeveloperErrorViewMixin.api_error( + status_code=status.HTTP_404_NOT_FOUND, + developer_message="Invalid course_id", + ) from err + + store = modulestore() + course = store.get_course(course_key) + if course is None: + raise DeveloperErrorViewMixin.api_error( + status_code=status.HTTP_404_NOT_FOUND, + developer_message="Course not found", + ) + # Cache key changes on: + # - response/schema changes (schema version), and + # - course publish/content changes (course.course_version when available). + content_version_str = str(getattr(course, "course_version", None) or "na") + cache_key = ( + f"{COURSE_OUTLINE_CACHE_KEY_PREFIX}" + f"s{COURSE_OUTLINE_CACHE_SCHEMA_VERSION}:" + f"{course_key}:" + f"{content_version_str}" + ) + cached = cache.get(cache_key) + if cached is not None: + return Response(cached, status=status.HTTP_200_OK) + + requested_fields = [ + "children", + "type", + "display_name", + "graded", + "format", # assignment type (Homework, etc.); fallback if graded is False + "hide_from_toc", + "visible_to_staff_only", + EffortEstimationTransformer.EFFORT_TIME, + EffortEstimationTransformer.EFFORT_ACTIVITIES, + ] + + blocks_response = get_blocks( + request, + course.location, + user=None, + # Full tree so units and their video/problem/html blocks are included. + depth=None, + requested_fields=requested_fields, + ) + + root_id = blocks_response.get("root") + blocks_data = blocks_response.get("blocks") or {} + modules = build_modules_from_blocks(blocks_data, root_id) + + response_data = { + "course_id": str(course_key), + "generated_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + "modules": modules, + } + + cache.set( + cache_key, + dict(response_data), + COURSE_OUTLINE_CACHE_TIMEOUT_SECONDS, + ) + + return Response(response_data, status=status.HTTP_200_OK) diff --git a/src/ol_openedx_course_outline_api/pyproject.toml b/src/ol_openedx_course_outline_api/pyproject.toml new file mode 100644 index 00000000..40d16dbe --- /dev/null +++ b/src/ol_openedx_course_outline_api/pyproject.toml @@ -0,0 +1,38 @@ +[project] +name = "ol-openedx-course-outline-api" +version = "0.1.0" +description = "An Open edX plugin to add API for course outline (modules) for the Learn product page" +authors = [ + {name = "MIT Office of Digital Learning"} +] +license = "BSD-3-Clause" +readme = "README.rst" +requires-python = ">=3.11" +dependencies = [ + "Django>=4.0", + "djangorestframework>=3.14.0", + "edx-django-utils>4.0.0", + "edx-drf-extensions>=10.0.0", + "edx-opaque-keys", +] + +[project.entry-points."lms.djangoapp"] +ol_openedx_course_outline_api = "ol_openedx_course_outline_api.app:CourseOutlineAPIConfig" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["ol_openedx_course_outline_api"] +include = [ + "ol_openedx_course_outline_api/**/*.py", +] + +[tool.hatch.build.targets.sdist] +include = [ + "ol_openedx_course_outline_api/**/*", + "README.rst", + "CHANGELOG.rst", + "pyproject.toml", +]