Skip to content
Open
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
12 changes: 12 additions & 0 deletions src/ol_openedx_course_outline_api/CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions src/ol_openedx_course_outline_api/LICENSE.txt
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions src/ol_openedx_course_outline_api/MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
include README.rst
recursive-include ol_openedx_course_outline_api *.py
79 changes: 79 additions & 0 deletions src/ol_openedx_course_outline_api/README.rst
Original file line number Diff line number Diff line change
@@ -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:

- ``<LMS_BASE>/api/course-outline/v0/<course_id>/``

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>:<course_key>:<content_version>``.
- ``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/``.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""
ol_openedx_course_outline_api
"""
Original file line number Diff line number Diff line change
@@ -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"},
}
},
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Production settings unique to the course outline API."""


def plugin_settings(settings):
"""Settings for the course outline API.""" # noqa: D401
Original file line number Diff line number Diff line change
@@ -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",
),
]
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading