From fa1d75b6aa37d2614072041c7ed6a0849d807cf3 Mon Sep 17 00:00:00 2001 From: Agrendalath Date: Mon, 14 Sep 2026 22:41:42 +0200 Subject: [PATCH] feat(pathways): implement CatalogPathway --- .importlinter | 15 +- src/openedx_catalog/admin.py | 91 +++++- src/openedx_catalog/api_impl.py | 214 ++++++++++++- .../migrations/0002_pathways.py | 286 ++++++++++++++++++ src/openedx_catalog/models/__init__.py | 3 + src/openedx_catalog/models/catalog_pathway.py | 182 +++++++++++ .../models/pathway_category.py | 99 ++++++ .../models/pathway_enrollment.py | 88 ++++++ src/openedx_catalog/models_api.py | 2 +- tests/openedx_catalog/test_pathway_api.py | 200 ++++++++++++ tests/openedx_catalog/test_pathway_models.py | 206 +++++++++++++ 11 files changed, 1379 insertions(+), 7 deletions(-) create mode 100644 src/openedx_catalog/migrations/0002_pathways.py create mode 100644 src/openedx_catalog/models/catalog_pathway.py create mode 100644 src/openedx_catalog/models/pathway_category.py create mode 100644 src/openedx_catalog/models/pathway_enrollment.py create mode 100644 tests/openedx_catalog/test_pathway_api.py create mode 100644 tests/openedx_catalog/test_pathway_models.py diff --git a/.importlinter b/.importlinter index 17dd176f6..02e191db2 100644 --- a/.importlinter +++ b/.importlinter @@ -8,6 +8,7 @@ root_packages = openedx_learning openedx_content openedx_tagging + openedx_catalog openedx_django_lib openedx_core @@ -18,14 +19,20 @@ root_packages = name = "top-level source folders are layered correctly" type = layers layers = - # Learning-domain features (currently CBE; Learning Pathways to follow). - # May build on content and tagging. Nothing below may import it: in - # particular, openedx_tagging must never know that CBE exists. + # Learning-domain features (CBE and Pathways). May build on content, catalog and + # tagging. Nothing below may import it: in particular, openedx_tagging must never + # know that CBE exists. openedx_learning - # Content: authoring-side models and APIs. + # Content: authoring-side models and APIs. May reference catalog entities, e.g. to record + # which content implements a given CourseRun. openedx_content + # Catalog: the enrollable things (CatalogCourse, CourseRun, CatalogPathway) that content and + # everything above it may point at. It stays unaware of content, so that changes in how + # content is represented never reach it. See the openedx_catalog ADR 0001, decision 2. + openedx_catalog + # Tagging is very simple & fundamental. Should probably not depend on any other Django apps. openedx_tagging diff --git a/src/openedx_catalog/admin.py b/src/openedx_catalog/admin.py index 2aa4f3ca6..64a8bb596 100644 --- a/src/openedx_catalog/admin.py +++ b/src/openedx_catalog/admin.py @@ -13,13 +13,16 @@ from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ -from .models import CatalogCourse, CourseRun +from .models import CatalogCourse, CatalogPathway, CourseRun, PathwayCategory, PathwayEnrollment if TYPE_CHECKING: class CatalogCourseWithRunCount(CatalogCourse): run_count: int + class PathwayCategoryWithPathwayCount(PathwayCategory): + pathway_count: int + class CatalogCourseAdmin(admin.ModelAdmin): """ @@ -110,3 +113,89 @@ def warnings(self, obj: CourseRun) -> str | None: admin.site.register(CourseRun, CourseRunAdmin) + + +class PathwayCategoryAdmin(admin.ModelAdmin): + """ + The PathwayCategory model admin. + + Renaming a category changes what learners see. It does not change the authoring-side terminology, which is always + "Pathway". + """ + + list_display = ["name", "category_code", "pathways_summary"] + search_fields = ["name", "category_code"] + + def get_readonly_fields(self, request, obj: PathwayCategory | None = None) -> tuple[str, ...]: + if obj: # editing an existing object; the code is what other systems key off + return ("category_code",) + return tuple() + + def get_queryset(self, request) -> QuerySet[PathwayCategoryWithPathwayCount]: + """Add the 'pathway_count' to the list_display queryset""" + qs = super().get_queryset(request) + qs = qs.annotate(pathway_count=Count("pathways")) + return qs + + @admin.display(description=_("Pathways"), ordering="pathway_count") + def pathways_summary(self, obj: PathwayCategoryWithPathwayCount) -> str: + """Link to the catalog pathways using this category""" + if obj.pathway_count == 0: + return "-" + url = reverse("admin:openedx_catalog_catalogpathway_changelist") + f"?category={obj.pk}" + return format_html('{}', url, obj.pathway_count) + + +admin.site.register(PathwayCategory, PathwayCategoryAdmin) + + +class CatalogPathwayAdmin(admin.ModelAdmin): + """ + The CatalogPathway model admin. + + This edits only the catalog half of a Pathway. The Items a learner must complete live on the content side, in the + openedx_learning app, and are versioned there. + """ + + list_filter = ["org__short_name", "category"] + list_display = ["title", "category", "org_display", "pathway_code", "key_str", "created_date", "modified"] + search_fields = ["title", "pathway_code"] + + def get_readonly_fields(self, request, obj: CatalogPathway | None = None) -> tuple[str, ...]: + if obj: # editing an existing object + return ("org", "pathway_code") + return tuple() + + @admin.display(description="Organization", ordering="org__short_name") + def org_display(self, obj: CatalogPathway) -> str: + """Display the organization, only showing the short_name if different from full name""" + if obj.org.name == obj.org.short_name: + return obj.org.short_name + return str(obj.org) + + @admin.display(description=_("Created"), ordering="created") + def created_date(self, obj: CatalogPathway) -> datetime.date: + """Display the created date without the timestamp""" + return obj.created.date() + + +admin.site.register(CatalogPathway, CatalogPathwayAdmin) + + +class PathwayEnrollmentAdmin(admin.ModelAdmin): + """ + The PathwayEnrollment model admin. + """ + + list_display = ["user", "catalog_pathway", "is_active", "created_date", "modified"] + list_filter = ["is_active", "catalog_pathway__category"] + # There may be very many users and a fair number of pathways, so don't use