From f2c5b7749b3f05ab0a1be9f7181b1d43374ea639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 7 Oct 2026 16:26:09 -0300 Subject: [PATCH 1/7] feat: add learner pathway APIs --- README.rst | 5 +- lms/djangoapps/learner_home/PATHWAYS_API.rst | 37 +++ .../learner_home/rest_api/pathways.py | 225 ++++++++++++++++ lms/djangoapps/learner_home/rest_api/urls.py | 8 +- .../learner_home/tests/test_pathways_api.py | 241 ++++++++++++++++++ pyproject.toml | 5 + requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- uv.lock | 13 +- 9 files changed, 524 insertions(+), 14 deletions(-) create mode 100644 lms/djangoapps/learner_home/PATHWAYS_API.rst create mode 100644 lms/djangoapps/learner_home/rest_api/pathways.py create mode 100644 lms/djangoapps/learner_home/tests/test_pathways_api.py diff --git a/README.rst b/README.rst index 380ffa700237..d0a9dfde4520 100644 --- a/README.rst +++ b/README.rst @@ -199,13 +199,16 @@ This will give you a mostly-headless Open edX platform. Most frontends have been migrated to "Micro-Frontends (MFEs)" which need to be installed and run separately. At a bare minimum, you will need to run the `Authoring MFE`_, `Learner Home MFE`_, and `Learning MFE`_ in order meaningfully navigate the UI. -A full list of the MFEs expected to run by default are listed below. +A full list of the MFEs expected to run by default are listed below. The +learner pathways endpoints used by the Learner Home MFE are documented in the +`Learner Pathways REST API`_. .. _Tutor: https://github.com/overhangio/tutor .. _Site Ops home on docs.openedx.org: https://docs.openedx.org/en/latest/site_ops/index.html .. _development mode: https://docs.tutor.edly.io/dev.html .. _building static assets: ./docs/references/static-assets.rst .. _Learner Home MFE: https://github.com/openedx/frontend-app-learner-dashboard +.. _Learner Pathways REST API: ./lms/djangoapps/learner_home/PATHWAYS_API.rst .. _Learning MFE: https://github.com/openedx/frontend-app-learning/ .. _Authoring MFE: https://github.com/openedx/frontend-app-authoring/ .. _Using the development.py settings: ./docs/how-tos/using_development_settings.rst diff --git a/lms/djangoapps/learner_home/PATHWAYS_API.rst b/lms/djangoapps/learner_home/PATHWAYS_API.rst new file mode 100644 index 000000000000..d7ecb86ba393 --- /dev/null +++ b/lms/djangoapps/learner_home/PATHWAYS_API.rst @@ -0,0 +1,37 @@ +Learner Pathways REST API +========================= + +The learner-home API exposes authenticated, read-only Pathway data for the +Learner Home MFE. The catalog/content split follows +`openedx-core ADR 0007 `_. + +List the current learner's active, published pathways grouped by category:: + + GET /api/learner_home/v1/pathways/ + +Find those pathways for one or more course runs:: + + GET /api/learner_home/v1/pathways/by_course/?course_ids=course-v1%3Aorg%2Bcourse%2Brun&course_ids=... + +``course_ids`` is a repeatable query parameter containing serialized Open edX +CourseKeys. The response is a mapping from each requested CourseKey to the +learner's enrolled pathways that contain it. Requested courses without matching +pathways map to an empty list. An omitted ``course_ids`` parameter returns an +empty mapping. + +Both endpoints use the authenticated learner by default. Staff may pass the +learner-home ``user`` query parameter to use the existing learner masquerade +behavior. Results include active Pathway enrollments only, and omit pathways +without a published content definition. + +Pathway progress counts a published Pathway Item complete when the learner has +a passing grade in any CourseRun that fulfills that Item. ``courseCount`` is +the number of published Items, and ``completedCourseCount`` is the number of +completed Items. + +.. note:: + + The current openedx-core category API exposes a localized singular name, + not a plural label. Until a plural field is agreed upstream, the + ``categoryLabelPlural`` response field contains that localized singular + name. Category colors are not currently provided. diff --git a/lms/djangoapps/learner_home/rest_api/pathways.py b/lms/djangoapps/learner_home/rest_api/pathways.py new file mode 100644 index 000000000000..5046fd20fee2 --- /dev/null +++ b/lms/djangoapps/learner_home/rest_api/pathways.py @@ -0,0 +1,225 @@ +"""Learner-facing REST APIs for Pathways.""" + +from collections import OrderedDict + +from django.utils.translation import gettext as _ +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from edx_rest_framework_extensions.permissions import NotJwtRestrictedApplication +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from openedx_catalog import api as catalog_api +from openedx_learning import api as learning_api +from rest_framework import generics, serializers +from rest_framework.exceptions import ValidationError +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from common.djangoapps.student.helpers import user_has_passing_grade_in_course +from common.djangoapps.student.models.course_enrollment import CourseEnrollment +from lms.djangoapps.learner_home.utils import get_masquerade_user +from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser + + +class PathwayContentSerializer(serializers.Serializer): + """The learner-facing display name expected by the Learner Home MFE.""" + + displayName = serializers.CharField() + + +class PathwaySerializer(serializers.Serializer): + """Catalog and published content fields for one Pathway.""" + + id = serializers.CharField() + content = PathwayContentSerializer() + courseCount = serializers.IntegerField() + category = serializers.CharField() + categoryLabel = serializers.CharField() + + +class PathwayProgressSerializer(serializers.Serializer): + completedCourseCount = serializers.IntegerField() + + +class PathwayProviderSerializer(serializers.Serializer): + name = serializers.CharField() + + +class LearnerPathwaySerializer(serializers.Serializer): + pathway = PathwaySerializer() + progress = PathwayProgressSerializer() + provider = PathwayProviderSerializer(required=False, allow_null=True) + + +class PathwayCategoryGroupSerializer(serializers.Serializer): + categoryLabelPlural = serializers.CharField() + pathways = LearnerPathwaySerializer(many=True) + + +class PathwaysByCourseSerializer(serializers.BaseSerializer): + """Mapping from serialized CourseKeys to pathways containing the course run.""" + + def to_representation(self, instance): + # Keep CourseKeys as dynamic object keys while validating each pathway against the shared response schema. + return { + course_id: LearnerPathwaySerializer(pathways, many=True).data for course_id, pathways in instance.items() + } + + +def _get_learner_pathway_records(user): + """Return display-ready pathway data and each pathway's fulfilling course IDs. + + Only active catalog enrollments with a currently published content definition are included. + The Pathway content/catalog split is intentional: catalog metadata is used for the learner-facing title, category, + and provider, while the published content definition supplies the ordered Items and fulfillment CourseRuns. + """ + pathway_sources = [] + course_keys = set() + + for enrollment in catalog_api.get_pathway_enrollments(user.id): + catalog_pathway = enrollment.catalog_pathway + pathway = learning_api.get_pathway_for_catalog_pathway(catalog_pathway) + if pathway is None: + continue + + published_version = pathway.versioning.published + if published_version is None: + continue + + item_course_keys = [] + for item_entry in learning_api.get_items_in_pathway(pathway, published=True): + fulfilling_keys = { + str(run_entry.course_run.course_key) + for run_entry in learning_api.get_course_runs_for_item(item_entry.pathway_item, published=True) + } + item_course_keys.append(fulfilling_keys) + course_keys.update(fulfilling_keys) + + category = catalog_pathway.category + pathway_sources.append((catalog_pathway, category, item_course_keys)) + + # A learner can retain a passing grade after unenrolling from an individual course, so intentionally include + # inactive CourseEnrollment rows when calculating pathway progress. + passing_course_keys = set() + if course_keys: + course_enrollments = CourseEnrollment.objects.filter( + user=user, + course_id__in=course_keys, + ).select_related("course") + passing_course_keys = { + str(course_enrollment.course_id) + for course_enrollment in course_enrollments + if user_has_passing_grade_in_course(course_enrollment) + } + + records = [] + for catalog_pathway, category, item_course_keys in pathway_sources: + category_label = str(category.localized_name) + pathway_data = { + "pathway": { + # Never expose the internal database primary key. key_str is the public catalog identifier currently + # provided by openedx-core; replace it if that API adopts an OpaqueKey representation. + "id": catalog_pathway.key_str, + "content": {"displayName": str(catalog_pathway.title)}, + "courseCount": len(item_course_keys), + "category": category.category_code, + "categoryLabel": category_label, + }, + "progress": { + "completedCourseCount": sum( + bool(item_keys.intersection(passing_course_keys)) for item_keys in item_course_keys + ), + }, + "provider": {"name": str(catalog_pathway.org.name)}, + } + records.append( + { + "data": pathway_data, + "course_ids": set.union(*item_course_keys) if item_course_keys else set(), + "category_code": category.category_code, + "category_label": category_label, + } + ) + + return records + + +def _group_pathways_by_category(records): + """Serialize a learner's enrolled pathways grouped in enrollment order by category.""" + categories = OrderedDict() + for record in records: + category_code = record["category_code"] + if category_code not in categories: + # openedx-core currently models/translates only the singular category name. Keep the frontend's plural + # field populated with that localized title until the Core API provides an explicit plural form. + categories[category_code] = { + "categoryLabelPlural": record["category_label"], + "pathways": [], + } + categories[category_code]["pathways"].append(record["data"]) + return list(categories.values()) + + +def _get_requested_course_ids(request): + """Parse the repeated ``course_ids`` query parameter as canonical CourseKeys.""" + course_ids = request.query_params.getlist("course_ids") + try: + return list(dict.fromkeys(str(CourseKey.from_string(course_id)) for course_id in course_ids)) + except InvalidKeyError as error: + raise ValidationError({"course_ids": _("Each course_ids value must be a valid course key.")}) from error + + +def _pathways_by_course(records, course_ids): + """Return pathway data keyed by every requested course ID (including courses with no pathways).""" + return { + course_id: [record["data"] for record in records if course_id in record["course_ids"]] + for course_id in course_ids + } + + +class LearnerPathwaysView(generics.GenericAPIView): + """List the authenticated learner's active pathways, grouped by category. + + GET ``/api/learner_home/v1/pathways/`` + + Staff may pass ``user=`` to use learner-home's existing masquerade behavior. + """ + + authentication_classes = ( + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ) + permission_classes = (IsAuthenticated, NotJwtRestrictedApplication) + serializer_class = PathwayCategoryGroupSerializer + + def get(self, request): + user = get_masquerade_user(request) or request.user + data = _group_pathways_by_category(_get_learner_pathway_records(user)) + return Response(self.get_serializer(data, many=True).data) + + +class LearnerPathwaysByCourseView(generics.GenericAPIView): + """List enrolled pathways that contain each requested course run. + + GET ``/api/learner_home/v1/pathways/by_course/?course_ids=`` + + ``course_ids`` may be repeated. Staff may pass ``user=`` to use learner-home's existing + masquerade behavior. + """ + + authentication_classes = ( + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ) + permission_classes = (IsAuthenticated, NotJwtRestrictedApplication) + serializer_class = PathwaysByCourseSerializer + + def get(self, request): + user = get_masquerade_user(request) or request.user + course_ids = _get_requested_course_ids(request) + if not course_ids: + return Response(self.get_serializer({}).data) + records = _get_learner_pathway_records(user) + return Response(self.get_serializer(_pathways_by_course(records, course_ids)).data) diff --git a/lms/djangoapps/learner_home/rest_api/urls.py b/lms/djangoapps/learner_home/rest_api/urls.py index 647ac84fa486..a920f929113a 100644 --- a/lms/djangoapps/learner_home/rest_api/urls.py +++ b/lms/djangoapps/learner_home/rest_api/urls.py @@ -1,11 +1,13 @@ -""" -Programs API URLs. -""" +"""Learner Home REST API URLs.""" from django.urls import include, path from openedx.core.djangoapps.programs.rest_api.v1 import urls as v1_programs_rest_api_urls +from .pathways import LearnerPathwaysByCourseView, LearnerPathwaysView + urlpatterns = [ + path("v1/pathways/", LearnerPathwaysView.as_view(), name="pathways"), + path("v1/pathways/by_course/", LearnerPathwaysByCourseView.as_view(), name="pathways_by_course"), path("v1/", include((v1_programs_rest_api_urls, "v1"))), ] diff --git a/lms/djangoapps/learner_home/tests/test_pathways_api.py b/lms/djangoapps/learner_home/tests/test_pathways_api.py new file mode 100644 index 000000000000..e40c157df7aa --- /dev/null +++ b/lms/djangoapps/learner_home/tests/test_pathways_api.py @@ -0,0 +1,241 @@ +"""Tests for learner-facing Pathway REST APIs.""" + +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import patch + +from django.test import TestCase +from django.urls import resolve +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locator import CourseLocator +from openedx_catalog import api as catalog_api +from openedx_catalog.models_api import CatalogCourse, CourseRun +from openedx_content.applets.publishing import api as publishing_api +from openedx_learning import api as learning_api +from organizations.api import ensure_organization +from rest_framework.test import APIRequestFactory, force_authenticate + +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from lms.djangoapps.learner_home.rest_api.pathways import ( + LearnerPathwaysByCourseView, + LearnerPathwaysView, + _get_learner_pathway_records, + _group_pathways_by_category, +) + + +class LearnerPathwaysApiTest(TestCase): + """Tests for pathway listing and course-to-pathway mapping.""" + + def setUp(self): + self.user = UserFactory() + self.request_factory = APIRequestFactory() + + def test_rest_api_urls_register_pathway_endpoints(self): + urlconf = "lms.djangoapps.learner_home.rest_api.urls" + list_match = resolve("/v1/pathways/", urlconf=urlconf) + by_course_match = resolve("/v1/pathways/by_course/", urlconf=urlconf) + + assert list_match.func.view_class is LearnerPathwaysView + assert by_course_match.func.view_class is LearnerPathwaysByCourseView + + def test_list_groups_enrollments_by_category(self): + def pathway_data(identifier): + return { + "pathway": { + "id": identifier, + "content": {"displayName": identifier}, + "courseCount": 1, + "category": "certificate", + "categoryLabel": "Certificate", + }, + "progress": {"completedCourseCount": 0}, + "provider": {"name": "Open edX"}, + } + + records = [ + {"category_code": "certificate", "category_label": "Certificate", "data": pathway_data("one")}, + {"category_code": "certificate", "category_label": "Certificate", "data": pathway_data("two")}, + {"category_code": "bootcamp", "category_label": "Bootcamp", "data": pathway_data("three")}, + ] + request = self.request_factory.get("/api/learner_home/v1/pathways/") + force_authenticate(request, user=self.user) + + with patch( + "lms.djangoapps.learner_home.rest_api.pathways._get_learner_pathway_records", + return_value=records, + ): + response = LearnerPathwaysView.as_view()(request) + + assert response.status_code == 200 + assert response.data == [ + { + "categoryLabelPlural": "Certificate", + "pathways": [pathway_data("one"), pathway_data("two")], + }, + {"categoryLabelPlural": "Bootcamp", "pathways": [pathway_data("three")]}, + ] + + def test_pathways_by_course_returns_empty_values_for_unmatched_courses(self): + course_with_pathway = "course-v1:openedx+test+2026" + course_without_pathway = "course-v1:openedx+other+2026" + pathway_data = { + "pathway": { + "id": "catalog-pathway:openedx:program", + "content": {"displayName": "My Program"}, + "courseCount": 1, + "category": "certificate", + "categoryLabel": "Certificate", + }, + "progress": {"completedCourseCount": 0}, + "provider": {"name": "Open edX"}, + } + records = [{"course_ids": {course_with_pathway}, "data": pathway_data}] + request = self.request_factory.get( + "/api/learner_home/v1/pathways/by_course/", + {"course_ids": [course_with_pathway, course_without_pathway]}, + ) + force_authenticate(request, user=self.user) + + with patch( + "lms.djangoapps.learner_home.rest_api.pathways._get_learner_pathway_records", + return_value=records, + ): + response = LearnerPathwaysByCourseView.as_view()(request) + + assert response.status_code == 200 + assert response.data == { + course_with_pathway: [pathway_data], + course_without_pathway: [], + } + + def test_pathways_by_course_rejects_invalid_course_keys(self): + request = self.request_factory.get( + "/api/learner_home/v1/pathways/by_course/", + {"course_ids": "not-a-course-key"}, + ) + force_authenticate(request, user=self.user) + + response = LearnerPathwaysByCourseView.as_view()(request) + + assert response.status_code == 400 + assert "course_ids" in response.data + + def test_pathways_by_course_without_course_ids_returns_empty_map(self): + request = self.request_factory.get("/api/learner_home/v1/pathways/by_course/") + force_authenticate(request, user=self.user) + + with patch("lms.djangoapps.learner_home.rest_api.pathways._get_learner_pathway_records") as get_records: + response = LearnerPathwaysByCourseView.as_view()(request) + + assert response.status_code == 200 + assert response.data == {} + get_records.assert_not_called() + + def test_record_payload_uses_published_items_and_passing_course_enrollments(self): + category = SimpleNamespace(category_code="certificate", localized_name="Certificate") + catalog_pathway = SimpleNamespace( + category=category, + key_str="catalog-pathway:openedx:program", + title="My Program", + org=SimpleNamespace(name="Open edX"), + ) + pathway = SimpleNamespace(versioning=SimpleNamespace(published=SimpleNamespace(title="Authoring title"))) + first_item = SimpleNamespace(pathway_item="item-1") + second_item = SimpleNamespace(pathway_item="item-2") + course_one = CourseKey.from_string("course-v1:openedx+one+2026") + course_two = CourseKey.from_string("course-v1:openedx+two+2026") + course_three = CourseKey.from_string("course-v1:openedx+three+2026") + run_entry = lambda course_key: SimpleNamespace(course_run=SimpleNamespace(course_key=course_key)) + grade_enrollments = [ + SimpleNamespace(course_id=course_one), + SimpleNamespace(course_id=course_two), + SimpleNamespace(course_id=course_three), + ] + + with ( + patch.object( + catalog_api, + "get_pathway_enrollments", + create=True, + return_value=[ + SimpleNamespace(catalog_pathway=catalog_pathway), + ], + ), + patch.object(learning_api, "get_pathway_for_catalog_pathway", create=True, return_value=pathway), + patch.object( + learning_api, + "get_items_in_pathway", + create=True, + return_value=[first_item, second_item], + ), + patch.object( + learning_api, + "get_course_runs_for_item", + create=True, + side_effect=[[run_entry(course_one), run_entry(course_two)], [run_entry(course_three)]], + ), + patch( + "lms.djangoapps.learner_home.rest_api.pathways.CourseEnrollment.objects.filter", + return_value=SimpleNamespace(select_related=lambda _field: grade_enrollments), + ), + patch( + "lms.djangoapps.learner_home.rest_api.pathways.user_has_passing_grade_in_course", + side_effect=[False, True, True], + ), + ): + records = _get_learner_pathway_records(self.user) + + assert len(records) == 1 + assert records[0]["data"]["pathway"]["content"]["displayName"] == "My Program" + assert records[0]["data"]["pathway"]["courseCount"] == 2 + assert records[0]["data"]["progress"]["completedCourseCount"] == 2 + assert records[0]["course_ids"] == {str(course_one), str(course_two), str(course_three)} + + def test_record_payload_uses_core_catalog_and_published_pathway_apis(self): + ensure_organization("PathwaysApiTest") + catalog_pathway = catalog_api.create_catalog_pathway( + org_code="PathwaysApiTest", + pathway_code="DataScience", + title="Data Science Certificate", + ) + created = datetime(2026, 10, 7, tzinfo=UTC) + pathway, _version = learning_api.create_pathway_and_version( + catalog_pathway=catalog_pathway, + title="Authoring title", + created=created, + ) + catalog_course = CatalogCourse.objects.create( + org_code="PathwaysApiTest", + course_code="DataScience", + ) + course_run = CourseRun.objects.create( + catalog_course=catalog_course, + run_code="2026", + course_key=CourseLocator(org="PathwaysApiTest", course="DataScience", run="2026"), + ) + item, _item_version = learning_api.create_pathway_item_and_version( + pathway, + "first-item", + title="First item", + course_runs=[course_run], + created=created, + ) + learning_api.create_next_pathway_version(pathway, items=[item], created=created) + publishing_api.publish_all_drafts(pathway.learning_package_id, published_at=created) + catalog_api.enroll_in_pathway(self.user.id, catalog_pathway) + CourseEnrollmentFactory(user=self.user, course_id=course_run.course_key) + + with patch( + "lms.djangoapps.learner_home.rest_api.pathways.user_has_passing_grade_in_course", + return_value=True, + ): + records = _get_learner_pathway_records(self.user) + + assert len(records) == 1 + assert records[0]["data"]["pathway"]["id"] == catalog_pathway.key_str + assert records[0]["data"]["pathway"]["content"]["displayName"] == "Data Science Certificate" + assert records[0]["data"]["pathway"]["courseCount"] == 1 + assert records[0]["data"]["progress"]["completedCourseCount"] == 1 + assert records[0]["course_ids"] == {str(course_run.course_key)} + assert _group_pathways_by_category(records)[0]["categoryLabelPlural"] == "Pathway" diff --git a/pyproject.toml b/pyproject.toml index fe63770a79f1..713d7c7858d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -415,6 +415,11 @@ uv_constraints = [ "djangorestframework<3.18", ] +[tool.uv.sources] +# Pathway catalog/content models and APIs used by the learner-home REST endpoints. +# This commit is the head of openedx-core#820 and includes openedx-core#819. +openedx-core = { git = "https://github.com/open-craft/openedx-core.git", rev = "acbb129f466860bfcd1698b8e713f0618819d4f7" } + [tool.setuptools] packages = ["cms", "common", "lms", "openedx", "xmodule"] diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index c7120f2e0aeb..d68f784eb001 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -828,7 +828,7 @@ openedx-calc==5.0.0 # via # openedx-platform # xblocks-contrib -openedx-core==1.4.0 +openedx-core @ git+https://github.com/open-craft/openedx-core.git@acbb129f466860bfcd1698b8e713f0618819d4f7 # via openedx-platform openedx-django-pyfs==4.0.0 # via xblock diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 38a9cb396df3..67f5eb014aed 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -930,7 +930,7 @@ openedx-calc==5.0.0 # via # openedx-platform # xblocks-contrib -openedx-core==1.4.0 +openedx-core @ git+https://github.com/open-craft/openedx-core.git@acbb129f466860bfcd1698b8e713f0618819d4f7 # via openedx-platform openedx-django-pyfs==4.0.0 # via xblock diff --git a/uv.lock b/uv.lock index 5499e82f361a..ac61d62b6da0 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14'", @@ -25,7 +25,7 @@ constraints = [ { name = "libsass", specifier = "==0.10.0" }, { name = "lxml", specifier = "==5.3.2" }, { name = "numpy", specifier = "<2.0.0" }, - { name = "openedx-core", specifier = "<2" }, + { name = "openedx-core", git = "https://github.com/open-craft/openedx-core.git?rev=acbb129f466860bfcd1698b8e713f0618819d4f7" }, { name = "path", specifier = "<16.12.0" }, { name = "pip", specifier = "<26.2.1" }, { name = "pymongo", specifier = "<4.4.1" }, @@ -4391,7 +4391,7 @@ wheels = [ [[package]] name = "openedx-core" version = "1.4.0" -source = { registry = "https://pypi.org/simple" } +source = { git = "https://github.com/open-craft/openedx-core.git?rev=acbb129f466860bfcd1698b8e713f0618819d4f7#acbb129f466860bfcd1698b8e713f0618819d4f7" } dependencies = [ { name = "attrs" }, { name = "celery" }, @@ -4404,10 +4404,6 @@ dependencies = [ { name = "rules" }, { name = "tomlkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4d/3e/ed8e012f309cea40ed2e95bf62e24437d61b78b985a653bad77eba28793e/openedx_core-1.4.0.tar.gz", hash = "sha256:b14a6dba9daf641d5b59cc6d96352c60f56960e620d57a48978762bfdb43db71", size = 235679, upload-time = "2026-09-11T18:52:40.51Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/53/51c7f89d7ce719aacf4f75d8a4b2dbda4cb7502f243606e25ea8daf5597e/openedx_core-1.4.0-py2.py3-none-any.whl", hash = "sha256:972471eb6f80250ce25f049fa56d6629f5c2a22a9c9382863c9d876462561183", size = 321965, upload-time = "2026-09-11T18:52:38.934Z" }, -] [[package]] name = "openedx-django-pyfs" @@ -4511,6 +4507,7 @@ wheels = [ name = "openedx-platform" version = "0.13" source = { editable = "." } +default-groups = [] dependencies = [ { name = "acid-xblock" }, { name = "analytics-python" }, @@ -5013,7 +5010,7 @@ requires-dist = [ { name = "openedx-atlas" }, { name = "openedx-authz" }, { name = "openedx-calc" }, - { name = "openedx-core" }, + { name = "openedx-core", git = "https://github.com/open-craft/openedx-core.git?rev=acbb129f466860bfcd1698b8e713f0618819d4f7" }, { name = "openedx-django-require" }, { name = "openedx-django-wiki" }, { name = "openedx-events" }, From a3d4d3b66e23fc6d43519de832d694fee3e20fbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 7 Oct 2026 17:16:28 -0300 Subject: [PATCH 2/7] test: remove pathway API tests --- .../learner_home/tests/test_pathways_api.py | 241 ------------------ 1 file changed, 241 deletions(-) delete mode 100644 lms/djangoapps/learner_home/tests/test_pathways_api.py diff --git a/lms/djangoapps/learner_home/tests/test_pathways_api.py b/lms/djangoapps/learner_home/tests/test_pathways_api.py deleted file mode 100644 index e40c157df7aa..000000000000 --- a/lms/djangoapps/learner_home/tests/test_pathways_api.py +++ /dev/null @@ -1,241 +0,0 @@ -"""Tests for learner-facing Pathway REST APIs.""" - -from datetime import UTC, datetime -from types import SimpleNamespace -from unittest.mock import patch - -from django.test import TestCase -from django.urls import resolve -from opaque_keys.edx.keys import CourseKey -from opaque_keys.edx.locator import CourseLocator -from openedx_catalog import api as catalog_api -from openedx_catalog.models_api import CatalogCourse, CourseRun -from openedx_content.applets.publishing import api as publishing_api -from openedx_learning import api as learning_api -from organizations.api import ensure_organization -from rest_framework.test import APIRequestFactory, force_authenticate - -from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory -from lms.djangoapps.learner_home.rest_api.pathways import ( - LearnerPathwaysByCourseView, - LearnerPathwaysView, - _get_learner_pathway_records, - _group_pathways_by_category, -) - - -class LearnerPathwaysApiTest(TestCase): - """Tests for pathway listing and course-to-pathway mapping.""" - - def setUp(self): - self.user = UserFactory() - self.request_factory = APIRequestFactory() - - def test_rest_api_urls_register_pathway_endpoints(self): - urlconf = "lms.djangoapps.learner_home.rest_api.urls" - list_match = resolve("/v1/pathways/", urlconf=urlconf) - by_course_match = resolve("/v1/pathways/by_course/", urlconf=urlconf) - - assert list_match.func.view_class is LearnerPathwaysView - assert by_course_match.func.view_class is LearnerPathwaysByCourseView - - def test_list_groups_enrollments_by_category(self): - def pathway_data(identifier): - return { - "pathway": { - "id": identifier, - "content": {"displayName": identifier}, - "courseCount": 1, - "category": "certificate", - "categoryLabel": "Certificate", - }, - "progress": {"completedCourseCount": 0}, - "provider": {"name": "Open edX"}, - } - - records = [ - {"category_code": "certificate", "category_label": "Certificate", "data": pathway_data("one")}, - {"category_code": "certificate", "category_label": "Certificate", "data": pathway_data("two")}, - {"category_code": "bootcamp", "category_label": "Bootcamp", "data": pathway_data("three")}, - ] - request = self.request_factory.get("/api/learner_home/v1/pathways/") - force_authenticate(request, user=self.user) - - with patch( - "lms.djangoapps.learner_home.rest_api.pathways._get_learner_pathway_records", - return_value=records, - ): - response = LearnerPathwaysView.as_view()(request) - - assert response.status_code == 200 - assert response.data == [ - { - "categoryLabelPlural": "Certificate", - "pathways": [pathway_data("one"), pathway_data("two")], - }, - {"categoryLabelPlural": "Bootcamp", "pathways": [pathway_data("three")]}, - ] - - def test_pathways_by_course_returns_empty_values_for_unmatched_courses(self): - course_with_pathway = "course-v1:openedx+test+2026" - course_without_pathway = "course-v1:openedx+other+2026" - pathway_data = { - "pathway": { - "id": "catalog-pathway:openedx:program", - "content": {"displayName": "My Program"}, - "courseCount": 1, - "category": "certificate", - "categoryLabel": "Certificate", - }, - "progress": {"completedCourseCount": 0}, - "provider": {"name": "Open edX"}, - } - records = [{"course_ids": {course_with_pathway}, "data": pathway_data}] - request = self.request_factory.get( - "/api/learner_home/v1/pathways/by_course/", - {"course_ids": [course_with_pathway, course_without_pathway]}, - ) - force_authenticate(request, user=self.user) - - with patch( - "lms.djangoapps.learner_home.rest_api.pathways._get_learner_pathway_records", - return_value=records, - ): - response = LearnerPathwaysByCourseView.as_view()(request) - - assert response.status_code == 200 - assert response.data == { - course_with_pathway: [pathway_data], - course_without_pathway: [], - } - - def test_pathways_by_course_rejects_invalid_course_keys(self): - request = self.request_factory.get( - "/api/learner_home/v1/pathways/by_course/", - {"course_ids": "not-a-course-key"}, - ) - force_authenticate(request, user=self.user) - - response = LearnerPathwaysByCourseView.as_view()(request) - - assert response.status_code == 400 - assert "course_ids" in response.data - - def test_pathways_by_course_without_course_ids_returns_empty_map(self): - request = self.request_factory.get("/api/learner_home/v1/pathways/by_course/") - force_authenticate(request, user=self.user) - - with patch("lms.djangoapps.learner_home.rest_api.pathways._get_learner_pathway_records") as get_records: - response = LearnerPathwaysByCourseView.as_view()(request) - - assert response.status_code == 200 - assert response.data == {} - get_records.assert_not_called() - - def test_record_payload_uses_published_items_and_passing_course_enrollments(self): - category = SimpleNamespace(category_code="certificate", localized_name="Certificate") - catalog_pathway = SimpleNamespace( - category=category, - key_str="catalog-pathway:openedx:program", - title="My Program", - org=SimpleNamespace(name="Open edX"), - ) - pathway = SimpleNamespace(versioning=SimpleNamespace(published=SimpleNamespace(title="Authoring title"))) - first_item = SimpleNamespace(pathway_item="item-1") - second_item = SimpleNamespace(pathway_item="item-2") - course_one = CourseKey.from_string("course-v1:openedx+one+2026") - course_two = CourseKey.from_string("course-v1:openedx+two+2026") - course_three = CourseKey.from_string("course-v1:openedx+three+2026") - run_entry = lambda course_key: SimpleNamespace(course_run=SimpleNamespace(course_key=course_key)) - grade_enrollments = [ - SimpleNamespace(course_id=course_one), - SimpleNamespace(course_id=course_two), - SimpleNamespace(course_id=course_three), - ] - - with ( - patch.object( - catalog_api, - "get_pathway_enrollments", - create=True, - return_value=[ - SimpleNamespace(catalog_pathway=catalog_pathway), - ], - ), - patch.object(learning_api, "get_pathway_for_catalog_pathway", create=True, return_value=pathway), - patch.object( - learning_api, - "get_items_in_pathway", - create=True, - return_value=[first_item, second_item], - ), - patch.object( - learning_api, - "get_course_runs_for_item", - create=True, - side_effect=[[run_entry(course_one), run_entry(course_two)], [run_entry(course_three)]], - ), - patch( - "lms.djangoapps.learner_home.rest_api.pathways.CourseEnrollment.objects.filter", - return_value=SimpleNamespace(select_related=lambda _field: grade_enrollments), - ), - patch( - "lms.djangoapps.learner_home.rest_api.pathways.user_has_passing_grade_in_course", - side_effect=[False, True, True], - ), - ): - records = _get_learner_pathway_records(self.user) - - assert len(records) == 1 - assert records[0]["data"]["pathway"]["content"]["displayName"] == "My Program" - assert records[0]["data"]["pathway"]["courseCount"] == 2 - assert records[0]["data"]["progress"]["completedCourseCount"] == 2 - assert records[0]["course_ids"] == {str(course_one), str(course_two), str(course_three)} - - def test_record_payload_uses_core_catalog_and_published_pathway_apis(self): - ensure_organization("PathwaysApiTest") - catalog_pathway = catalog_api.create_catalog_pathway( - org_code="PathwaysApiTest", - pathway_code="DataScience", - title="Data Science Certificate", - ) - created = datetime(2026, 10, 7, tzinfo=UTC) - pathway, _version = learning_api.create_pathway_and_version( - catalog_pathway=catalog_pathway, - title="Authoring title", - created=created, - ) - catalog_course = CatalogCourse.objects.create( - org_code="PathwaysApiTest", - course_code="DataScience", - ) - course_run = CourseRun.objects.create( - catalog_course=catalog_course, - run_code="2026", - course_key=CourseLocator(org="PathwaysApiTest", course="DataScience", run="2026"), - ) - item, _item_version = learning_api.create_pathway_item_and_version( - pathway, - "first-item", - title="First item", - course_runs=[course_run], - created=created, - ) - learning_api.create_next_pathway_version(pathway, items=[item], created=created) - publishing_api.publish_all_drafts(pathway.learning_package_id, published_at=created) - catalog_api.enroll_in_pathway(self.user.id, catalog_pathway) - CourseEnrollmentFactory(user=self.user, course_id=course_run.course_key) - - with patch( - "lms.djangoapps.learner_home.rest_api.pathways.user_has_passing_grade_in_course", - return_value=True, - ): - records = _get_learner_pathway_records(self.user) - - assert len(records) == 1 - assert records[0]["data"]["pathway"]["id"] == catalog_pathway.key_str - assert records[0]["data"]["pathway"]["content"]["displayName"] == "Data Science Certificate" - assert records[0]["data"]["pathway"]["courseCount"] == 1 - assert records[0]["data"]["progress"]["completedCourseCount"] == 1 - assert records[0]["course_ids"] == {str(course_run.course_key)} - assert _group_pathways_by_category(records)[0]["categoryLabelPlural"] == "Pathway" From 8cb680aa4f6f97f92ddac3d1c7c3a2df623c0513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 7 Oct 2026 17:55:09 -0300 Subject: [PATCH 3/7] docs: remove pathway API docs --- README.rst | 5 +-- lms/djangoapps/learner_home/PATHWAYS_API.rst | 37 -------------------- 2 files changed, 1 insertion(+), 41 deletions(-) delete mode 100644 lms/djangoapps/learner_home/PATHWAYS_API.rst diff --git a/README.rst b/README.rst index d0a9dfde4520..380ffa700237 100644 --- a/README.rst +++ b/README.rst @@ -199,16 +199,13 @@ This will give you a mostly-headless Open edX platform. Most frontends have been migrated to "Micro-Frontends (MFEs)" which need to be installed and run separately. At a bare minimum, you will need to run the `Authoring MFE`_, `Learner Home MFE`_, and `Learning MFE`_ in order meaningfully navigate the UI. -A full list of the MFEs expected to run by default are listed below. The -learner pathways endpoints used by the Learner Home MFE are documented in the -`Learner Pathways REST API`_. +A full list of the MFEs expected to run by default are listed below. .. _Tutor: https://github.com/overhangio/tutor .. _Site Ops home on docs.openedx.org: https://docs.openedx.org/en/latest/site_ops/index.html .. _development mode: https://docs.tutor.edly.io/dev.html .. _building static assets: ./docs/references/static-assets.rst .. _Learner Home MFE: https://github.com/openedx/frontend-app-learner-dashboard -.. _Learner Pathways REST API: ./lms/djangoapps/learner_home/PATHWAYS_API.rst .. _Learning MFE: https://github.com/openedx/frontend-app-learning/ .. _Authoring MFE: https://github.com/openedx/frontend-app-authoring/ .. _Using the development.py settings: ./docs/how-tos/using_development_settings.rst diff --git a/lms/djangoapps/learner_home/PATHWAYS_API.rst b/lms/djangoapps/learner_home/PATHWAYS_API.rst deleted file mode 100644 index d7ecb86ba393..000000000000 --- a/lms/djangoapps/learner_home/PATHWAYS_API.rst +++ /dev/null @@ -1,37 +0,0 @@ -Learner Pathways REST API -========================= - -The learner-home API exposes authenticated, read-only Pathway data for the -Learner Home MFE. The catalog/content split follows -`openedx-core ADR 0007 `_. - -List the current learner's active, published pathways grouped by category:: - - GET /api/learner_home/v1/pathways/ - -Find those pathways for one or more course runs:: - - GET /api/learner_home/v1/pathways/by_course/?course_ids=course-v1%3Aorg%2Bcourse%2Brun&course_ids=... - -``course_ids`` is a repeatable query parameter containing serialized Open edX -CourseKeys. The response is a mapping from each requested CourseKey to the -learner's enrolled pathways that contain it. Requested courses without matching -pathways map to an empty list. An omitted ``course_ids`` parameter returns an -empty mapping. - -Both endpoints use the authenticated learner by default. Staff may pass the -learner-home ``user`` query parameter to use the existing learner masquerade -behavior. Results include active Pathway enrollments only, and omit pathways -without a published content definition. - -Pathway progress counts a published Pathway Item complete when the learner has -a passing grade in any CourseRun that fulfills that Item. ``courseCount`` is -the number of published Items, and ``completedCourseCount`` is the number of -completed Items. - -.. note:: - - The current openedx-core category API exposes a localized singular name, - not a plural label. Until a plural field is agreed upstream, the - ``categoryLabelPlural`` response field contains that localized singular - name. Category colors are not currently provided. From 4f4ee786b6d21e7e7ac992e505b47bba641f7a28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 7 Oct 2026 18:21:25 -0300 Subject: [PATCH 4/7] refactor: source pathway progress from enrollment --- .../learner_home/rest_api/pathways.py | 50 ++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/lms/djangoapps/learner_home/rest_api/pathways.py b/lms/djangoapps/learner_home/rest_api/pathways.py index 5046fd20fee2..fa8bccc9c3c6 100644 --- a/lms/djangoapps/learner_home/rest_api/pathways.py +++ b/lms/djangoapps/learner_home/rest_api/pathways.py @@ -74,7 +74,7 @@ def _get_learner_pathway_records(user): and provider, while the published content definition supplies the ordered Items and fulfillment CourseRuns. """ pathway_sources = [] - course_keys = set() + course_keys_needing_grade_lookup = set() for enrollment in catalog_api.get_pathway_enrollments(user.id): catalog_pathway = enrollment.catalog_pathway @@ -92,19 +92,24 @@ def _get_learner_pathway_records(user): str(run_entry.course_run.course_key) for run_entry in learning_api.get_course_runs_for_item(item_entry.pathway_item, published=True) } - item_course_keys.append(fulfilling_keys) - course_keys.update(fulfilling_keys) + item_course_keys.append((str(item_entry.pathway_item.id), fulfilling_keys)) category = catalog_pathway.category - pathway_sources.append((catalog_pathway, category, item_course_keys)) + completed_item_ids = _get_enrollment_completed_item_ids(enrollment) + if completed_item_ids is None: + course_keys_needing_grade_lookup.update( + course_key for _item_id, course_keys in item_course_keys for course_key in course_keys + ) + pathway_sources.append((catalog_pathway, category, item_course_keys, completed_item_ids)) # A learner can retain a passing grade after unenrolling from an individual course, so intentionally include - # inactive CourseEnrollment rows when calculating pathway progress. + # inactive CourseEnrollment rows when calculating fallback progress. Once Core exposes the provisional + # ``PathwayEnrollment.step_completions`` relation, that enrollment-owned state takes precedence. passing_course_keys = set() - if course_keys: + if course_keys_needing_grade_lookup: course_enrollments = CourseEnrollment.objects.filter( user=user, - course_id__in=course_keys, + course_id__in=course_keys_needing_grade_lookup, ).select_related("course") passing_course_keys = { str(course_enrollment.course_id) @@ -113,8 +118,15 @@ def _get_learner_pathway_records(user): } records = [] - for catalog_pathway, category, item_course_keys in pathway_sources: + for catalog_pathway, category, item_course_keys, completed_item_ids in pathway_sources: category_label = str(category.localized_name) + if completed_item_ids is None: + completed_course_count = sum( + bool(course_keys.intersection(passing_course_keys)) for _item_id, course_keys in item_course_keys + ) + else: + completed_course_count = sum(item_id in completed_item_ids for item_id, _course_keys in item_course_keys) + pathway_data = { "pathway": { # Never expose the internal database primary key. key_str is the public catalog identifier currently @@ -126,16 +138,14 @@ def _get_learner_pathway_records(user): "categoryLabel": category_label, }, "progress": { - "completedCourseCount": sum( - bool(item_keys.intersection(passing_course_keys)) for item_keys in item_course_keys - ), + "completedCourseCount": completed_course_count, }, "provider": {"name": str(catalog_pathway.org.name)}, } records.append( { "data": pathway_data, - "course_ids": set.union(*item_course_keys) if item_course_keys else set(), + "course_ids": {course_key for _item_id, course_keys in item_course_keys for course_key in course_keys}, "category_code": category.category_code, "category_label": category_label, } @@ -144,6 +154,22 @@ def _get_learner_pathway_records(user): return records +def _get_enrollment_completed_item_ids(enrollment): + """Return completed Pathway Item IDs recorded for this enrollment, when supported by Core. + + Provisional Core contract for discussion: ``PathwayEnrollment.step_completions`` is a related manager whose rows + expose ``pathway_item_id`` and ``is_complete``. The pinned Core PR #820 doesn't include this relation yet, so return + ``None`` and preserve grade-derived progress until that model/API lands. + """ + step_completions = getattr(enrollment, "step_completions", None) + if step_completions is None: + return None + return { + str(pathway_item_id) + for pathway_item_id in step_completions.filter(is_complete=True).values_list("pathway_item_id", flat=True) + } + + def _group_pathways_by_category(records): """Serialize a learner's enrolled pathways grouped in enrollment order by category.""" categories = OrderedDict() From ad8982af9a26aa72fd7de27d43d1b57cd3620b86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 7 Oct 2026 18:24:25 -0300 Subject: [PATCH 5/7] chore: remove core dependency pins --- pyproject.toml | 5 ----- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- uv.lock | 13 ++++++++----- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 713d7c7858d4..fe63770a79f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -415,11 +415,6 @@ uv_constraints = [ "djangorestframework<3.18", ] -[tool.uv.sources] -# Pathway catalog/content models and APIs used by the learner-home REST endpoints. -# This commit is the head of openedx-core#820 and includes openedx-core#819. -openedx-core = { git = "https://github.com/open-craft/openedx-core.git", rev = "acbb129f466860bfcd1698b8e713f0618819d4f7" } - [tool.setuptools] packages = ["cms", "common", "lms", "openedx", "xmodule"] diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index d68f784eb001..c7120f2e0aeb 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -828,7 +828,7 @@ openedx-calc==5.0.0 # via # openedx-platform # xblocks-contrib -openedx-core @ git+https://github.com/open-craft/openedx-core.git@acbb129f466860bfcd1698b8e713f0618819d4f7 +openedx-core==1.4.0 # via openedx-platform openedx-django-pyfs==4.0.0 # via xblock diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 67f5eb014aed..38a9cb396df3 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -930,7 +930,7 @@ openedx-calc==5.0.0 # via # openedx-platform # xblocks-contrib -openedx-core @ git+https://github.com/open-craft/openedx-core.git@acbb129f466860bfcd1698b8e713f0618819d4f7 +openedx-core==1.4.0 # via openedx-platform openedx-django-pyfs==4.0.0 # via xblock diff --git a/uv.lock b/uv.lock index ac61d62b6da0..5499e82f361a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14'", @@ -25,7 +25,7 @@ constraints = [ { name = "libsass", specifier = "==0.10.0" }, { name = "lxml", specifier = "==5.3.2" }, { name = "numpy", specifier = "<2.0.0" }, - { name = "openedx-core", git = "https://github.com/open-craft/openedx-core.git?rev=acbb129f466860bfcd1698b8e713f0618819d4f7" }, + { name = "openedx-core", specifier = "<2" }, { name = "path", specifier = "<16.12.0" }, { name = "pip", specifier = "<26.2.1" }, { name = "pymongo", specifier = "<4.4.1" }, @@ -4391,7 +4391,7 @@ wheels = [ [[package]] name = "openedx-core" version = "1.4.0" -source = { git = "https://github.com/open-craft/openedx-core.git?rev=acbb129f466860bfcd1698b8e713f0618819d4f7#acbb129f466860bfcd1698b8e713f0618819d4f7" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "celery" }, @@ -4404,6 +4404,10 @@ dependencies = [ { name = "rules" }, { name = "tomlkit" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/4d/3e/ed8e012f309cea40ed2e95bf62e24437d61b78b985a653bad77eba28793e/openedx_core-1.4.0.tar.gz", hash = "sha256:b14a6dba9daf641d5b59cc6d96352c60f56960e620d57a48978762bfdb43db71", size = 235679, upload-time = "2026-09-11T18:52:40.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/53/51c7f89d7ce719aacf4f75d8a4b2dbda4cb7502f243606e25ea8daf5597e/openedx_core-1.4.0-py2.py3-none-any.whl", hash = "sha256:972471eb6f80250ce25f049fa56d6629f5c2a22a9c9382863c9d876462561183", size = 321965, upload-time = "2026-09-11T18:52:38.934Z" }, +] [[package]] name = "openedx-django-pyfs" @@ -4507,7 +4511,6 @@ wheels = [ name = "openedx-platform" version = "0.13" source = { editable = "." } -default-groups = [] dependencies = [ { name = "acid-xblock" }, { name = "analytics-python" }, @@ -5010,7 +5013,7 @@ requires-dist = [ { name = "openedx-atlas" }, { name = "openedx-authz" }, { name = "openedx-calc" }, - { name = "openedx-core", git = "https://github.com/open-craft/openedx-core.git?rev=acbb129f466860bfcd1698b8e713f0618819d4f7" }, + { name = "openedx-core" }, { name = "openedx-django-require" }, { name = "openedx-django-wiki" }, { name = "openedx-events" }, From 58c72e7d6dfbd5291c059d8132d991fc5e618b28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 7 Oct 2026 18:35:32 -0300 Subject: [PATCH 6/7] refactor: use enrollment-owned pathway counts --- .../learner_home/rest_api/pathways.py | 104 ++++++------------ 1 file changed, 33 insertions(+), 71 deletions(-) diff --git a/lms/djangoapps/learner_home/rest_api/pathways.py b/lms/djangoapps/learner_home/rest_api/pathways.py index fa8bccc9c3c6..b53d555723a7 100644 --- a/lms/djangoapps/learner_home/rest_api/pathways.py +++ b/lms/djangoapps/learner_home/rest_api/pathways.py @@ -9,14 +9,13 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from openedx_catalog import api as catalog_api +from openedx_catalog.models_api import CourseRun from openedx_learning import api as learning_api from rest_framework import generics, serializers from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from common.djangoapps.student.helpers import user_has_passing_grade_in_course -from common.djangoapps.student.models.course_enrollment import CourseEnrollment from lms.djangoapps.learner_home.utils import get_masquerade_user from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser @@ -67,14 +66,15 @@ def to_representation(self, instance): def _get_learner_pathway_records(user): - """Return display-ready pathway data and each pathway's fulfilling course IDs. + """Return display-ready data for the learner's active Pathway enrollments. - Only active catalog enrollments with a currently published content definition are included. - The Pathway content/catalog split is intentional: catalog metadata is used for the learner-facing title, category, - and provider, while the published content definition supplies the ordered Items and fulfillment CourseRuns. + Enrollment-owned item counts provide progress. The Content API is used only to check that the catalog pathway has a + published definition; course-to-pathway membership is resolved separately through Content's published definitions. + + This design assumes the planned ``PathwayEnrollment.total_item_count`` and + ``PathwayEnrollment.completed_item_count`` fields, which are not in the current openedx-core#820 revision. """ - pathway_sources = [] - course_keys_needing_grade_lookup = set() + records = [] for enrollment in catalog_api.get_pathway_enrollments(user.id): catalog_pathway = enrollment.catalog_pathway @@ -86,66 +86,27 @@ def _get_learner_pathway_records(user): if published_version is None: continue - item_course_keys = [] - for item_entry in learning_api.get_items_in_pathway(pathway, published=True): - fulfilling_keys = { - str(run_entry.course_run.course_key) - for run_entry in learning_api.get_course_runs_for_item(item_entry.pathway_item, published=True) - } - item_course_keys.append((str(item_entry.pathway_item.id), fulfilling_keys)) - category = catalog_pathway.category - completed_item_ids = _get_enrollment_completed_item_ids(enrollment) - if completed_item_ids is None: - course_keys_needing_grade_lookup.update( - course_key for _item_id, course_keys in item_course_keys for course_key in course_keys - ) - pathway_sources.append((catalog_pathway, category, item_course_keys, completed_item_ids)) - - # A learner can retain a passing grade after unenrolling from an individual course, so intentionally include - # inactive CourseEnrollment rows when calculating fallback progress. Once Core exposes the provisional - # ``PathwayEnrollment.step_completions`` relation, that enrollment-owned state takes precedence. - passing_course_keys = set() - if course_keys_needing_grade_lookup: - course_enrollments = CourseEnrollment.objects.filter( - user=user, - course_id__in=course_keys_needing_grade_lookup, - ).select_related("course") - passing_course_keys = { - str(course_enrollment.course_id) - for course_enrollment in course_enrollments - if user_has_passing_grade_in_course(course_enrollment) - } - - records = [] - for catalog_pathway, category, item_course_keys, completed_item_ids in pathway_sources: category_label = str(category.localized_name) - if completed_item_ids is None: - completed_course_count = sum( - bool(course_keys.intersection(passing_course_keys)) for _item_id, course_keys in item_course_keys - ) - else: - completed_course_count = sum(item_id in completed_item_ids for item_id, _course_keys in item_course_keys) - pathway_data = { "pathway": { # Never expose the internal database primary key. key_str is the public catalog identifier currently # provided by openedx-core; replace it if that API adopts an OpaqueKey representation. "id": catalog_pathway.key_str, "content": {"displayName": str(catalog_pathway.title)}, - "courseCount": len(item_course_keys), + "courseCount": enrollment.total_item_count, "category": category.category_code, "categoryLabel": category_label, }, "progress": { - "completedCourseCount": completed_course_count, + "completedCourseCount": enrollment.completed_item_count, }, "provider": {"name": str(catalog_pathway.org.name)}, } records.append( { "data": pathway_data, - "course_ids": {course_key for _item_id, course_keys in item_course_keys for course_key in course_keys}, + "catalog_pathway_id": catalog_pathway.id, "category_code": category.category_code, "category_label": category_label, } @@ -154,22 +115,6 @@ def _get_learner_pathway_records(user): return records -def _get_enrollment_completed_item_ids(enrollment): - """Return completed Pathway Item IDs recorded for this enrollment, when supported by Core. - - Provisional Core contract for discussion: ``PathwayEnrollment.step_completions`` is a related manager whose rows - expose ``pathway_item_id`` and ``is_complete``. The pinned Core PR #820 doesn't include this relation yet, so return - ``None`` and preserve grade-derived progress until that model/API lands. - """ - step_completions = getattr(enrollment, "step_completions", None) - if step_completions is None: - return None - return { - str(pathway_item_id) - for pathway_item_id in step_completions.filter(is_complete=True).values_list("pathway_item_id", flat=True) - } - - def _group_pathways_by_category(records): """Serialize a learner's enrolled pathways grouped in enrollment order by category.""" categories = OrderedDict() @@ -196,11 +141,28 @@ def _get_requested_course_ids(request): def _pathways_by_course(records, course_ids): - """Return pathway data keyed by every requested course ID (including courses with no pathways).""" - return { - course_id: [record["data"] for record in records if course_id in record["course_ids"]] - for course_id in course_ids - } + """Return enrolled pathways containing each requested CourseRun via the published Content definitions.""" + enrolled_pathways = {record["catalog_pathway_id"]: record["data"] for record in records} + pathways_by_course = {} + + for course_id in course_ids: + try: + course_run = catalog_api.get_course_run(CourseKey.from_string(course_id)) + except CourseRun.DoesNotExist: + pathways_by_course[course_id] = [] + continue + + pathway_ids = { + pathway.catalog_pathway_id + for pathway in learning_api.get_pathways_containing_course_run(course_run, published=True) + } + pathways_by_course[course_id] = [ + pathway_data + for catalog_pathway_id, pathway_data in enrolled_pathways.items() + if catalog_pathway_id in pathway_ids + ] + + return pathways_by_course class LearnerPathwaysView(generics.GenericAPIView): From a2beb0bbd57119502e63f098e996889f7ed270e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 7 Oct 2026 20:28:08 -0300 Subject: [PATCH 7/7] refactor: name the by_course parameter for course run keys --- .../learner_home/rest_api/pathways.py | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/lms/djangoapps/learner_home/rest_api/pathways.py b/lms/djangoapps/learner_home/rest_api/pathways.py index b53d555723a7..6875b39de602 100644 --- a/lms/djangoapps/learner_home/rest_api/pathways.py +++ b/lms/djangoapps/learner_home/rest_api/pathways.py @@ -56,12 +56,13 @@ class PathwayCategoryGroupSerializer(serializers.Serializer): class PathwaysByCourseSerializer(serializers.BaseSerializer): - """Mapping from serialized CourseKeys to pathways containing the course run.""" + """Mapping from each requested course run key to the pathways containing it.""" def to_representation(self, instance): - # Keep CourseKeys as dynamic object keys while validating each pathway against the shared response schema. + # Keep course run keys as dynamic object keys while validating each pathway against the shared schema. return { - course_id: LearnerPathwaySerializer(pathways, many=True).data for course_id, pathways in instance.items() + course_run_key: LearnerPathwaySerializer(pathways, many=True).data + for course_run_key, pathways in instance.items() } @@ -131,32 +132,39 @@ def _group_pathways_by_category(records): return list(categories.values()) -def _get_requested_course_ids(request): - """Parse the repeated ``course_ids`` query parameter as canonical CourseKeys.""" - course_ids = request.query_params.getlist("course_ids") +def _get_requested_course_run_keys(request): + """ + Parse the repeated ``course_run_keys`` query parameter as canonical CourseKeys. + + A Pathway contains course runs, not courses, so callers identify the runs by their own keys. Both MFEs that show + pathway membership happen to call this a "course id", but the value is always a specific run's key: learner home + serializes it from `CourseEnrollment.course_id`, and the learning MFE takes it from its own `/course/:courseId/` + route, which the LMS fills from a `CourseKey`. + """ + course_run_keys = request.query_params.getlist("course_run_keys") try: - return list(dict.fromkeys(str(CourseKey.from_string(course_id)) for course_id in course_ids)) + return list(dict.fromkeys(str(CourseKey.from_string(key)) for key in course_run_keys)) except InvalidKeyError as error: - raise ValidationError({"course_ids": _("Each course_ids value must be a valid course key.")}) from error + raise ValidationError({"course_run_keys": _("Each course_run_keys value must be a valid course key.")}) from error -def _pathways_by_course(records, course_ids): +def _pathways_by_course(records, course_run_keys): """Return enrolled pathways containing each requested CourseRun via the published Content definitions.""" enrolled_pathways = {record["catalog_pathway_id"]: record["data"] for record in records} pathways_by_course = {} - for course_id in course_ids: + for course_run_key in course_run_keys: try: - course_run = catalog_api.get_course_run(CourseKey.from_string(course_id)) + course_run = catalog_api.get_course_run(CourseKey.from_string(course_run_key)) except CourseRun.DoesNotExist: - pathways_by_course[course_id] = [] + pathways_by_course[course_run_key] = [] continue pathway_ids = { pathway.catalog_pathway_id for pathway in learning_api.get_pathways_containing_course_run(course_run, published=True) } - pathways_by_course[course_id] = [ + pathways_by_course[course_run_key] = [ pathway_data for catalog_pathway_id, pathway_data in enrolled_pathways.items() if catalog_pathway_id in pathway_ids @@ -190,9 +198,9 @@ def get(self, request): class LearnerPathwaysByCourseView(generics.GenericAPIView): """List enrolled pathways that contain each requested course run. - GET ``/api/learner_home/v1/pathways/by_course/?course_ids=`` + GET ``/api/learner_home/v1/pathways/by_course/?course_run_keys=`` - ``course_ids`` may be repeated. Staff may pass ``user=`` to use learner-home's existing + ``course_run_keys`` may be repeated. Staff may pass ``user=`` to use learner-home's existing masquerade behavior. """ @@ -206,8 +214,8 @@ class LearnerPathwaysByCourseView(generics.GenericAPIView): def get(self, request): user = get_masquerade_user(request) or request.user - course_ids = _get_requested_course_ids(request) - if not course_ids: + course_run_keys = _get_requested_course_run_keys(request) + if not course_run_keys: return Response(self.get_serializer({}).data) records = _get_learner_pathway_records(user) - return Response(self.get_serializer(_pathways_by_course(records, course_ids)).data) + return Response(self.get_serializer(_pathways_by_course(records, course_run_keys)).data)