-
Notifications
You must be signed in to change notification settings - Fork 6
feat: added ol_course_outline plugin #752
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
451b4b9
feat: added ol_course_outline plugin
zamanafzal a38ddc4
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 20d3792
feat: added course_outline_block api
zamanafzal 1775883
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] a2e09d9
feat: added course_outline_block api
zamanafzal 4f6b9f1
feat: added course_outline_block api
zamanafzal 8a32111
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 1b4bb99
feat: added toc flag and updated cache mechanism
zamanafzal 553ef9c
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 2bdd425
feat: added toc flag and updated cache mechanism
zamanafzal 6754436
fix: addressed feedback
zamanafzal 42074c7
fix: addressed comment for 1 week
zamanafzal df8e10f
fix: addressed comment for staff only
zamanafzal File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| 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/ol-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/ol-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**: configurable via ``OL_COURSE_OUTLINE_API_CACHE_TIMEOUT_SECONDS`` (default: 1 week). | ||
| - **Cache key**: ``<prefix>s<schema_version>:<course_key>:<content_version>``. | ||
| - ``prefix`` is configurable via ``OL_COURSE_OUTLINE_API_CACHE_KEY_PREFIX`` (default: ``ol_course_outline_api:outline:v0:``). | ||
| - ``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/ol-course-outline/v0/course-v1:OpenedX%2BDemoX%2BDemoCourse/``. |
3 changes: 3 additions & 0 deletions
3
src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| """ | ||
| ol_openedx_course_outline_api | ||
| """ |
34 changes: 34 additions & 0 deletions
34
src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/app.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/ol-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"}, | ||
| } | ||
| }, | ||
| } |
21 changes: 21 additions & 0 deletions
21
src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/constants.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| """ | ||
| Constants for the Course Outline API plugin. | ||
| """ | ||
|
|
||
| # Block type groupings used when summarizing course content. | ||
| CONTENT_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") | ||
|
|
||
| # 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 |
Empty file.
15 changes: 15 additions & 0 deletions
15
src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/settings/common.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| """Common settings unique to the course outline API plugin.""" | ||
|
|
||
|
|
||
| def plugin_settings(settings): | ||
| """Settings for the course outline API plugin.""" # noqa: D401 | ||
| settings.OL_COURSE_OUTLINE_API_CACHE_KEY_PREFIX = getattr( | ||
| settings, | ||
| "OL_COURSE_OUTLINE_API_CACHE_KEY_PREFIX", | ||
| "ol_course_outline_api:outline:v0:", | ||
| ) | ||
| settings.OL_COURSE_OUTLINE_API_CACHE_TIMEOUT_SECONDS = getattr( | ||
| settings, | ||
| "OL_COURSE_OUTLINE_API_CACHE_TIMEOUT_SECONDS", | ||
| 60 * 60 * 24 * 7, # 1 week | ||
| ) |
5 changes: 5 additions & 0 deletions
5
src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/settings/production.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
16 changes: 16 additions & 0 deletions
16
src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/urls.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| ), | ||
| ] |
164 changes: 164 additions & 0 deletions
164
src/ol_openedx_course_outline_api/ol_openedx_course_outline_api/utils.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| """ | ||
| Utility functions for working with Blocks API responses in the Course Outline API. | ||
| """ | ||
|
|
||
| from ol_openedx_course_outline_api.constants import ( | ||
| CONTENT_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. | ||
| Traversal is pruned for blocks hidden from TOC or visible to staff only. | ||
| """ | ||
| 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 | ||
| if is_visible_to_staff_only(block): | ||
| # If a block is staff-only, exclude its descendants from traversal. | ||
| 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 CONTENT_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 | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.