From 451b4b9d93bfb97881abdd197253b0f0d660f813 Mon Sep 17 00:00:00 2001 From: zamanafzal Date: Fri, 6 Mar 2026 17:39:18 +0500 Subject: [PATCH 01/10] feat: added ol_course_outline plugin --- src/ol_openedx_course_outline_api/LICENSE.txt | 28 +++ src/ol_openedx_course_outline_api/MANIFEST.in | 2 + src/ol_openedx_course_outline_api/README.rst | 83 ++++++++ .../ol_openedx_course_outline_api/__init__.py | 3 + .../ol_openedx_course_outline_api/app.py | 34 ++++ .../settings/common.py | 5 + .../settings/production.py | 5 + .../ol_openedx_course_outline_api/urls.py | 16 ++ .../ol_openedx_course_outline_api/views.py | 184 ++++++++++++++++++ .../pyproject.toml | 37 ++++ 10 files changed, 397 insertions(+) create mode 100644 src/ol_openedx_course_outline_api/LICENSE.txt create mode 100644 src/ol_openedx_course_outline_api/MANIFEST.in create mode 100644 src/ol_openedx_course_outline_api/README.rst create mode 100644 src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/__init__.py create mode 100644 src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/app.py create mode 100644 src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/settings/common.py create mode 100644 src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/settings/production.py create mode 100644 src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/urls.py create mode 100644 src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/views.py create mode 100644 src/ol_openedx_course_outline_api/pyproject.toml 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..b35f8317 --- /dev/null +++ b/src/ol_openedx_course_outline_api/README.rst @@ -0,0 +1,83 @@ +================================ +OL Course Outline API +================================ + +**Plan (what can be done this way):** One public endpoint returns per-module (chapter) +title, estimated time, and counts. We **do not have** a module summary/description (Open edX +has no such field). For **time**: we can use the platform’s effort only for **videos** (and +readings); there is **no mechanism** to count time for assignments. Counts come from the +Blocks API: **videos** = ``video`` blocks, **readings** = ``html`` blocks, **assignments** = +graded sequentials (count), **app_items** = other leaf blocks (not video/html/problem). +Visibility and effort follow platform settings; no new storage. + +**Endpoint:** ``GET /api/course-outline/v0/{course_id}/`` + +---------------------------------------------------------------------------- +What does the API return? +---------------------------------------------------------------------------- + +We do **not** have a module summary or description (Open edX does not expose that for +chapters). The response is one JSON object per request with: + +- **course_id** – Course key (e.g. ``course-v1:Org+Course+Run``). +- **generated_at** – ISO timestamp when the outline was built. +- **modules** – List of modules (one per Open edX **chapter**), each with: + - **id** – Chapter block usage key. + - **title** – Chapter display name. + - **estimated_time_seconds** – Total estimated time for the chapter (seconds). + - **counts** – Object with **videos**, **readings**, **assignments**, **app_items** (integers). + +The front end can format ``estimated_time_seconds`` (e.g. hours/minutes) and combine title +and counts into a line like "Module 1: Introduction — 5 videos, 3 readings, 1 assignment, 2 activities" (time only reflects video + reading; no assignment time). + +---------------------------------------------------------------------------- +How is estimated time (estimated_time_seconds) obtained? +---------------------------------------------------------------------------- + +**Where it comes from:** We use the Open edX **EffortEstimationTransformer** (Blocks API). +We **can** count time for **videos** (duration from video pipeline/edxval); the platform +also estimates **reading** time from HTML word count. It does **not** provide time for +assignments or other block types. At the chapter level, ``estimated_time_seconds`` is +the platform’s aggregate of video + reading time only. There is no fallback; if the +platform returns 0, we return 0. + +**How to get non-zero values:** See the section *How to get non-zero estimated_time_seconds?* below (publish, video durations, waffle flag). + +**What about time for assignments?** There is **no mechanism** in Open edX to set or +derive time for assignments/subsections. We only have **estimated_time_seconds** (video + +reading). **counts.assignments** is the number of graded sequentials, not a duration. + +---------------------------------------------------------------------------- +How are the content counts (mappings) done? +---------------------------------------------------------------------------- + +We call the **Blocks API** (``get_blocks``) with ``block_counts=["video", "html", "problem"]`` +and use the block tree under each chapter. Mappings: + +- **videos** – Blocks with type ``video`` under the chapter (Blocks API ``block_counts.video``). +- **readings** – Blocks with type ``html`` under the chapter (``block_counts.html``). +- **assignments** – Count of **graded sequentials** (``type == "sequential"`` and ``graded == True``); not problem count. +- **app_items** – **Leaf** blocks (no children) whose type is not ``video``, ``html``, or ``problem``, and not a container (``course``, ``chapter``, ``sequential``, ``vertical``)—e.g. custom XBlocks, drag-and-drop. + +---------------------------------------------------------------------------- +How to get non-zero estimated_time_seconds? +---------------------------------------------------------------------------- + +The platform only fills ``effort_time`` when the EffortEstimationTransformer runs and has enough data. Do the following. + +**1. Re-publish the course** + +Block structure (and effort) is updated on course publish. In **Studio**, open the course and use **Publish**. After the background task completes (often within a minute), chapter-level ``effort_time`` is available. Re-publish again after changing content or fixing video durations. + +**2. Ensure every video has a duration** + +The transformer uses the video pipeline (e.g. edxval). If *any* course video has missing or zero duration, the transformer disables estimation for the **entire** course. Use **Video Uploads** in Studio (or your pipeline) so every video has duration metadata; fix or re-process any video that does not. + +**3. Leave effort estimation enabled for the course** + +The course-level waffle flag ``effort_estimation.disabled`` must be **off** for the course. + +- **Django Admin:** **Waffle Utils > Waffle flag course overrides**. If there is an override for ``effort_estimation.disabled`` and your course, set **Override choice** to **Force Off**. If there is no override, estimation is enabled by default. +- Do not force the flag **On** for this course, or ``estimated_time_seconds`` will stay 0. + +When all three are done, the API will return non-zero ``estimated_time_seconds`` when the Blocks API provides them. 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..4484aae2 --- /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 (public course modules summary). + """ + + 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/settings/common.py b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/settings/common.py new file mode 100644 index 00000000..eb958f27 --- /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..59554690 --- /dev/null +++ b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/settings/production.py @@ -0,0 +1,5 @@ +"""Production settings for 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/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/views.py b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/views.py new file mode 100644 index 00000000..f45084db --- /dev/null +++ b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/views.py @@ -0,0 +1,184 @@ +""" +Views for the public Course Outline API (Learn product page modules). +""" + +import logging + +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.generics import GenericAPIView +from rest_framework.response import Response +from xmodule.modulestore.django import modulestore + +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists +from lms.djangoapps.course_api.blocks.api import get_blocks +from openedx.features.effort_estimation.block_transformers import EffortEstimationTransformer +from xmodule.course_block import ( + COURSE_VISIBILITY_PUBLIC, + COURSE_VISIBILITY_PUBLIC_OUTLINE, +) + +log = logging.getLogger(__name__) + +# When False, skip course visibility check so any course outline is accessible (e.g. for testing). +# When True, only public/public_outline courses are allowed, with staff bypass. +ENFORCE_PUBLIC_ACCESS = False + +CONTAINER_TYPES = {"course", "chapter", "sequential", "vertical"} +KNOWN_LEAF_TYPES = {"video", "html", "problem"} + +# Open edX block type (XBlock category) -> Learn API count key. +# BlockCountsTransformer returns counts keyed by category; map to our response names. +# (assignments = graded sequentials, not problem count; we count those separately.) +BLOCK_TYPE_TO_LEARN_COUNT = { + "video": "videos", + "html": "readings", +} +# Block types we ask the Blocks API to count (category names at block level). +BLOCK_TYPES_FOR_COUNTS = ["video", "html", "problem"] + + +def _get_descendant_ids(blocks_data, block_id): + """Return set of all descendant block ids (including block_id) from blocks_data.""" + result = {block_id} + for child_id in blocks_data.get(block_id, {}).get("children") or []: + result.update(_get_descendant_ids(blocks_data, child_id)) + return result + + +def _count_assignments_under_chapter(blocks_data, chapter_id): + """Count sequential blocks with graded=True under the chapter.""" + count = 0 + for bid in _get_descendant_ids(blocks_data, chapter_id): + block = blocks_data.get(bid, {}) + if block.get("type") == "sequential" and block.get("graded") is True: + 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).""" + count = 0 + for bid in _get_descendant_ids(blocks_data, chapter_id): + block = blocks_data.get(bid, {}) + 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 + block_counts = block.get("block_counts") or {} + counts = {"videos": 0, "readings": 0, "assignments": 0, "app_items": 0} + for block_type, learn_key in BLOCK_TYPE_TO_LEARN_COUNT.items(): + if learn_key in counts: + counts[learn_key] = block_counts.get(block_type, 0) + counts["assignments"] = _count_assignments_under_chapter(blocks_data, child_id) + counts["app_items"] = _count_app_items_under_chapter(blocks_data, child_id) + modules.append({ + "id": child_id, + "title": block.get("display_name") or "", + "estimated_time_seconds": block.get("effort_time") or 0, + "counts": counts, + }) + return modules + + +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, + estimated_time_seconds, and counts (videos, readings, assignments, app_items). + + estimated_time_seconds 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 = [AllowAny] + + @verify_course_exists() + def get(self, request, course_id): + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError: + raise DeveloperErrorViewMixin.api_error( + status_code=status.HTTP_404_NOT_FOUND, + developer_message="Invalid course_id", + ) + + 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", + ) + + if ENFORCE_PUBLIC_ACCESS: + visibility = getattr(course, "course_visibility", None) + if visibility not in (COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE): + try: + from lms.djangoapps.courseware.access import has_access + + if not ( + request.user.is_authenticated + and has_access(request.user, "staff", course_key).has_access + ): + raise DeveloperErrorViewMixin.api_error( + status_code=status.HTTP_403_FORBIDDEN, + developer_message="Course is not available for public outline access", + ) + except ImportError: + raise DeveloperErrorViewMixin.api_error( + status_code=status.HTTP_403_FORBIDDEN, + developer_message="Course is not available for public outline access", + ) + + requested_fields = [ + "children", + "type", + "display_name", + "graded", + "block_counts", # required so each block gets block_counts dict (video/html/problem at block level) + EffortEstimationTransformer.EFFORT_TIME, + ] + blocks_response = get_blocks( + request, + course.location, + user=None, + depth=None, # full tree so units and their video/problem/html blocks are included + nav_depth=3, + requested_fields=requested_fields, + block_counts=BLOCK_TYPES_FOR_COUNTS, + ) + + root_id = blocks_response.get("root") + blocks_data = blocks_response.get("blocks") or {} + modules = _build_modules_from_blocks(blocks_data, root_id) + + from datetime import datetime, timezone as dt_tz + + return Response( + { + "course_id": str(course_key), + "generated_at": datetime.now(dt_tz.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "modules": modules, + }, + 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..34d9de34 --- /dev/null +++ b/src/ol_openedx_course_outline_api/pyproject.toml @@ -0,0 +1,37 @@ +[project] +name = "ol-openedx-course-outline-api" +version = "0.1.0" +description = "An Open edX plugin providing a public API for course outline (modules summary) for product and marketing pages" +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", + "pyproject.toml", +] From a38ddc460e117748d0a0a74b2e0e79d028f73da3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:40:22 +0000 Subject: [PATCH 02/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../ol_openedx_course_outline_api/views.py | 46 ++++++++++++------- 1 file changed, 30 insertions(+), 16 deletions(-) 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 index f45084db..3b9143f2 100644 --- 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 @@ -3,22 +3,27 @@ """ import logging +from datetime import UTC +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.view_utils import ( + DeveloperErrorViewMixin, + verify_course_exists, +) +from openedx.features.effort_estimation.block_transformers import ( + EffortEstimationTransformer, +) from rest_framework import status -from rest_framework.permissions import AllowAny from rest_framework.generics import GenericAPIView +from rest_framework.permissions import AllowAny from rest_framework.response import Response -from xmodule.modulestore.django import modulestore - -from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists -from lms.djangoapps.course_api.blocks.api import get_blocks -from openedx.features.effort_estimation.block_transformers import EffortEstimationTransformer from xmodule.course_block import ( COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE, ) +from xmodule.modulestore.django import modulestore log = logging.getLogger(__name__) @@ -66,7 +71,11 @@ def _count_app_items_under_chapter(blocks_data, chapter_id): 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: + if ( + is_leaf + and block_type not in CONTAINER_TYPES + and block_type not in KNOWN_LEAF_TYPES + ): count += 1 return count @@ -86,12 +95,14 @@ def _build_modules_from_blocks(blocks_data, root_id): counts[learn_key] = block_counts.get(block_type, 0) counts["assignments"] = _count_assignments_under_chapter(blocks_data, child_id) counts["app_items"] = _count_app_items_under_chapter(blocks_data, child_id) - modules.append({ - "id": child_id, - "title": block.get("display_name") or "", - "estimated_time_seconds": block.get("effort_time") or 0, - "counts": counts, - }) + modules.append( + { + "id": child_id, + "title": block.get("display_name") or "", + "estimated_time_seconds": block.get("effort_time") or 0, + "counts": counts, + } + ) return modules @@ -132,7 +143,10 @@ def get(self, request, course_id): if ENFORCE_PUBLIC_ACCESS: visibility = getattr(course, "course_visibility", None) - if visibility not in (COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE): + if visibility not in ( + COURSE_VISIBILITY_PUBLIC, + COURSE_VISIBILITY_PUBLIC_OUTLINE, + ): try: from lms.djangoapps.courseware.access import has_access @@ -172,12 +186,12 @@ def get(self, request, course_id): blocks_data = blocks_response.get("blocks") or {} modules = _build_modules_from_blocks(blocks_data, root_id) - from datetime import datetime, timezone as dt_tz + from datetime import datetime return Response( { "course_id": str(course_key), - "generated_at": datetime.now(dt_tz.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "generated_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), "modules": modules, }, status=status.HTTP_200_OK, From 20d379291134340775325fc1ef99a01bd3c195c9 Mon Sep 17 00:00:00 2001 From: zamanafzal Date: Mon, 16 Mar 2026 20:10:27 +0500 Subject: [PATCH 03/10] feat: added course_outline_block api --- src/ol_openedx_course_outline_api/README.rst | 48 +++-- .../ol_openedx_course_outline_api/app.py | 2 +- .../constants.py | 21 ++ .../settings/__init__.py | 0 .../settings/common.py | 2 +- .../settings/production.py | 4 +- .../ol_openedx_course_outline_api/utils.py | 126 ++++++++++++ .../ol_openedx_course_outline_api/views.py | 185 +++++------------- 8 files changed, 237 insertions(+), 151 deletions(-) create mode 100644 src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/constants.py create mode 100644 src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/settings/__init__.py create mode 100644 src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/utils.py diff --git a/src/ol_openedx_course_outline_api/README.rst b/src/ol_openedx_course_outline_api/README.rst index b35f8317..4e6809fd 100644 --- a/src/ol_openedx_course_outline_api/README.rst +++ b/src/ol_openedx_course_outline_api/README.rst @@ -1,9 +1,9 @@ ================================ -OL Course Outline API +Course Outline API Plugin ================================ **Plan (what can be done this way):** One public endpoint returns per-module (chapter) -title, estimated time, and counts. We **do not have** a module summary/description (Open edX +title, effort time, and counts. We **do not have** a module summary/description (Open edX has no such field). For **time**: we can use the platform’s effort only for **videos** (and readings); there is **no mechanism** to count time for assignments. Counts come from the Blocks API: **videos** = ``video`` blocks, **readings** = ``html`` blocks, **assignments** = @@ -12,6 +12,28 @@ Visibility and effort follow platform settings; no new storage. **Endpoint:** ``GET /api/course-outline/v0/{course_id}/`` +Installation +------------ + +For detailed installation instructions, please refer to the +`plugin installation guide <../../docs#installation-guide>`_. + +Installation required in: + +* LMS + +The plugin must be **installed** in the LMS so it is in ``INSTALLED_APPS`` and its URLs are registered. + +- **Tutor:** Add the package to your Tutor config so it is installed in the LMS container (e.g. ``OPENEDX_PLUGINS`` or a custom requirements file, then ``tutor images build openedx`` and restart). If you mount the repo, run ``pip install -e /path/to/open-edx-plugins/src/ol_openedx_course_outline_api`` inside the LMS container. +- **Standalone:** ``pip install ol-openedx-course-outline-api`` (or install from the repo). Ensure the LMS ``INSTALLED_APPS`` includes the plugin (auto-added when installed via the ``lms.djangoapp`` entry point). + +---------------------------------------------------------------------------- +Troubleshooting: Page not found (404) +---------------------------------------------------------------------------- + +- **Plugin not installed:** If the plugin is not installed in the LMS, no URL pattern is registered and you get a 404. Confirm the package is installed (e.g. ``pip list | grep course-outline``) and that the app is loaded (e.g. in Django shell: ``from django.conf import settings; "ol_openedx_course_outline_api" in settings.INSTALLED_APPS``). +- **Course ID in the URL:** Course keys contain ``+`` (e.g. ``course-v1:OpenedX+DemoX+DemoCourse``). In URLs, ``+`` can be interpreted as a space. Use **URL-encoded** form: ``course-v1:OpenedX%2BDemoX%2BDemoCourse``. Example: ``GET /api/course-outline/v0/course-v1:OpenedX%2BDemoX%2BDemoCourse/``. + ---------------------------------------------------------------------------- What does the API return? ---------------------------------------------------------------------------- @@ -24,27 +46,28 @@ chapters). The response is one JSON object per request with: - **modules** – List of modules (one per Open edX **chapter**), each with: - **id** – Chapter block usage key. - **title** – Chapter display name. - - **estimated_time_seconds** – Total estimated time for the chapter (seconds). + - **effort_time** – Total estimated time for the chapter (seconds) as returned by the Blocks API. + - **effort_activities** – Number of activities that the effort system counted when computing ``effort_time``. - **counts** – Object with **videos**, **readings**, **assignments**, **app_items** (integers). -The front end can format ``estimated_time_seconds`` (e.g. hours/minutes) and combine title +The front end can format ``effort_time`` (e.g. hours/minutes) and combine title and counts into a line like "Module 1: Introduction — 5 videos, 3 readings, 1 assignment, 2 activities" (time only reflects video + reading; no assignment time). ---------------------------------------------------------------------------- -How is estimated time (estimated_time_seconds) obtained? +How is estimated time (effort_time) obtained? ---------------------------------------------------------------------------- **Where it comes from:** We use the Open edX **EffortEstimationTransformer** (Blocks API). We **can** count time for **videos** (duration from video pipeline/edxval); the platform also estimates **reading** time from HTML word count. It does **not** provide time for -assignments or other block types. At the chapter level, ``estimated_time_seconds`` is +assignments or other block types. At the chapter level, ``effort_time`` is the platform’s aggregate of video + reading time only. There is no fallback; if the platform returns 0, we return 0. -**How to get non-zero values:** See the section *How to get non-zero estimated_time_seconds?* below (publish, video durations, waffle flag). +**How to get non-zero values:** See the section *How to get non-zero effort_time?* below (publish, video durations, waffle flag). **What about time for assignments?** There is **no mechanism** in Open edX to set or -derive time for assignments/subsections. We only have **estimated_time_seconds** (video + +derive time for assignments/subsections. We only have **effort_time** (video + reading). **counts.assignments** is the number of graded sequentials, not a duration. ---------------------------------------------------------------------------- @@ -56,11 +79,12 @@ and use the block tree under each chapter. Mappings: - **videos** – Blocks with type ``video`` under the chapter (Blocks API ``block_counts.video``). - **readings** – Blocks with type ``html`` under the chapter (``block_counts.html``). -- **assignments** – Count of **graded sequentials** (``type == "sequential"`` and ``graded == True``); not problem count. +- **assignments** – Count of sequentials that are graded or have an assignment type. We treat a sequential as an assignment if ``graded == True`` **or** if it has a non-empty **format** (e.g. Homework, Lab, Midterm Exam, Final Exam). The Blocks API can return ``graded: false`` even when the subsection is linked to an assignment in Studio; we request ``format`` and use it as a fallback so those still count. - **app_items** – **Leaf** blocks (no children) whose type is not ``video``, ``html``, or ``problem``, and not a container (``course``, ``chapter``, ``sequential``, ``vertical``)—e.g. custom XBlocks, drag-and-drop. +- **asktim** – Number of video/problem blocks under the chapter that have **Enable AI Chat Assistant** on (ol_openedx_chat aside). 0 if the plugin is not installed. ---------------------------------------------------------------------------- -How to get non-zero estimated_time_seconds? +How to get non-zero effort_time? ---------------------------------------------------------------------------- The platform only fills ``effort_time`` when the EffortEstimationTransformer runs and has enough data. Do the following. @@ -78,6 +102,6 @@ The transformer uses the video pipeline (e.g. edxval). If *any* course video has The course-level waffle flag ``effort_estimation.disabled`` must be **off** for the course. - **Django Admin:** **Waffle Utils > Waffle flag course overrides**. If there is an override for ``effort_estimation.disabled`` and your course, set **Override choice** to **Force Off**. If there is no override, estimation is enabled by default. -- Do not force the flag **On** for this course, or ``estimated_time_seconds`` will stay 0. +- Do not force the flag **On** for this course, or ``effort_time`` will stay 0. -When all three are done, the API will return non-zero ``estimated_time_seconds`` when the Blocks API provides them. +When all three are done, the API will return non-zero ``effort_time`` when the Blocks API provides them. 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 index 4484aae2..a0fd1b85 100644 --- 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 @@ -9,7 +9,7 @@ class CourseOutlineAPIConfig(AppConfig): """ - Configuration class for Course Outline API (public course modules summary). + Configuration class for Course Outline API (Learn product page modules). """ name = "ol_openedx_course_outline_api" 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..66b3697e --- /dev/null +++ b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/constants.py @@ -0,0 +1,21 @@ +""" +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") + +# Per-course response cache (used only when include_gating is False; key = course_id). +COURSE_OUTLINE_CACHE_KEY_PREFIX = "ol_course_outline_api:outline:v0:" +COURSE_OUTLINE_CACHE_TIMEOUT_SECONDS = 300 # 5 minutes + 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 index eb958f27..ce3ce089 100644 --- 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 @@ -2,4 +2,4 @@ def plugin_settings(settings): - """Settings for the course outline API plugin.""" # noqa: D401 + """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 index 59554690..bcf245c2 100644 --- 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 @@ -1,5 +1,5 @@ -"""Production settings for the course outline API plugin.""" +"""Production settings unique to the course outline API.""" def plugin_settings(settings): - """Settings for the course outline API plugin.""" # noqa: D401 + """Settings for the course outline API.""" # noqa: D401 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..d43e4889 --- /dev/null +++ b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/utils.py @@ -0,0 +1,126 @@ +""" +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. + """ + for key in VISIBLE_TO_STAFF_ONLY_KEYS: + if block.get(key) is True: + return True + return False + + +def get_descendant_ids(blocks_data, block_id): + """ + Return set of all descendant block ids (including block_id) from blocks_data. + """ + result = {block_id} + for child_id in blocks_data.get(block_id, {}).get("children") or []: + result.update(get_descendant_ids(blocks_data, child_id)) + return result + + +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 get_descendant_ids(blocks_data, chapter_id): + block = blocks_data.get(block_id, {}) + if is_visible_to_staff_only(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 get_descendant_ids(blocks_data, chapter_id): + block = blocks_data.get(block_id, {}) + if is_visible_to_staff_only(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 get_descendant_ids(blocks_data, chapter_id): + block = blocks_data.get(block_id, {}) + if is_visible_to_staff_only(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 + + counts = { + # Match Blocks API-style block_counts keys: + # html = readings, problem = problems, video = videos, others = everything else. + "html": count_blocks_by_type_under_chapter(blocks_data, child_id, "html"), + "problem": count_blocks_by_type_under_chapter(blocks_data, child_id, "problem"), + "video": count_blocks_by_type_under_chapter(blocks_data, child_id, "video"), + "others": 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 index 3b9143f2..375d742f 100644 --- 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 @@ -2,108 +2,26 @@ Views for the public Course Outline API (Learn product page modules). """ -import logging -from datetime import UTC - -from lms.djangoapps.course_api.blocks.api import get_blocks +from django.core.cache import cache from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey -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.permissions import IsAdminUser from rest_framework.generics import GenericAPIView -from rest_framework.permissions import AllowAny from rest_framework.response import Response -from xmodule.course_block import ( - COURSE_VISIBILITY_PUBLIC, - COURSE_VISIBILITY_PUBLIC_OUTLINE, -) +from rest_framework.authentication import SessionAuthentication from xmodule.modulestore.django import modulestore -log = logging.getLogger(__name__) - -# When False, skip course visibility check so any course outline is accessible (e.g. for testing). -# When True, only public/public_outline courses are allowed, with staff bypass. -ENFORCE_PUBLIC_ACCESS = False - -CONTAINER_TYPES = {"course", "chapter", "sequential", "vertical"} -KNOWN_LEAF_TYPES = {"video", "html", "problem"} - -# Open edX block type (XBlock category) -> Learn API count key. -# BlockCountsTransformer returns counts keyed by category; map to our response names. -# (assignments = graded sequentials, not problem count; we count those separately.) -BLOCK_TYPE_TO_LEARN_COUNT = { - "video": "videos", - "html": "readings", -} -# Block types we ask the Blocks API to count (category names at block level). -BLOCK_TYPES_FOR_COUNTS = ["video", "html", "problem"] - - -def _get_descendant_ids(blocks_data, block_id): - """Return set of all descendant block ids (including block_id) from blocks_data.""" - result = {block_id} - for child_id in blocks_data.get(block_id, {}).get("children") or []: - result.update(_get_descendant_ids(blocks_data, child_id)) - return result - - -def _count_assignments_under_chapter(blocks_data, chapter_id): - """Count sequential blocks with graded=True under the chapter.""" - count = 0 - for bid in _get_descendant_ids(blocks_data, chapter_id): - block = blocks_data.get(bid, {}) - if block.get("type") == "sequential" and block.get("graded") is True: - 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).""" - count = 0 - for bid in _get_descendant_ids(blocks_data, chapter_id): - block = blocks_data.get(bid, {}) - 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 - block_counts = block.get("block_counts") or {} - counts = {"videos": 0, "readings": 0, "assignments": 0, "app_items": 0} - for block_type, learn_key in BLOCK_TYPE_TO_LEARN_COUNT.items(): - if learn_key in counts: - counts[learn_key] = block_counts.get(block_type, 0) - counts["assignments"] = _count_assignments_under_chapter(blocks_data, child_id) - counts["app_items"] = _count_app_items_under_chapter(blocks_data, child_id) - modules.append( - { - "id": child_id, - "title": block.get("display_name") or "", - "estimated_time_seconds": block.get("effort_time") or 0, - "counts": counts, - } - ) - return modules +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from openedx.core.lib.api.authentication import BearerAuthentication +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists +from lms.djangoapps.course_api.blocks.api import get_blocks +from openedx.features.effort_estimation.block_transformers import EffortEstimationTransformer +from ol_openedx_course_outline_api.constants import ( + COURSE_OUTLINE_CACHE_KEY_PREFIX, + COURSE_OUTLINE_CACHE_TIMEOUT_SECONDS, +) +from ol_openedx_course_outline_api.utils import build_modules_from_blocks class CourseOutlineView(DeveloperErrorViewMixin, GenericAPIView): @@ -112,19 +30,26 @@ class CourseOutlineView(DeveloperErrorViewMixin, GenericAPIView): GET api/course-outline/v0/{course_id}/ - Returns course_id, generated_at, and a list of modules (chapters) with title, - estimated_time_seconds, and counts (videos, readings, assignments, app_items). + Returns course_id, generated_at, and a list of modules (chapters) + with title, effort_time, effort_activities, and counts (videos, readings, assignments, app_items). - estimated_time_seconds comes from the platform's EffortEstimationTransformer (see + 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 = [AllowAny] + permission_classes = [IsAdminUser] + authentication_classes = ( + JwtAuthentication, + BearerAuthentication, + SessionAuthentication, + ) @verify_course_exists() def get(self, request, course_id): + from datetime import datetime, timezone as dt_tz + try: course_key = CourseKey.from_string(course_id) except InvalidKeyError: @@ -141,58 +66,48 @@ def get(self, request, course_id): developer_message="Course not found", ) - if ENFORCE_PUBLIC_ACCESS: - visibility = getattr(course, "course_visibility", None) - if visibility not in ( - COURSE_VISIBILITY_PUBLIC, - COURSE_VISIBILITY_PUBLIC_OUTLINE, - ): - try: - from lms.djangoapps.courseware.access import has_access - - if not ( - request.user.is_authenticated - and has_access(request.user, "staff", course_key).has_access - ): - raise DeveloperErrorViewMixin.api_error( - status_code=status.HTTP_403_FORBIDDEN, - developer_message="Course is not available for public outline access", - ) - except ImportError: - raise DeveloperErrorViewMixin.api_error( - status_code=status.HTTP_403_FORBIDDEN, - developer_message="Course is not available for public outline access", - ) + # Per-course cache: only when response is not user-specific (no gating). + cache_key = f"{COURSE_OUTLINE_CACHE_KEY_PREFIX}{course_key}" + cached = cache.get(cache_key) + if cached is not None: + cached["generated_at"] = datetime.now(dt_tz.utc).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + return Response(cached, status=status.HTTP_200_OK) requested_fields = [ "children", "type", "display_name", "graded", - "block_counts", # required so each block gets block_counts dict (video/html/problem at block level) + "format", # assignment type (Homework, etc.); used as fallback when graded is False + "visible_to_staff_only", EffortEstimationTransformer.EFFORT_TIME, ] + blocks_response = get_blocks( request, course.location, user=None, depth=None, # full tree so units and their video/problem/html blocks are included - nav_depth=3, requested_fields=requested_fields, - block_counts=BLOCK_TYPES_FOR_COUNTS, ) root_id = blocks_response.get("root") blocks_data = blocks_response.get("blocks") or {} - modules = _build_modules_from_blocks(blocks_data, root_id) - - from datetime import datetime - - return Response( - { - "course_id": str(course_key), - "generated_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), - "modules": modules, - }, - status=status.HTTP_200_OK, + modules = build_modules_from_blocks(blocks_data, root_id) + + response_data = { + "course_id": str(course_key), + "generated_at": datetime.now(dt_tz.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "modules": modules, + } + + cache_key = f"{COURSE_OUTLINE_CACHE_KEY_PREFIX}{course_key}" + cache.set( + cache_key, + dict(response_data), + COURSE_OUTLINE_CACHE_TIMEOUT_SECONDS, ) + + return Response(response_data, status=status.HTTP_200_OK) From 17758833812f0ac135d0d87402c11fe9ca0b75c7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:12:12 +0000 Subject: [PATCH 04/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../constants.py | 1 - .../ol_openedx_course_outline_api/utils.py | 11 +++++--- .../ol_openedx_course_outline_api/views.py | 27 ++++++++++++------- 3 files changed, 25 insertions(+), 14 deletions(-) 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 index 66b3697e..1505b3b8 100644 --- 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 @@ -18,4 +18,3 @@ # Per-course response cache (used only when include_gating is False; key = course_id). COURSE_OUTLINE_CACHE_KEY_PREFIX = "ol_course_outline_api:outline:v0:" COURSE_OUTLINE_CACHE_TIMEOUT_SECONDS = 300 # 5 minutes - 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 index d43e4889..1719a42d 100644 --- 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 @@ -88,7 +88,11 @@ def count_app_items_under_chapter(blocks_data, chapter_id): 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: + if ( + is_leaf + and block_type not in CONTAINER_TYPES + and block_type not in KNOWN_LEAF_TYPES + ): count += 1 return count @@ -110,7 +114,9 @@ def build_modules_from_blocks(blocks_data, root_id): # Match Blocks API-style block_counts keys: # html = readings, problem = problems, video = videos, others = everything else. "html": count_blocks_by_type_under_chapter(blocks_data, child_id, "html"), - "problem": count_blocks_by_type_under_chapter(blocks_data, child_id, "problem"), + "problem": count_blocks_by_type_under_chapter( + blocks_data, child_id, "problem" + ), "video": count_blocks_by_type_under_chapter(blocks_data, child_id, "video"), "others": count_app_items_under_chapter(blocks_data, child_id), } @@ -123,4 +129,3 @@ def build_modules_from_blocks(blocks_data, root_id): } 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 index 375d742f..2ac88cc2 100644 --- 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 @@ -2,21 +2,28 @@ Views for the public Course Outline API (Learn product page modules). """ +from datetime import UTC + 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.permissions import IsAdminUser +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 rest_framework.authentication import SessionAuthentication from xmodule.modulestore.django import modulestore -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication -from openedx.core.lib.api.authentication import BearerAuthentication -from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists -from lms.djangoapps.course_api.blocks.api import get_blocks -from openedx.features.effort_estimation.block_transformers import EffortEstimationTransformer from ol_openedx_course_outline_api.constants import ( COURSE_OUTLINE_CACHE_KEY_PREFIX, COURSE_OUTLINE_CACHE_TIMEOUT_SECONDS, @@ -48,7 +55,7 @@ class CourseOutlineView(DeveloperErrorViewMixin, GenericAPIView): @verify_course_exists() def get(self, request, course_id): - from datetime import datetime, timezone as dt_tz + from datetime import datetime try: course_key = CourseKey.from_string(course_id) @@ -70,7 +77,7 @@ def get(self, request, course_id): cache_key = f"{COURSE_OUTLINE_CACHE_KEY_PREFIX}{course_key}" cached = cache.get(cache_key) if cached is not None: - cached["generated_at"] = datetime.now(dt_tz.utc).strftime( + cached["generated_at"] = datetime.now(UTC).strftime( "%Y-%m-%dT%H:%M:%SZ" ) return Response(cached, status=status.HTTP_200_OK) @@ -99,7 +106,7 @@ def get(self, request, course_id): response_data = { "course_id": str(course_key), - "generated_at": datetime.now(dt_tz.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "generated_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), "modules": modules, } From a2e09d9b812906ace34afb37933306e7c5079601 Mon Sep 17 00:00:00 2001 From: zamanafzal Date: Tue, 17 Mar 2026 16:46:25 +0500 Subject: [PATCH 05/10] feat: added course_outline_block api --- src/ol_openedx_course_outline_api/README.rst | 148 +++++++----------- .../ol_openedx_course_outline_api/utils.py | 39 +++-- .../ol_openedx_course_outline_api/views.py | 22 +-- .../pyproject.toml | 3 +- 4 files changed, 83 insertions(+), 129 deletions(-) diff --git a/src/ol_openedx_course_outline_api/README.rst b/src/ol_openedx_course_outline_api/README.rst index 4e6809fd..c4d1ed84 100644 --- a/src/ol_openedx_course_outline_api/README.rst +++ b/src/ol_openedx_course_outline_api/README.rst @@ -1,16 +1,8 @@ -================================ Course Outline API Plugin -================================ +========================= -**Plan (what can be done this way):** One public endpoint returns per-module (chapter) -title, effort time, and counts. We **do not have** a module summary/description (Open edX -has no such field). For **time**: we can use the platform’s effort only for **videos** (and -readings); there is **no mechanism** to count time for assignments. Counts come from the -Blocks API: **videos** = ``video`` blocks, **readings** = ``html`` blocks, **assignments** = -graded sequentials (count), **app_items** = other leaf blocks (not video/html/problem). -Visibility and effort follow platform settings; no new storage. - -**Endpoint:** ``GET /api/course-outline/v0/{course_id}/`` +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 ------------ @@ -22,86 +14,54 @@ Installation required in: * LMS -The plugin must be **installed** in the LMS so it is in ``INSTALLED_APPS`` and its URLs are registered. - -- **Tutor:** Add the package to your Tutor config so it is installed in the LMS container (e.g. ``OPENEDX_PLUGINS`` or a custom requirements file, then ``tutor images build openedx`` and restart). If you mount the repo, run ``pip install -e /path/to/open-edx-plugins/src/ol_openedx_course_outline_api`` inside the LMS container. -- **Standalone:** ``pip install ol-openedx-course-outline-api`` (or install from the repo). Ensure the LMS ``INSTALLED_APPS`` includes the plugin (auto-added when installed via the ``lms.djangoapp`` entry point). - ----------------------------------------------------------------------------- -Troubleshooting: Page not found (404) ----------------------------------------------------------------------------- - -- **Plugin not installed:** If the plugin is not installed in the LMS, no URL pattern is registered and you get a 404. Confirm the package is installed (e.g. ``pip list | grep course-outline``) and that the app is loaded (e.g. in Django shell: ``from django.conf import settings; "ol_openedx_course_outline_api" in settings.INSTALLED_APPS``). -- **Course ID in the URL:** Course keys contain ``+`` (e.g. ``course-v1:OpenedX+DemoX+DemoCourse``). In URLs, ``+`` can be interpreted as a space. Use **URL-encoded** form: ``course-v1:OpenedX%2BDemoX%2BDemoCourse``. Example: ``GET /api/course-outline/v0/course-v1:OpenedX%2BDemoX%2BDemoCourse/``. - ----------------------------------------------------------------------------- -What does the API return? ----------------------------------------------------------------------------- - -We do **not** have a module summary or description (Open edX does not expose that for -chapters). The response is one JSON object per request with: - -- **course_id** – Course key (e.g. ``course-v1:Org+Course+Run``). -- **generated_at** – ISO timestamp when the outline was built. -- **modules** – List of modules (one per Open edX **chapter**), each with: - - **id** – Chapter block usage key. - - **title** – Chapter display name. - - **effort_time** – Total estimated time for the chapter (seconds) as returned by the Blocks API. - - **effort_activities** – Number of activities that the effort system counted when computing ``effort_time``. - - **counts** – Object with **videos**, **readings**, **assignments**, **app_items** (integers). - -The front end can format ``effort_time`` (e.g. hours/minutes) and combine title -and counts into a line like "Module 1: Introduction — 5 videos, 3 readings, 1 assignment, 2 activities" (time only reflects video + reading; no assignment time). - ----------------------------------------------------------------------------- -How is estimated time (effort_time) obtained? ----------------------------------------------------------------------------- - -**Where it comes from:** We use the Open edX **EffortEstimationTransformer** (Blocks API). -We **can** count time for **videos** (duration from video pipeline/edxval); the platform -also estimates **reading** time from HTML word count. It does **not** provide time for -assignments or other block types. At the chapter level, ``effort_time`` is -the platform’s aggregate of video + reading time only. There is no fallback; if the -platform returns 0, we return 0. - -**How to get non-zero values:** See the section *How to get non-zero effort_time?* below (publish, video durations, waffle flag). - -**What about time for assignments?** There is **no mechanism** in Open edX to set or -derive time for assignments/subsections. We only have **effort_time** (video + -reading). **counts.assignments** is the number of graded sequentials, not a duration. - ----------------------------------------------------------------------------- -How are the content counts (mappings) done? ----------------------------------------------------------------------------- - -We call the **Blocks API** (``get_blocks``) with ``block_counts=["video", "html", "problem"]`` -and use the block tree under each chapter. Mappings: - -- **videos** – Blocks with type ``video`` under the chapter (Blocks API ``block_counts.video``). -- **readings** – Blocks with type ``html`` under the chapter (``block_counts.html``). -- **assignments** – Count of sequentials that are graded or have an assignment type. We treat a sequential as an assignment if ``graded == True`` **or** if it has a non-empty **format** (e.g. Homework, Lab, Midterm Exam, Final Exam). The Blocks API can return ``graded: false`` even when the subsection is linked to an assignment in Studio; we request ``format`` and use it as a fallback so those still count. -- **app_items** – **Leaf** blocks (no children) whose type is not ``video``, ``html``, or ``problem``, and not a container (``course``, ``chapter``, ``sequential``, ``vertical``)—e.g. custom XBlocks, drag-and-drop. -- **asktim** – Number of video/problem blocks under the chapter that have **Enable AI Chat Assistant** on (ol_openedx_chat aside). 0 if the plugin is not installed. - ----------------------------------------------------------------------------- -How to get non-zero effort_time? ----------------------------------------------------------------------------- - -The platform only fills ``effort_time`` when the EffortEstimationTransformer runs and has enough data. Do the following. - -**1. Re-publish the course** - -Block structure (and effort) is updated on course publish. In **Studio**, open the course and use **Publish**. After the background task completes (often within a minute), chapter-level ``effort_time`` is available. Re-publish again after changing content or fixing video durations. - -**2. Ensure every video has a duration** - -The transformer uses the video pipeline (e.g. edxval). If *any* course video has missing or zero duration, the transformer disables estimation for the **entire** course. Use **Video Uploads** in Studio (or your pipeline) so every video has duration metadata; fix or re-process any video that does not. - -**3. Leave effort estimation enabled for the course** - -The course-level waffle flag ``effort_estimation.disabled`` must be **off** for the course. - -- **Django Admin:** **Waffle Utils > Waffle flag course overrides**. If there is an override for ``effort_estimation.disabled`` and your course, set **Override choice** to **Force Off**. If there is no override, estimation is enabled by default. -- Do not force the flag **On** for this course, or ``effort_time`` will stay 0. - -When all three are done, the API will return non-zero ``effort_time`` when the Blocks API provides them. +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) + +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/utils.py b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/utils.py index 1719a42d..77317663 100644 --- 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 @@ -20,14 +20,22 @@ def is_visible_to_staff_only(block): return False -def get_descendant_ids(blocks_data, block_id): +def iter_descendant_ids(blocks_data, root_id): """ - Return set of all descendant block ids (including block_id) from blocks_data. + Yield all descendant block ids (including root_id) from blocks_data. + + Implemented iteratively to avoid recursion depth issues on very large courses. """ - result = {block_id} - for child_id in blocks_data.get(block_id, {}).get("children") or []: - result.update(get_descendant_ids(blocks_data, child_id)) - return result + seen = set() + stack = [root_id] + while stack: + block_id = stack.pop() + if block_id in seen: + continue + seen.add(block_id) + yield block_id + children = blocks_data.get(block_id, {}).get("children") or [] + stack.extend(children) def is_graded_sequential(block): @@ -53,7 +61,7 @@ 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 get_descendant_ids(blocks_data, chapter_id): + 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 @@ -67,7 +75,7 @@ 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 get_descendant_ids(blocks_data, chapter_id): + 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 @@ -81,7 +89,7 @@ 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 get_descendant_ids(blocks_data, chapter_id): + 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 @@ -111,14 +119,11 @@ def build_modules_from_blocks(blocks_data, root_id): continue counts = { - # Match Blocks API-style block_counts keys: - # html = readings, problem = problems, video = videos, others = everything else. - "html": count_blocks_by_type_under_chapter(blocks_data, child_id, "html"), - "problem": count_blocks_by_type_under_chapter( - blocks_data, child_id, "problem" - ), - "video": count_blocks_by_type_under_chapter(blocks_data, child_id, "video"), - "others": count_app_items_under_chapter(blocks_data, child_id), + "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, 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 index 2ac88cc2..724e46c6 100644 --- 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 @@ -2,7 +2,7 @@ Views for the public Course Outline API (Learn product page modules). """ -from datetime import UTC +from datetime import datetime, timezone as dt_tz from django.core.cache import cache from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication @@ -10,13 +10,8 @@ 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 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 @@ -55,8 +50,6 @@ class CourseOutlineView(DeveloperErrorViewMixin, GenericAPIView): @verify_course_exists() def get(self, request, course_id): - from datetime import datetime - try: course_key = CourseKey.from_string(course_id) except InvalidKeyError: @@ -72,14 +65,9 @@ def get(self, request, course_id): status_code=status.HTTP_404_NOT_FOUND, developer_message="Course not found", ) - - # Per-course cache: only when response is not user-specific (no gating). cache_key = f"{COURSE_OUTLINE_CACHE_KEY_PREFIX}{course_key}" cached = cache.get(cache_key) if cached is not None: - cached["generated_at"] = datetime.now(UTC).strftime( - "%Y-%m-%dT%H:%M:%SZ" - ) return Response(cached, status=status.HTTP_200_OK) requested_fields = [ @@ -90,6 +78,7 @@ def get(self, request, course_id): "format", # assignment type (Homework, etc.); used as fallback when graded is False "visible_to_staff_only", EffortEstimationTransformer.EFFORT_TIME, + EffortEstimationTransformer.EFFORT_ACTIVITIES, ] blocks_response = get_blocks( @@ -106,11 +95,10 @@ def get(self, request, course_id): response_data = { "course_id": str(course_key), - "generated_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + "generated_at": datetime.now(dt_tz.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "modules": modules, } - cache_key = f"{COURSE_OUTLINE_CACHE_KEY_PREFIX}{course_key}" cache.set( cache_key, dict(response_data), diff --git a/src/ol_openedx_course_outline_api/pyproject.toml b/src/ol_openedx_course_outline_api/pyproject.toml index 34d9de34..40d16dbe 100644 --- a/src/ol_openedx_course_outline_api/pyproject.toml +++ b/src/ol_openedx_course_outline_api/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ol-openedx-course-outline-api" version = "0.1.0" -description = "An Open edX plugin providing a public API for course outline (modules summary) for product and marketing pages" +description = "An Open edX plugin to add API for course outline (modules) for the Learn product page" authors = [ {name = "MIT Office of Digital Learning"} ] @@ -33,5 +33,6 @@ include = [ include = [ "ol_openedx_course_outline_api/**/*", "README.rst", + "CHANGELOG.rst", "pyproject.toml", ] From 4f6b9f1b50e70a3bb9fb8e195cc3023f99d88e3d Mon Sep 17 00:00:00 2001 From: zamanafzal Date: Tue, 17 Mar 2026 21:22:46 +0500 Subject: [PATCH 06/10] feat: added course_outline_block api Made-with: Cursor --- .../CHANGELOG.rst | 12 ++++++++ .../ol_openedx_course_outline_api/utils.py | 11 ++++--- .../ol_openedx_course_outline_api/views.py | 30 ++++++++++++------- 3 files changed, 36 insertions(+), 17 deletions(-) create mode 100644 src/ol_openedx_course_outline_api/CHANGELOG.rst 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/ol_openedx_course_outline_api/utils.py b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/utils.py index 77317663..cbe5d0e1 100644 --- 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 @@ -14,10 +14,7 @@ def is_visible_to_staff_only(block): """ Return True if the block is staff-only, based on any known visibility key. """ - for key in VISIBLE_TO_STAFF_ONLY_KEYS: - if block.get(key) is True: - return True - return False + return any(block.get(key) is True for key in VISIBLE_TO_STAFF_ONLY_KEYS) def iter_descendant_ids(blocks_data, root_id): @@ -72,7 +69,8 @@ def count_blocks_by_type_under_chapter(blocks_data, chapter_id, block_type): def count_assignments_under_chapter(blocks_data, chapter_id): """ - Count sequential blocks that are graded or have an assignment format (excludes staff-only). + 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): @@ -86,7 +84,8 @@ def count_assignments_under_chapter(blocks_data, chapter_id): 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 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): 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 index 724e46c6..8b66f550 100644 --- 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 @@ -2,7 +2,7 @@ Views for the public Course Outline API (Learn product page modules). """ -from datetime import datetime, timezone as dt_tz +from datetime import UTC, datetime from django.core.cache import cache from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication @@ -10,8 +10,13 @@ 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 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 @@ -33,11 +38,13 @@ class CourseOutlineView(DeveloperErrorViewMixin, GenericAPIView): 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). + 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. + 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"] @@ -52,11 +59,11 @@ class CourseOutlineView(DeveloperErrorViewMixin, GenericAPIView): def get(self, request, course_id): try: course_key = CourseKey.from_string(course_id) - except InvalidKeyError: + 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) @@ -75,7 +82,7 @@ def get(self, request, course_id): "type", "display_name", "graded", - "format", # assignment type (Homework, etc.); used as fallback when graded is False + "format", # assignment type (Homework, etc.); fallback if graded is False "visible_to_staff_only", EffortEstimationTransformer.EFFORT_TIME, EffortEstimationTransformer.EFFORT_ACTIVITIES, @@ -85,7 +92,8 @@ def get(self, request, course_id): request, course.location, user=None, - depth=None, # full tree so units and their video/problem/html blocks are included + # Full tree so units and their video/problem/html blocks are included. + depth=None, requested_fields=requested_fields, ) @@ -95,7 +103,7 @@ def get(self, request, course_id): response_data = { "course_id": str(course_key), - "generated_at": datetime.now(dt_tz.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "generated_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), "modules": modules, } From 8a32111ca68f4b1a04c564c43d5444f635c79c88 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:26:46 +0000 Subject: [PATCH 07/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../ol_openedx_course_outline_api/utils.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 index cbe5d0e1..84465969 100644 --- 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 @@ -118,9 +118,15 @@ def build_modules_from_blocks(blocks_data, root_id): 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"), + "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), } From 1b4bb9949727150817c1450795480ca46c9baa59 Mon Sep 17 00:00:00 2001 From: zamanafzal Date: Wed, 18 Mar 2026 14:26:10 +0500 Subject: [PATCH 08/10] feat: added toc flag and updated cache mechanism --- src/ol_openedx_course_outline_api/README.rst | 12 +++++++++++ .../constants.py | 12 +++++++++-- .../ol_openedx_course_outline_api/utils.py | 21 ++++++++++++++++++- .../ol_openedx_course_outline_api/views.py | 13 +++++++++++- 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/ol_openedx_course_outline_api/README.rst b/src/ol_openedx_course_outline_api/README.rst index c4d1ed84..f80d4bcf 100644 --- a/src/ol_openedx_course_outline_api/README.rst +++ b/src/ol_openedx_course_outline_api/README.rst @@ -59,6 +59,18 @@ Notes - ``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 --------------- 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 index 1505b3b8..f792acd0 100644 --- 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 @@ -15,6 +15,14 @@ # 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") -# Per-course response cache (used only when include_gating is False; key = course_id). +# 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 = 300 # 5 minutes +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/utils.py b/src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/utils.py index 84465969..05e41881 100644 --- 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 @@ -17,6 +17,13 @@ def is_visible_to_staff_only(block): 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. @@ -31,7 +38,11 @@ def iter_descendant_ids(blocks_data, root_id): continue seen.add(block_id) yield block_id - children = blocks_data.get(block_id, {}).get("children") or [] + block = blocks_data.get(block_id, {}) or {} + if is_hidden_from_toc(block): + # If a block is hidden from the TOC, don't count it or any of its descendants. + continue + children = block.get("children") or [] stack.extend(children) @@ -62,6 +73,8 @@ def count_blocks_by_type_under_chapter(blocks_data, chapter_id, block_type): 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 @@ -77,6 +90,8 @@ def count_assignments_under_chapter(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 @@ -92,6 +107,8 @@ def count_app_items_under_chapter(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 @@ -116,6 +133,8 @@ def build_modules_from_blocks(blocks_data, root_id): continue if is_visible_to_staff_only(block): continue + if is_hidden_from_toc(block): + continue counts = { "videos": count_blocks_by_type_under_chapter( 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 index 8b66f550..2515c6f9 100644 --- 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 @@ -27,6 +27,7 @@ from ol_openedx_course_outline_api.constants import ( COURSE_OUTLINE_CACHE_KEY_PREFIX, COURSE_OUTLINE_CACHE_TIMEOUT_SECONDS, + COURSE_OUTLINE_CACHE_SCHEMA_VERSION, ) from ol_openedx_course_outline_api.utils import build_modules_from_blocks @@ -72,7 +73,16 @@ def get(self, request, course_id): status_code=status.HTTP_404_NOT_FOUND, developer_message="Course not found", ) - cache_key = f"{COURSE_OUTLINE_CACHE_KEY_PREFIX}{course_key}" + # 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) @@ -83,6 +93,7 @@ def get(self, request, course_id): "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, From 553ef9cc94c63b65e072c2c526f9825532aeeaac Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:26:34 +0000 Subject: [PATCH 09/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../ol_openedx_course_outline_api/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 2515c6f9..a3eab603 100644 --- 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 @@ -26,8 +26,8 @@ from ol_openedx_course_outline_api.constants import ( COURSE_OUTLINE_CACHE_KEY_PREFIX, - COURSE_OUTLINE_CACHE_TIMEOUT_SECONDS, COURSE_OUTLINE_CACHE_SCHEMA_VERSION, + COURSE_OUTLINE_CACHE_TIMEOUT_SECONDS, ) from ol_openedx_course_outline_api.utils import build_modules_from_blocks From 2bdd42561a823ee22cebae2968ff1b5827d00ddf Mon Sep 17 00:00:00 2001 From: zamanafzal Date: Wed, 18 Mar 2026 14:40:45 +0500 Subject: [PATCH 10/10] feat: added toc flag and updated cache mechanism --- .../ol_openedx_course_outline_api/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 05e41881..6fd557e0 100644 --- 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 @@ -40,7 +40,7 @@ def iter_descendant_ids(blocks_data, root_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, don't count it or any of its descendants. + # If a block is hidden from the TOC, exclude it and its descendants. continue children = block.get("children") or [] stack.extend(children)