From 643644c1725f5550ee8053f0e53986b8846829b5 Mon Sep 17 00:00:00 2001 From: Agrendalath Date: Mon, 14 Sep 2026 22:41:42 +0200 Subject: [PATCH 1/2] feat(pathways): implement CatalogPathway --- .importlinter | 16 +- src/openedx_catalog/admin.py | 103 +++++- src/openedx_catalog/api_impl.py | 246 ++++++++++++++- .../migrations/0002_pathways.py | 298 ++++++++++++++++++ src/openedx_catalog/models/__init__.py | 3 + src/openedx_catalog/models/catalog_pathway.py | 199 ++++++++++++ .../models/pathway_category.py | 99 ++++++ .../models/pathway_enrollment.py | 88 ++++++ src/openedx_catalog/models_api.py | 2 +- tests/openedx_catalog/test_pathway_api.py | 230 ++++++++++++++ tests/openedx_catalog/test_pathway_models.py | 245 ++++++++++++++ 11 files changed, 1522 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..85bba4a0d 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,12 +19,19 @@ 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 catalog, content 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. + # Catalog holds the models learners browse and enroll against (CatalogCourse, CourseRun, + # CatalogPathway). A catalog entry points at its content, never the reverse, so catalog + # sits above content: it may hold (nullable) foreign keys to content, while content stays + # ignorant of what a learning package represents. See the openedx_catalog ADR 0001. + openedx_catalog + + # Content: authoring-side models and APIs. Generic infrastructure for courses, libraries, + # pathways and future context types alike. openedx_content # Tagging is very simple & fundamental. Should probably not depend on any other Django apps. diff --git a/src/openedx_catalog/admin.py b/src/openedx_catalog/admin.py index 2aa4f3ca6..6aeaedaa3 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,101 @@ 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", + "content_entity", + "created_date", + "modified", + ] + list_select_related = ["org", "category", "content_entity"] + search_fields = ["title", "pathway_code"] + + def get_readonly_fields(self, request, obj: CatalogPathway | None = None) -> tuple[str, ...]: + # The definition is linked through openedx_learning.api, which is the only place that can check that the entity + # really is a Pathway. Show it, but don't offer a + raw_id_fields = ["user", "catalog_pathway"] + + @admin.display(description=_("Enrolled"), ordering="created") + def created_date(self, obj: PathwayEnrollment) -> datetime.date: + """Display the enrollment date without the timestamp""" + return obj.created.date() + + +admin.site.register(PathwayEnrollment, PathwayEnrollmentAdmin) diff --git a/src/openedx_catalog/api_impl.py b/src/openedx_catalog/api_impl.py index 03c9a3ee7..3fd3f8c5a 100644 --- a/src/openedx_catalog/api_impl.py +++ b/src/openedx_catalog/api_impl.py @@ -5,11 +5,17 @@ import logging from typing import overload +from django.db import transaction +from django.db.models import QuerySet +from django.utils import timezone from opaque_keys.edx.keys import CourseKey from organizations.api import ensure_organization # type: ignore[import] from organizations.api import exceptions as org_exceptions -from .models import CatalogCourse, CourseRun +from openedx_content.models_api import PublishableEntity + +from .models import CatalogCourse, CatalogPathway, CourseRun, PathwayCategory, PathwayEnrollment +from .models.pathway_category import get_default_pathway_category log = logging.getLogger(__name__) @@ -22,6 +28,17 @@ "sync_course_run_details", "create_course_run_for_modulestore_course_with", "delete_course_run", + "get_default_pathway_category", + "get_pathway_category", + "get_catalog_pathway", + "create_catalog_pathway", + "update_catalog_pathway", + "set_catalog_pathway_content", + "delete_catalog_pathway", + "enroll_in_pathway", + "unenroll_from_pathway", + "is_enrolled_in_pathway", + "get_pathway_enrollments", ] @@ -249,3 +266,230 @@ def delete_course_run(course_key: CourseKey) -> None: ⚠️ Does not emit any course lifecycle events. """ CourseRun.objects.get(course_key=course_key).delete() + + +# Pathways (catalog side). +# +# A Pathway is split into a catalog half (these models) and a versioned content half in +# `openedx_learning.applets.pathways`. See the openedx_learning ADR 0007. The functions below only touch the catalog +# half; creating and versioning the *definition* of a Pathway is done through `openedx_learning.api`, which also links +# the definition to its `CatalogPathway` via `set_catalog_pathway_content()`. + + +# `get_default_pathway_category` is part of this API too, and is re-exported via `__all__`. It's defined next to the +# model because the `CatalogPathway.category` field default needs it as well. + + +def get_pathway_category(category_code: str) -> PathwayCategory: + """ + Get a `PathwayCategory` by its stable code. + + ⚠️ Does not check permissions. + """ + return PathwayCategory.objects.get(category_code=category_code) + + +@overload +def get_catalog_pathway(*, org_code: str, pathway_code: str) -> CatalogPathway: ... +@overload +def get_catalog_pathway(*, key_str: str) -> CatalogPathway: ... +@overload +def get_catalog_pathway(*, pk: CatalogPathway.ID) -> CatalogPathway: ... + + +def get_catalog_pathway( + pk: CatalogPathway.ID | None = None, + key_str: str = "", + org_code: str = "", + pathway_code: str = "", +) -> CatalogPathway: + """ + Get a catalog pathway. + + ⚠️ Does not check permissions or visibility rules. + + The `CatalogPathway` may not have a definition yet: `content_entity` is `None` until `openedx_learning.api` links + one. To resolve it to the actual Pathway, use `openedx_learning.api.get_pathway_for_catalog_pathway()`. + """ + assert pk or key_str or (org_code and pathway_code) + if pk: + assert not org_code + assert not key_str + return CatalogPathway.objects.get(pk=pk) + if key_str: + assert key_str.startswith("catalog-pathway:") + assert not org_code + assert not pathway_code + _, org_code, pathway_code = key_str.split(":", 2) + # We might as well select_related org because we're joining to check the org__short_name field anyways. + return CatalogPathway.objects.select_related("org").get(org__short_name=org_code, pathway_code=pathway_code) + + +def create_catalog_pathway( + *, + org_code: str, + pathway_code: str, + title: str = "", + category: PathwayCategory | None = None, + description: str = "", +) -> CatalogPathway: + """ + Create a `CatalogPathway`. + + The `Organization` identified by `org_code` must already exist. Pass `category=None` to use the default category. + + This creates only the catalog half of a Pathway. Use `openedx_learning.api` to create the versioned definition and + link it to this entry. + + ⚠️ Does not check permissions. + """ + pathway = CatalogPathway( + pathway_code=pathway_code, + title=title, + description=description, + # Only pass the category if given, so that the field default (which queries for the shipped category) runs + # only when it's actually needed. + **({"category": category} if category is not None else {}), + ) + pathway.org_code = org_code # Resolves the Organization by short_name; raises Organization.DoesNotExist. + pathway.save() + return pathway + + +def update_catalog_pathway( + catalog_pathway: CatalogPathway | CatalogPathway.ID, + *, + title: str | None = None, + category: PathwayCategory | None = None, + description: str | None = None, +) -> None: + """ + Update a `CatalogPathway`. Pass `None` for a field to leave it unchanged. + + None of these edits create a new content version: catalog copy and the Pathway definition change at different rates + and are edited by different people, which is the whole point of the split. + + ⚠️ Does not check permissions. + """ + if isinstance(catalog_pathway, CatalogPathway): + cp = catalog_pathway + else: + cp = CatalogPathway.objects.get(pk=catalog_pathway) + + update_fields = [] + for field_name, value in ( + ("title", title), + ("category", category), + ("description", description), + ): + if value is not None: + setattr(cp, field_name, value) + update_fields.append(field_name) + if update_fields: + cp.save(update_fields=update_fields + ["modified"]) + + +def set_catalog_pathway_content( + catalog_pathway: CatalogPathway | CatalogPathway.ID, + content_entity: PublishableEntity | PublishableEntity.ID | None, +) -> None: + """ + Point a `CatalogPathway` at the `PublishableEntity` holding its versioned definition, or pass `None` to unlink it. + + A definition can serve only one catalog entry, so this raises an `IntegrityError` if another `CatalogPathway` + already points at the same entity. + + `openedx_catalog` cannot tell a Pathway entity apart from any other `PublishableEntity`, so no such check happens + here. Prefer `openedx_learning.api` (`create_pathway()`, `link_catalog_pathway()`), which only ever passes entities + it created as Pathways. + + ⚠️ Does not check permissions. + """ + if isinstance(catalog_pathway, CatalogPathway): + cp = catalog_pathway + else: + cp = CatalogPathway.objects.get(pk=catalog_pathway) + + if content_entity is None or isinstance(content_entity, PublishableEntity): + cp.content_entity = content_entity + else: + cp.content_entity_id = content_entity + cp.save(update_fields=["content_entity", "modified"]) + + +def delete_catalog_pathway(catalog_pathway: CatalogPathway | CatalogPathway.ID) -> None: + """ + Delete a `CatalogPathway`, along with its enrollments. + + The versioned definition it pointed at, if any, is left in place in its learning package; it is simply no longer + linked from the catalog. + + ⚠️ Does not check permissions. + """ + if isinstance(catalog_pathway, CatalogPathway): + cp = catalog_pathway + else: + cp = CatalogPathway.objects.get(pk=catalog_pathway) + cp.delete() + + +def enroll_in_pathway(user_id: int, catalog_pathway: CatalogPathway | CatalogPathway.ID) -> PathwayEnrollment: + """ + Enroll a learner in a `CatalogPathway`, or return their existing active enrollment. + + If the learner had previously unenrolled, their existing row is reactivated rather than replaced, so the original + enrollment date is kept. + + Enrollment does not pin a content version: progress is always evaluated against whatever is published at the time, + so that authoring changes reach learners who are already enrolled. + + ⚠️ Does not check permissions. + """ + pathway_id = catalog_pathway.id if isinstance(catalog_pathway, CatalogPathway) else catalog_pathway + with transaction.atomic(): + # Lock the row so a concurrent unenroll can't slip in between reading `is_active` and writing it back. + enrollment, created = PathwayEnrollment.objects.select_for_update().get_or_create( + user_id=user_id, catalog_pathway_id=pathway_id + ) + if not created and not enrollment.is_active: + enrollment.is_active = True + enrollment.save(update_fields=["is_active", "modified"]) + return enrollment + + +def unenroll_from_pathway(user_id: int, catalog_pathway: CatalogPathway | CatalogPathway.ID) -> None: + """ + Unenroll a learner from a `CatalogPathway`. A no-op if they aren't enrolled. + + The enrollment row is deactivated, not deleted. + + ⚠️ Does not check permissions. + """ + pathway_id = catalog_pathway.id if isinstance(catalog_pathway, CatalogPathway) else catalog_pathway + PathwayEnrollment.objects.filter(user_id=user_id, catalog_pathway_id=pathway_id, is_active=True).update( + is_active=False, modified=timezone.now() + ) + + +def is_enrolled_in_pathway(user_id: int, catalog_pathway: CatalogPathway | CatalogPathway.ID) -> bool: + """ + Check whether this learner is actively enrolled in this `CatalogPathway`. + + ⚠️ Does not check permissions. + """ + pathway_id = catalog_pathway.id if isinstance(catalog_pathway, CatalogPathway) else catalog_pathway + return PathwayEnrollment.objects.filter(user_id=user_id, catalog_pathway_id=pathway_id, is_active=True).exists() + + +def get_pathway_enrollments(user_id: int, *, include_inactive: bool = False) -> QuerySet[PathwayEnrollment]: + """ + Get a learner's pathway enrollments, most recent first. + + Only active enrollments are returned unless ``include_inactive`` is set. + + ⚠️ Does not check permissions or visibility rules. + """ + enrollments = PathwayEnrollment.objects.filter(user_id=user_id).select_related("catalog_pathway") + if not include_inactive: + enrollments = enrollments.filter(is_active=True) + return enrollments diff --git a/src/openedx_catalog/migrations/0002_pathways.py b/src/openedx_catalog/migrations/0002_pathways.py new file mode 100644 index 000000000..05c7f1fc4 --- /dev/null +++ b/src/openedx_catalog/migrations/0002_pathways.py @@ -0,0 +1,298 @@ +""" +Create the catalog half of a Pathway: PathwayCategory, CatalogPathway, and PathwayEnrollment. + +Every CatalogPathway must have a category. Rather than falling back to the word "Pathway" in code, we ship a database +row with that name, so that the behavior is uniform and operators can rename it or add categories of their own without a +code change (ADR 0007, decision 2). The default row is created right after its table and before CatalogPathway exists. +Because Django unapplies operations in reverse order, the reverse step deletes that row only after the CatalogPathway +table is already gone, so nothing can still reference it. +""" + +import re + +import django.core.validators +import django.db.models.deletion +import django.db.models.functions.text +import django.db.models.lookups +from django.conf import settings +from django.db import migrations, models + +import openedx_catalog.models.pathway_category +import openedx_django_lib.fields +import openedx_django_lib.validators + +# These values are duplicated from openedx_catalog.models.pathway_category rather than imported, because a migration +# should represent a point-in-time transformation and must not change if those constants later do. +DEFAULT_CATEGORY_CODE = "pathway" +DEFAULT_CATEGORY_NAME = "Pathway" + + +def create_default_pathway_category(apps, schema_editor): + """Create the default category.""" + PathwayCategory = apps.get_model("openedx_catalog", "PathwayCategory") + PathwayCategory.objects.get_or_create( + category_code=DEFAULT_CATEGORY_CODE, + defaults={"name": DEFAULT_CATEGORY_NAME}, + ) + + +def delete_default_pathway_category(apps, schema_editor): + """Remove the default category on reverse.""" + PathwayCategory = apps.get_model("openedx_catalog", "PathwayCategory") + PathwayCategory.objects.filter(category_code=DEFAULT_CATEGORY_CODE).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("openedx_catalog", "0001_initial"), + ("openedx_content", "0014_typed_media_id"), + ("organizations", "0004_auto_20230727_2054"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="PathwayCategory", + fields=[ + ( + "id", + models.BigAutoField( + editable=False, + help_text="The internal database ID for this pathway category. Should not be exposed to users nor in APIs.", + primary_key=True, + serialize=False, + verbose_name="Primary Key", + ), + ), + ( + "category_code", + openedx_django_lib.fields.MultiCollationCharField( + db_collations={"mysql": "utf8mb4_bin", "sqlite": "BINARY"}, + help_text='A stable slug identifying this category, e.g. "masters-degree". Not shown to learners.', + max_length=255, + validators=[ + django.core.validators.RegexValidator( + re.compile("^[a-zA-Z0-9_.-]+\\Z"), + 'Enter a valid "code name" consisting of latin letters (A-Z, a-z), numbers, underscores, hyphens, or periods.', + "invalid", + ) + ], + ), + ), + ( + "name", + openedx_django_lib.fields.MultiCollationCharField( + db_collations={"mysql": "utf8mb4_unicode_ci", "sqlite": "NOCASE"}, + help_text='The learner-facing name of this category, e.g. "Master\'s Degree". Operators may change this.', + max_length=255, + ), + ), + ], + options={ + "verbose_name": "Pathway Category", + "verbose_name_plural": "Pathway Categories", + "ordering": ("name",), + "constraints": [ + models.UniqueConstraint( + django.db.models.functions.text.Lower("category_code"), + name="oex_catalog_pathwaycategory_code_uniq_ci", + ), + models.CheckConstraint( + condition=django.db.models.lookups.Regex(models.F("category_code"), "^[a-zA-Z0-9_.-]+\\Z"), + name="oex_catalog_pathwaycategory_code_regex", + violation_error_message='Enter a valid "code name" consisting of latin letters (A-Z, a-z), numbers, underscores, hyphens, or periods.', + ), + models.CheckConstraint( + condition=models.Q(("name__length__gt", 0)), name="oex_catalog_pathwaycategory_name_not_blank" + ), + ], + }, + ), + migrations.RunPython( + create_default_pathway_category, + reverse_code=delete_default_pathway_category, + ), + migrations.CreateModel( + name="CatalogPathway", + fields=[ + ( + "id", + models.BigAutoField( + editable=False, + help_text="The internal database ID for this catalog pathway. Should not be exposed to users nor in APIs.", + primary_key=True, + serialize=False, + verbose_name="Primary Key", + ), + ), + ( + "pathway_code", + openedx_django_lib.fields.MultiCollationCharField( + db_collations={"mysql": "utf8mb4_bin", "sqlite": "BINARY"}, + help_text='The pathway code/number, e.g. "DataScience2026".', + max_length=255, + validators=[ + django.core.validators.RegexValidator( + re.compile("^[a-zA-Z0-9_.-]+\\Z"), + 'Enter a valid "code name" consisting of latin letters (A-Z, a-z), numbers, underscores, hyphens, or periods.', + "invalid", + ) + ], + ), + ), + ( + "created", + models.DateTimeField( + auto_now_add=True, validators=[openedx_django_lib.validators.validate_utc_datetime] + ), + ), + ( + "modified", + models.DateTimeField( + auto_now=True, + help_text="When the catalog fields of this pathway were last edited. Unrelated to its content.", + validators=[openedx_django_lib.validators.validate_utc_datetime], + ), + ), + ( + "title", + openedx_django_lib.fields.MultiCollationCharField( + blank=True, + db_collations={"mysql": "utf8mb4_unicode_ci", "sqlite": "NOCASE"}, + help_text='The full title (display name) of this pathway, e.g. "Data Science Professional Certificate". Leave blank to use the pathway code as the title.', + max_length=255, + ), + ), + ( + "description", + openedx_django_lib.fields.MultiCollationTextField( + blank=True, + db_collations={"mysql": "utf8mb4_unicode_ci", "sqlite": "NOCASE"}, + default="", + help_text="The description shown to learners browsing the catalog.", + max_length=10000, + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="catalog_pathways", + to="organizations.organization", + ), + ), + ( + "category", + models.ForeignKey( + default=openedx_catalog.models.pathway_category.get_default_pathway_category_id, + help_text="The learner-facing kind of pathway this is. Always required; defaults to a category we ship.", + on_delete=django.db.models.deletion.PROTECT, + related_name="pathways", + to="openedx_catalog.pathwaycategory", + ), + ), + ( + "content_entity", + models.OneToOneField( + blank=True, + help_text="The publishable entity holding this pathway's versioned definition (a Pathway in openedx_learning). Blank until a definition has been created and linked through openedx_learning.api.", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="catalog_pathway", + to="openedx_content.publishableentity", + ), + ), + ], + options={ + "verbose_name": "Catalog Pathway", + "verbose_name_plural": "Catalog Pathways", + "ordering": ("-created",), + }, + ), + migrations.CreateModel( + name="PathwayEnrollment", + fields=[ + ( + "id", + models.BigAutoField( + editable=False, + help_text="The internal database ID for this enrollment. Should not be exposed to users nor in APIs.", + primary_key=True, + serialize=False, + verbose_name="Primary Key", + ), + ), + ( + "created", + models.DateTimeField( + auto_now_add=True, validators=[openedx_django_lib.validators.validate_utc_datetime] + ), + ), + ( + "modified", + models.DateTimeField(auto_now=True, validators=[openedx_django_lib.validators.validate_utc_datetime]), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="False once the learner has unenrolled. The row is kept so re-enrolling reuses it.", + ), + ), + ( + "catalog_pathway", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="enrollments", + to="openedx_catalog.catalogpathway", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="pathway_enrollments", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Pathway Enrollment", + "verbose_name_plural": "Pathway Enrollments", + "ordering": ("-created",), + }, + ), + migrations.AddIndex( + model_name="catalogpathway", + index=models.Index(fields=["org", "pathway_code"], name="openedx_cat_org_id_037ff8_idx"), + ), + migrations.AddConstraint( + model_name="catalogpathway", + constraint=models.UniqueConstraint( + models.F("org"), + django.db.models.functions.text.Lower("pathway_code"), + name="oex_catalog_catalogpathway_org_code_uniq_ci", + ), + ), + migrations.AddConstraint( + model_name="catalogpathway", + constraint=models.CheckConstraint( + condition=django.db.models.lookups.Regex(models.F("pathway_code"), "^[a-zA-Z0-9_.-]+\\Z"), + name="oex_catalog_catalogpathway_pathway_code_regex", + violation_error_message='Enter a valid "code name" consisting of latin letters (A-Z, a-z), numbers, underscores, hyphens, or periods.', + ), + ), + migrations.AddConstraint( + model_name="catalogpathway", + constraint=models.CheckConstraint( + condition=models.Q(("title__length__gt", 0)), name="oex_catalog_catalogpathway_title_not_blank" + ), + ), + migrations.AddConstraint( + model_name="pathwayenrollment", + constraint=models.UniqueConstraint( + fields=("user", "catalog_pathway"), name="oex_catalog_pathwayenrollment_uniq_user_pathway" + ), + ), + ] diff --git a/src/openedx_catalog/models/__init__.py b/src/openedx_catalog/models/__init__.py index 31da59962..513141541 100644 --- a/src/openedx_catalog/models/__init__.py +++ b/src/openedx_catalog/models/__init__.py @@ -3,4 +3,7 @@ """ from .catalog_course import CatalogCourse +from .catalog_pathway import CatalogPathway from .course_run import CourseRun +from .pathway_category import PathwayCategory +from .pathway_enrollment import PathwayEnrollment diff --git a/src/openedx_catalog/models/catalog_pathway.py b/src/openedx_catalog/models/catalog_pathway.py new file mode 100644 index 000000000..3761e9a30 --- /dev/null +++ b/src/openedx_catalog/models/catalog_pathway.py @@ -0,0 +1,199 @@ +""" +CatalogPathway model +""" + +import logging +from typing import NewType + +from django.contrib import admin +from django.db import models +from django.db.models.functions import Length, Lower +from django.utils.translation import gettext_lazy as _ +from organizations.models import Organization # type: ignore[import] + +from openedx_content.models_api import PublishableEntity +from openedx_django_lib.fields import ( + MultiCollationTextField, + TypedBigAutoField, + case_insensitive_char_field, + code_field, + code_field_check, +) +from openedx_django_lib.validators import validate_utc_datetime + +from .pathway_category import PathwayCategory, get_default_pathway_category_id + +log = logging.getLogger(__name__) + +# Make 'length' available for CHECK constraints. OK if this is called multiple times. +models.CharField.register_lookup(Length) + + +class CatalogPathway(models.Model): + """ + The learner-browsable, enrollable half of a Pathway. + + A Pathway is split in two (see the openedx_learning ADR 0007). This model is the catalog half: the display name, the + description shown in the catalog, and the `PathwayCategory`. It is **not versioned**, because marketing copy is + revised frequently and casually and versioning it would be pure overhead. + + The other half - the *definition* of the Pathway, meaning its Items and completion criteria - lives in + `openedx_learning.applets.pathways` and *is* versioned, so that progress and credentials can be judged against the + definition that was in effect at the time. + + The one link between the two halves is `content_entity`, which points at the `PublishableEntity` that carries the + versioned definition. It lives here because a context always points at its content, never the reverse (see the + openedx_catalog ADR 0001): `openedx_catalog` sits above `openedx_content` and may reference it, while the definition + models themselves live in `openedx_learning`, above this app. That is why the field is typed as a bare + `PublishableEntity` rather than as a Pathway; `openedx_learning.api` is what sets it and resolves it. + + A `CatalogPathway` may exist before its definition does, in the same way that a `CatalogCourse` may exist as a + marketing placeholder for a course that has no content yet. + + Like `CatalogCourse`, this model is intentionally minimal. Additional catalog-side fields should generally go in a + related model in your own app, with a `ForeignKey` or `OneToOneField` to this one. + + .. no_pii: + """ + + CatalogPathwayID = NewType("CatalogPathwayID", int) + type ID = CatalogPathwayID + + class IDField(TypedBigAutoField[ID]): # Boilerplate for fully-typed ID field. + pass + + id = IDField( + primary_key=True, + verbose_name=_("Primary Key"), + help_text=_("The internal database ID for this catalog pathway. Should not be exposed to users nor in APIs."), + editable=False, + ) + org = models.ForeignKey( + Organization, + on_delete=models.PROTECT, + null=False, + related_name="catalog_pathways", + ) + pathway_code = code_field( + unicode=False, + help_text=_('The pathway code/number, e.g. "DataScience2026".'), + ) + created = models.DateTimeField( + auto_now_add=True, + validators=[validate_utc_datetime], + editable=False, + ) + # This reflects edits to the *catalog* fields on this row only (title, category, description). It says nothing about + # when the pathway's *definition* - its Items and completion criteria - last changed. That happens on the content + # side and is versioned there; ask `openedx_learning.api` for it. + modified = models.DateTimeField( + auto_now=True, + validators=[validate_utc_datetime], + editable=False, + help_text=_("When the catalog fields of this pathway were last edited. Unrelated to its content."), + ) + title = case_insensitive_char_field( + max_length=255, + blank=True, # Only allowed to be blank temporarily when creating a new instance in the Django admin form. + help_text=_( + 'The full title (display name) of this pathway, e.g. "Data Science Professional Certificate". ' + "Leave blank to use the pathway code as the title." + ), + ) + category = models.ForeignKey( + PathwayCategory, + on_delete=models.PROTECT, + null=False, + default=get_default_pathway_category_id, + related_name="pathways", + help_text=_("The learner-facing kind of pathway this is. Always required; defaults to a category we ship."), + ) + content_entity = models.OneToOneField( # One definition serves one catalog entry. + # The link is deliberately unversioned: a catalog entry follows whichever version of its Pathway is currently + # published, which is what lets authoring changes reach learners who are already enrolled. + PublishableEntity, + # Deleting a definition out from under enrolled learners would leave them enrolled in something with + # no requirements. We must unlink it first. + on_delete=models.PROTECT, + null=True, # A `CatalogPathway` may exist as a placeholder before any definition does. + blank=True, + related_name="catalog_pathway", + help_text=_( + "The publishable entity holding this pathway's versioned definition (a Pathway in openedx_learning). " + "Blank until a definition has been created and linked through openedx_learning.api." + ), + ) + description = MultiCollationTextField( + blank=True, + null=False, + default="", + max_length=10_000, + # We don't expect to sort by this column, but we may want case-insensitive searches over it. + db_collations={ + "sqlite": "NOCASE", + "mysql": "utf8mb4_unicode_ci", + }, + help_text=_("The description shown to learners browsing the catalog."), + ) + + # 🛑 Avoid adding additional fields here. Anything that describes what a learner must *do* belongs on the content + # side, where it is versioned. Anything else catalog-related should go in a related model in your own app. + + @property + @admin.display(ordering="org__short_name") + def org_code(self) -> str: + """ + Get the org code (Organization short_name) of this pathway, e.g. "MITx". + """ + return self.org.short_name + + @org_code.setter + def org_code(self, org_code: str) -> None: + """ + Convenience method to set the related organization using its short_name. + """ + # As with CatalogCourse, we don't use `get_organization_by_short_name` because it filters for active orgs only, + # and we need to allow inactive orgs to support historical data and backfills. + self.org = Organization.objects.get(short_name__iexact=org_code) + + @property + def key_str(self) -> str: + """ + A string key that can be used to identify this catalog pathway in URLs or APIs. + + As with `CatalogCourse.key_str`, this may become based on an editable `SlugField` or an opaque key in the + future, so don't assume it never changes. + """ + return f"catalog-pathway:{self.org_code}:{self.pathway_code}" + + def clean(self) -> None: + """Validate/normalize fields when edited via Django admin.""" + # Set a default value for title: + if not self.title: + self.title = self.pathway_code + + def save(self, *args, **kwargs): + """Save the model, with some defaults and validation.""" + self.clean() + super().save(*args, **kwargs) + + def __str__(self) -> str: + return f"{self.title} ({self.org_code} {self.pathway_code})" + + class Meta: + verbose_name = _("Catalog Pathway") + verbose_name_plural = _("Catalog Pathways") + ordering = ("-created",) + indexes = [ + # We need fast lookups by (org, pathway_code) pairs. We generally want this lookup to be case sensitive. + models.Index(fields=["org", "pathway_code"]), + ] + constraints = [ + # The pathway_code must be case-insensitively unique per org: + models.UniqueConstraint("org", Lower("pathway_code"), name="oex_catalog_catalogpathway_org_code_uniq_ci"), + code_field_check("pathway_code", name="oex_catalog_catalogpathway_pathway_code_regex", unicode=False), + # Enforce at the DB level that these required fields are not blank: + models.CheckConstraint( + condition=models.Q(title__length__gt=0), name="oex_catalog_catalogpathway_title_not_blank" + ), + ] diff --git a/src/openedx_catalog/models/pathway_category.py b/src/openedx_catalog/models/pathway_category.py new file mode 100644 index 000000000..de41c6e3d --- /dev/null +++ b/src/openedx_catalog/models/pathway_category.py @@ -0,0 +1,99 @@ +""" +PathwayCategory model +""" + +import logging +from typing import NewType + +from django.db import models +from django.db.models.functions import Length, Lower +from django.utils.translation import gettext_lazy as _ + +from openedx_django_lib.fields import TypedBigAutoField, case_insensitive_char_field, code_field, code_field_check + +log = logging.getLogger(__name__) + +# Make 'length' available for CHECK constraints. OK if this is called multiple times. +models.CharField.register_lookup(Length) + +DEFAULT_PATHWAY_CATEGORY_CODE = "pathway" +DEFAULT_PATHWAY_CATEGORY_NAME = "Pathway" + + +class PathwayCategory(models.Model): + """ + A student-facing label for a kind of Pathway. + + Learners are shown the category ("Master's Degree", "Annual Training") rather than the word "Pathway". In authoring + contexts - Studio, Django admin, code, docs - the terminology stays "Pathway", with the category shown explicitly; + relabelling is a learner-facing concern of the catalog side only. + + The ``category_code`` is the stable identifier that code and imports may key off. The ``name`` is what learners see, + and operators are free to change it - including on the default category shipped by the initial migration. + + .. no_pii: + """ + + PathwayCategoryID = NewType("PathwayCategoryID", int) + type ID = PathwayCategoryID + + class IDField(TypedBigAutoField[ID]): # Boilerplate for fully-typed ID field. + pass + + id = IDField( + primary_key=True, + verbose_name=_("Primary Key"), + help_text=_("The internal database ID for this pathway category. Should not be exposed to users nor in APIs."), + editable=False, + ) + category_code = code_field( + unicode=False, + help_text=_('A stable slug identifying this category, e.g. "masters-degree". Not shown to learners.'), + ) + name = case_insensitive_char_field( + max_length=255, + blank=False, + help_text=_('The learner-facing name of this category, e.g. "Master\'s Degree". Operators may change this.'), + ) + + def __str__(self) -> str: + return str(self.name) + + class Meta: + verbose_name = _("Pathway Category") + verbose_name_plural = _("Pathway Categories") + ordering = ("name",) + constraints = [ + # The category_code must be case-insensitively unique: + models.UniqueConstraint(Lower("category_code"), name="oex_catalog_pathwaycategory_code_uniq_ci"), + code_field_check("category_code", name="oex_catalog_pathwaycategory_code_regex", unicode=False), + # Enforce at the DB level that this required field is not blank: + models.CheckConstraint( + condition=models.Q(name__length__gt=0), name="oex_catalog_pathwaycategory_name_not_blank" + ), + ] + + +def get_default_pathway_category() -> PathwayCategory: + """ + Get the default `PathwayCategory`, creating it if it doesn't exist. + + Every `CatalogPathway` must have a category. Rather than falling back to the word "Pathway" in code, we ship a + database row with that name, so that operators can rename it or add categories of their own without a code change. + See the openedx_learning ADR 0007. + """ + category, _created = PathwayCategory.objects.get_or_create( + category_code=DEFAULT_PATHWAY_CATEGORY_CODE, + defaults={"name": DEFAULT_PATHWAY_CATEGORY_NAME}, + ) + return category + + +def get_default_pathway_category_id() -> PathwayCategory.ID: + """ + Get the ID of the default `PathwayCategory`, creating it if it doesn't exist. + + Note: this function is used as a field default and is therefore referenced from migrations, so update those + migrations if moving it or changing its signature. + """ + return get_default_pathway_category().id diff --git a/src/openedx_catalog/models/pathway_enrollment.py b/src/openedx_catalog/models/pathway_enrollment.py new file mode 100644 index 000000000..59371dea1 --- /dev/null +++ b/src/openedx_catalog/models/pathway_enrollment.py @@ -0,0 +1,88 @@ +""" +PathwayEnrollment model +""" + +import logging +from typing import NewType + +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from openedx_django_lib.fields import TypedBigAutoField +from openedx_django_lib.validators import validate_utc_datetime + +from .catalog_pathway import CatalogPathway + +log = logging.getLogger(__name__) + + +class PathwayEnrollment(models.Model): + """ + Ties a learner to a `CatalogPathway`. + + Enrollment is against the *catalog* half of a Pathway, never against a version of its content. Progress is evaluated + against whichever content version is published at the time of evaluation, not against a version frozen at enrollment + time, so that authoring changes reach learners who are already enrolled. That is why this model pins no version. See + the openedx_learning ADR 0007, decision 5. + + Unenrolling sets ``is_active`` to False rather than deleting the row, so that "unenrolled" can be told apart from + "never enrolled" and the original enrollment date survives. Re-enrolling reactivates the same row. + + .. no_pii: + """ + + PathwayEnrollmentID = NewType("PathwayEnrollmentID", int) + type ID = PathwayEnrollmentID + + class IDField(TypedBigAutoField[ID]): # Boilerplate for fully-typed ID field. + pass + + id = IDField( + primary_key=True, + verbose_name=_("Primary Key"), + help_text=_("The internal database ID for this enrollment. Should not be exposed to users nor in APIs."), + editable=False, + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + null=False, + related_name="pathway_enrollments", + ) + catalog_pathway = models.ForeignKey( + CatalogPathway, + on_delete=models.CASCADE, + null=False, + related_name="enrollments", + ) + created = models.DateTimeField( + auto_now_add=True, + validators=[validate_utc_datetime], + editable=False, + ) + modified = models.DateTimeField( + auto_now=True, + validators=[validate_utc_datetime], + editable=False, + ) + is_active = models.BooleanField( + default=True, + help_text=_("False once the learner has unenrolled. The row is kept so re-enrolling reuses it."), + ) + + def __str__(self) -> str: + return f"{self.user} in {self.catalog_pathway}" + + class Meta: + verbose_name = _("Pathway Enrollment") + verbose_name_plural = _("Pathway Enrollments") + ordering = ("-created",) + constraints = [ + # There is only ever one row per (learner, pathway) pair; unenrolling flips `is_active` rather than + # deleting or adding a row. + models.UniqueConstraint( + fields=["user", "catalog_pathway"], + name="oex_catalog_pathwayenrollment_uniq_user_pathway", + ), + ] diff --git a/src/openedx_catalog/models_api.py b/src/openedx_catalog/models_api.py index 2836a2aa1..90a67b1a3 100644 --- a/src/openedx_catalog/models_api.py +++ b/src/openedx_catalog/models_api.py @@ -7,4 +7,4 @@ """ # pylint: disable=unused-import -from .models import CatalogCourse, CourseRun +from .models import CatalogCourse, CatalogPathway, CourseRun, PathwayCategory, PathwayEnrollment diff --git a/tests/openedx_catalog/test_pathway_api.py b/tests/openedx_catalog/test_pathway_api.py new file mode 100644 index 000000000..c6831929b --- /dev/null +++ b/tests/openedx_catalog/test_pathway_api.py @@ -0,0 +1,230 @@ +""" +Tests of the catalog-side Pathway API. +""" +# pylint: disable=unused-argument + +from datetime import datetime, timezone + +import pytest +from django.contrib.auth import get_user_model +from freezegun import freeze_time +from organizations.api import ensure_organization # type: ignore[import] + +from openedx_catalog import api as catalog_api +from openedx_catalog.models import CatalogPathway, PathwayCategory +from openedx_catalog.models.pathway_category import DEFAULT_PATHWAY_CATEGORY_CODE +from openedx_content import api as content_api +from openedx_content.models_api import PublishableEntity + +User = get_user_model() + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(name="org1") +def _org1() -> None: + """Create an "Org1" organization for use in these tests""" + ensure_organization("Org1") + + +@pytest.fixture(name="data_science") +def _data_science(org1) -> CatalogPathway: + """Create a CatalogPathway for use in these tests""" + return catalog_api.create_catalog_pathway( + org_code="Org1", + pathway_code="DataScience", + title="Data Science Professional Certificate", + description="Learn data science.", + ) + + +@pytest.fixture(name="learner") +def _learner(): + """Create a learner for use in these tests""" + return User.objects.create(username="learner", email="learner@example.com") + + +@pytest.fixture(name="definition") +def _definition() -> PublishableEntity: + """Create a bare PublishableEntity to stand in for a Pathway definition.""" + package = content_api.create_learning_package(package_ref="pathway-tests", title="Pathway tests") + return content_api.create_publishable_entity( + package.id, "pathway:DataScience", datetime(2026, 1, 1, tzinfo=timezone.utc), None + ) + + +def test_get_default_pathway_category() -> None: + assert catalog_api.get_default_pathway_category().category_code == DEFAULT_PATHWAY_CATEGORY_CODE + + +def test_get_pathway_category() -> None: + """Categories are looked up by their stable code, not by the learner-facing name.""" + masters = PathwayCategory.objects.create(category_code="masters-degree", name="Master's Degree") + assert catalog_api.get_pathway_category("masters-degree") == masters + with pytest.raises(PathwayCategory.DoesNotExist): + catalog_api.get_pathway_category("Master's Degree") + + +def test_create_with_default_category(org1) -> None: + """Omitting the category picks the shipped default, and a blank title falls back to the code.""" + pathway = catalog_api.create_catalog_pathway(org_code="Org1", pathway_code="CompSci") + assert pathway.category.category_code == DEFAULT_PATHWAY_CATEGORY_CODE + assert pathway.title == "CompSci" + + +def test_create_with_explicit_category(org1) -> None: + """Operators can add categories of their own; the default is only a default.""" + masters = PathwayCategory.objects.create(category_code="masters-degree", name="Master's Degree") + pathway = catalog_api.create_catalog_pathway( + org_code="Org1", + pathway_code="CompSci", + title="Computer Science", + category=masters, + ) + assert pathway.category == masters + + +def test_get_catalog_pathway(data_science) -> None: + """A catalog pathway can be looked up by pk, key string, or org + code.""" + assert catalog_api.get_catalog_pathway(pk=data_science.id) == data_science + assert catalog_api.get_catalog_pathway(key_str=data_science.key_str) == data_science + assert catalog_api.get_catalog_pathway(org_code="Org1", pathway_code="DataScience") == data_science + with pytest.raises(CatalogPathway.DoesNotExist): + catalog_api.get_catalog_pathway(org_code="Org1", pathway_code="Nope") + + +def test_update_catalog_pathway_by_id_and_category(data_science) -> None: + """The pathway may be given by ID, and the category can be changed like any other catalog field.""" + masters = PathwayCategory.objects.create(category_code="masters-degree", name="Master's Degree") + catalog_api.update_catalog_pathway(data_science.id, category=masters) + assert catalog_api.get_catalog_pathway(pk=data_science.id).category == masters + + +def test_update_catalog_pathway(data_science) -> None: + """Only the fields passed are changed; the rest are left alone. `modified` records the edit.""" + edited_at = datetime(2026, 3, 1, tzinfo=timezone.utc) + with freeze_time(edited_at): + catalog_api.update_catalog_pathway(data_science, description="Learn even more data science.") + + reloaded = catalog_api.get_catalog_pathway(pk=data_science.id) + assert reloaded.description == "Learn even more data science." + assert reloaded.title == "Data Science Professional Certificate" + assert reloaded.modified == edited_at + + +def test_update_catalog_pathway_with_nothing_to_change(data_science) -> None: + """Passing no fields is a no-op, so `modified` is not bumped.""" + before = catalog_api.get_catalog_pathway(pk=data_science.id).modified + catalog_api.update_catalog_pathway(data_science) + assert catalog_api.get_catalog_pathway(pk=data_science.id).modified == before + + +def test_delete_catalog_pathway(data_science) -> None: + catalog_api.delete_catalog_pathway(data_science.id) + with pytest.raises(CatalogPathway.DoesNotExist): + catalog_api.get_catalog_pathway(pk=data_science.id) + + +def test_set_catalog_pathway_content(data_science, definition) -> None: + """The catalog entry points at its definition; the link can be set by instance or ID, and cleared with None.""" + catalog_api.set_catalog_pathway_content(data_science, definition) + assert catalog_api.get_catalog_pathway(pk=data_science.id).content_entity == definition + + catalog_api.set_catalog_pathway_content(data_science.id, None) + assert catalog_api.get_catalog_pathway(pk=data_science.id).content_entity is None + + catalog_api.set_catalog_pathway_content(data_science.id, definition.id) + assert catalog_api.get_catalog_pathway(pk=data_science.id).content_entity == definition + + +def test_deleting_catalog_pathway_leaves_definition_in_place(data_science, definition) -> None: + """Deleting the catalog half unlinks the definition; it does not delete it.""" + catalog_api.set_catalog_pathway_content(data_science, definition) + catalog_api.delete_catalog_pathway(data_science) + assert PublishableEntity.objects.filter(id=definition.id).exists() + + +def test_enrollment_round_trip(data_science, learner) -> None: + assert not catalog_api.is_enrolled_in_pathway(learner.id, data_science) + + enrollment = catalog_api.enroll_in_pathway(learner.id, data_science) + assert catalog_api.is_enrolled_in_pathway(learner.id, data_science) + assert list(catalog_api.get_pathway_enrollments(learner.id)) == [enrollment] + + catalog_api.unenroll_from_pathway(learner.id, data_science) + assert not catalog_api.is_enrolled_in_pathway(learner.id, data_science) + + +def test_enrolling_twice_is_idempotent(data_science, learner) -> None: + """Enrolling again returns the existing enrollment rather than failing, and doesn't touch `modified`.""" + with freeze_time(datetime(2026, 1, 1, tzinfo=timezone.utc)): + first = catalog_api.enroll_in_pathway(learner.id, data_science) + with freeze_time(datetime(2026, 2, 1, tzinfo=timezone.utc)): + second = catalog_api.enroll_in_pathway(learner.id, data_science.id) + assert first == second + assert second.modified == datetime(2026, 1, 1, tzinfo=timezone.utc) + assert catalog_api.get_pathway_enrollments(learner.id).count() == 1 + + +def test_unenrolling_keeps_the_row(data_science, learner) -> None: + """Unenrolling deactivates rather than deletes, so history survives.""" + enrollment = catalog_api.enroll_in_pathway(learner.id, data_science) + catalog_api.unenroll_from_pathway(learner.id, data_science) + + assert not catalog_api.is_enrolled_in_pathway(learner.id, data_science) + assert catalog_api.get_pathway_enrollments(learner.id).count() == 0 + inactive = catalog_api.get_pathway_enrollments(learner.id, include_inactive=True) + assert list(inactive) == [enrollment] + assert not inactive[0].is_active + + +def test_re_enrolling_reactivates_the_same_row(data_science, learner) -> None: + """ + Re-enrolling reuses the original row, keeping the original enrollment date. `modified` tracks each flip of + `is_active`, so it ends up as "when this learner last enrolled or unenrolled". + """ + enrolled_at = datetime(2026, 1, 1, tzinfo=timezone.utc) + unenrolled_at = datetime(2026, 2, 1, tzinfo=timezone.utc) + re_enrolled_at = datetime(2026, 3, 1, tzinfo=timezone.utc) + + with freeze_time(enrolled_at): + original = catalog_api.enroll_in_pathway(learner.id, data_science) + assert original.modified == enrolled_at + + with freeze_time(unenrolled_at): + catalog_api.unenroll_from_pathway(learner.id, data_science) + inactive = catalog_api.get_pathway_enrollments(learner.id, include_inactive=True).get() + assert not inactive.is_active + assert inactive.modified == unenrolled_at + + with freeze_time(re_enrolled_at): + reactivated = catalog_api.enroll_in_pathway(learner.id, data_science) + assert reactivated.id == original.id + assert reactivated.is_active + assert reactivated.created == enrolled_at + assert reactivated.modified == re_enrolled_at + assert catalog_api.is_enrolled_in_pathway(learner.id, data_science) + + +def test_unenrolling_when_not_enrolled_is_a_no_op(data_science, learner) -> None: + catalog_api.unenroll_from_pathway(learner.id, data_science) # Should not raise. + assert not catalog_api.is_enrolled_in_pathway(learner.id, data_science) + assert catalog_api.get_pathway_enrollments(learner.id, include_inactive=True).count() == 0 + + +def test_unenrolling_twice_does_not_bump_modified(data_science, learner) -> None: + """Unenrolling only touches rows that are actually active, so a repeat call leaves `modified` alone.""" + catalog_api.enroll_in_pathway(learner.id, data_science) + with freeze_time(datetime(2026, 2, 1, tzinfo=timezone.utc)): + catalog_api.unenroll_from_pathway(learner.id, data_science) + with freeze_time(datetime(2026, 3, 1, tzinfo=timezone.utc)): + catalog_api.unenroll_from_pathway(learner.id, data_science) + + row = catalog_api.get_pathway_enrollments(learner.id, include_inactive=True).get() + assert row.modified == datetime(2026, 2, 1, tzinfo=timezone.utc) + + +def test_deleting_pathway_removes_enrollments(data_science, learner) -> None: + catalog_api.enroll_in_pathway(learner.id, data_science) + catalog_api.delete_catalog_pathway(data_science) + assert catalog_api.get_pathway_enrollments(learner.id).count() == 0 diff --git a/tests/openedx_catalog/test_pathway_models.py b/tests/openedx_catalog/test_pathway_models.py new file mode 100644 index 000000000..cb718274a --- /dev/null +++ b/tests/openedx_catalog/test_pathway_models.py @@ -0,0 +1,245 @@ +""" +Tests related to the catalog half of Pathways. +""" +# pylint: disable=unused-argument +# mypy: disable-error-code="misc" +# (Ignore 'Unexpected attribute "org_code" for model "CatalogPathway"' until +# https://github.com/typeddjango/django-stubs/issues/1034 is fixed.) + +from datetime import datetime, timezone + +import pytest +from django.contrib.auth import get_user_model +from django.db import transaction +from django.db.models import ProtectedError +from django.db.utils import IntegrityError +from freezegun import freeze_time +from organizations.api import ensure_organization # type: ignore[import] +from organizations.models import Organization # type: ignore[import] + +from openedx_catalog.models import CatalogPathway, PathwayCategory, PathwayEnrollment +from openedx_catalog.models.pathway_category import DEFAULT_PATHWAY_CATEGORY_CODE, DEFAULT_PATHWAY_CATEGORY_NAME +from openedx_content import api as content_api +from openedx_content.models_api import PublishableEntity + +User = get_user_model() + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(name="org1") +def _org1() -> None: + """Create an "Org1" organization for use in these tests""" + ensure_organization("Org1") + + +@pytest.fixture(name="org2") +def _org2() -> None: + """Create an "Org2" organization for use in these tests""" + ensure_organization("Org2") + + +@pytest.fixture(name="data_science") +def _data_science(org1) -> CatalogPathway: + """Create a CatalogPathway for use in these tests""" + return CatalogPathway.objects.create(org_code="Org1", pathway_code="DataScience") + + +@pytest.fixture(name="learner") +def _learner(): + """Create a learner for use in these tests""" + return User.objects.create(username="learner", email="learner@example.com") + + +@pytest.fixture(name="definition") +def _definition() -> PublishableEntity: + """ + Create a bare PublishableEntity to stand in for a Pathway definition. + + The catalog can't tell a Pathway entity from any other, so a bare entity exercises the same constraints. + """ + package = content_api.create_learning_package(package_ref="pathway-tests", title="Pathway tests") + return content_api.create_publishable_entity( + package.id, "pathway:DataScience", datetime(2026, 1, 1, tzinfo=timezone.utc), None + ) + + +# PathwayCategory + + +def test_default_category_is_shipped() -> None: + """ + The default category is a database row, not a fallback in code, so that operators can rename it without a code + change (ADR 0007, decision 2). + """ + category = PathwayCategory.objects.get(category_code=DEFAULT_PATHWAY_CATEGORY_CODE) + assert category.name == DEFAULT_PATHWAY_CATEGORY_NAME + + +def test_category_is_always_provided(org1) -> None: + """A CatalogPathway created without a category gets the default one.""" + pathway = CatalogPathway.objects.create(org_code="Org1", pathway_code="NoCategory") + assert pathway.category.category_code == DEFAULT_PATHWAY_CATEGORY_CODE + + +def test_default_category_can_be_renamed(org1) -> None: + """ + Renaming the default changes what learners see, and nothing else. The code stays put, so existing pathways keep + pointing at the same row. + """ + category = PathwayCategory.objects.get(category_code=DEFAULT_PATHWAY_CATEGORY_CODE) + category.name = "Program" + category.save() + + pathway = CatalogPathway.objects.create(org_code="Org1", pathway_code="Renamed") + assert pathway.category.name == "Program" + assert pathway.category.category_code == DEFAULT_PATHWAY_CATEGORY_CODE + + +def test_category_code_unique_ci() -> None: + """Category codes are case-insensitively unique.""" + PathwayCategory.objects.create(category_code="masters-degree", name="Master's Degree") + with pytest.raises(IntegrityError), transaction.atomic(): + PathwayCategory.objects.create(category_code="Masters-Degree", name="Duplicate") + + +def test_category_name_cannot_be_blank() -> None: + """The learner-facing name is required at the database level.""" + with pytest.raises(IntegrityError), transaction.atomic(): + PathwayCategory.objects.create(category_code="blank-name", name="") + + +def test_category_in_use_cannot_be_deleted(data_science) -> None: + """Deleting a category out from under a pathway would leave it without one.""" + with pytest.raises(ProtectedError): + data_science.category.delete() + + +def test_category_string_representation() -> None: + """The string representation of a category is its name.""" + category = PathwayCategory.objects.get(category_code=DEFAULT_PATHWAY_CATEGORY_CODE) + assert str(category) == DEFAULT_PATHWAY_CATEGORY_NAME + +# CatalogPathway + + +def test_invalid_org() -> None: + """The Organization must exist in the DB before a CatalogPathway can be created""" + with pytest.raises(Organization.DoesNotExist): + CatalogPathway.objects.create(org_code="NewOrg", pathway_code="Whatever") + + +def test_pathway_code_unique_per_org_ci(org1, org2) -> None: + """The pathway_code is case-insensitively unique per org, but not across orgs.""" + CatalogPathway.objects.create(org_code="Org1", pathway_code="DataScience") + with pytest.raises(IntegrityError), transaction.atomic(): + CatalogPathway.objects.create(org_code="Org1", pathway_code="datascience") + # A different org may use the same code: + CatalogPathway.objects.create(org_code="Org2", pathway_code="DataScience") + + +def test_title_defaults_to_pathway_code(data_science) -> None: + """A blank title falls back to the code, rather than failing the not-blank constraint.""" + assert data_science.title == "DataScience" + + +def test_key_str(data_science) -> None: + """The key is derived from the org and pathway codes.""" + assert data_science.key_str == "catalog-pathway:Org1:DataScience" + + +def test_catalog_edits_are_free(data_science) -> None: + """ + Catalog copy is not versioned. Editing it is an ordinary save, with no version to create and no trace left behind. + """ + data_science.title = "Data Science Professional Program" + data_science.description = "Learn data science." + data_science.save() + + reloaded = CatalogPathway.objects.get(pk=data_science.pk) + assert reloaded.title == "Data Science Professional Program" + assert reloaded.description == "Learn data science." + + +def test_modified_tracks_catalog_edits(org1) -> None: + """ + `modified` moves when the catalog fields change and `created` does not. + """ + created_at = datetime(2026, 1, 1, tzinfo=timezone.utc) + edited_at = datetime(2026, 2, 1, tzinfo=timezone.utc) + with freeze_time(created_at): + pathway = CatalogPathway.objects.create(org_code="Org1", pathway_code="Timestamps") + assert pathway.created == created_at + assert pathway.modified == created_at + + with freeze_time(edited_at): + pathway.title = "Renamed" + pathway.save() + + reloaded = CatalogPathway.objects.get(pk=pathway.pk) + assert reloaded.created == created_at + assert reloaded.modified == edited_at + + +def test_pathway_string_representation(data_science) -> None: + """Test the string representation of a pathway.""" + data_science.title = "Data Science Professional Program" + data_science.save() + data_science.refresh_from_db() + assert str(data_science) == "Data Science Professional Program (Org1 DataScience)" + + +def test_content_entity_is_optional(data_science) -> None: + """A catalog pathway may exist as a placeholder before its definition does.""" + assert data_science.content_entity is None + + +def test_one_catalog_pathway_per_definition(org1, definition) -> None: + """A definition serves exactly one catalog entry.""" + CatalogPathway.objects.create(org_code="Org1", pathway_code="First", content_entity=definition) + with pytest.raises(IntegrityError), transaction.atomic(): + CatalogPathway.objects.create(org_code="Org1", pathway_code="Second", content_entity=definition) + + +def test_linked_definition_cannot_be_deleted(data_science, definition) -> None: + """PROTECT: deleting a definition out from under a catalog entry would leave its learners with no requirements.""" + data_science.content_entity = definition + data_science.save() + with pytest.raises(ProtectedError), transaction.atomic(): + definition.delete() + + data_science.content_entity = None + data_science.save() + definition.delete() # Unlinked, so this is fine. + + +# PathwayEnrollment + + +def test_enrollment_is_unique_per_learner(data_science, learner) -> None: + """A learner is either enrolled in a pathway or not; there is never a second row.""" + PathwayEnrollment.objects.create(user=learner, catalog_pathway=data_science) + with pytest.raises(IntegrityError), transaction.atomic(): + PathwayEnrollment.objects.create(user=learner, catalog_pathway=data_science) + + +def test_enrollment_pins_no_version(data_science, learner) -> None: + """ + Enrollment ties a learner to the catalog half only. There is deliberately no field pinning a content version, + because progress is evaluated against whatever is published at the time (ADR 0007, decision 5). + """ + enrollment = PathwayEnrollment.objects.create(user=learner, catalog_pathway=data_science) + field_names = {field.name for field in enrollment._meta.get_fields()} + assert not any("version" in name for name in field_names) + + +def test_enrollment_is_active_by_default(data_science, learner) -> None: + """A fresh enrollment is active; deactivating it is how unenrolling is recorded.""" + enrollment = PathwayEnrollment.objects.create(user=learner, catalog_pathway=data_science) + assert enrollment.is_active + + +def test_enrollment_string_representation(data_science, learner) -> None: + """Test the string representation of a pathway enrollment.""" + enrollment = PathwayEnrollment.objects.create(user=learner, catalog_pathway=data_science) + assert str(enrollment) == f"{learner} in {data_science}" From 07c9b35fd2ab141a37e7f0b72a9539d5b85d6fa6 Mon Sep 17 00:00:00 2001 From: Agrendalath Date: Tue, 15 Sep 2026 22:32:51 +0200 Subject: [PATCH 2/2] feat(pathways): implement Pathway content --- .importlinter | 15 + docs/openedx_learning/index.rst | 2 +- src/openedx_learning/README.rst | 14 +- src/openedx_learning/admin.py | 1 + .../applets/pathways/__init__.py | 3 + .../applets/pathways/admin.py | 135 +++++++ .../applets/pathways/models.py | 368 ++++++++++++++++++ src/openedx_learning/apps.py | 25 ++ .../migrations/0002_pathways.py | 307 +++++++++++++++ src/openedx_learning/models.py | 1 + src/openedx_learning/models_api.py | 15 +- .../applets/pathways/__init__.py | 3 + .../applets/pathways/test_models.py | 107 +++++ 13 files changed, 988 insertions(+), 8 deletions(-) create mode 100644 src/openedx_learning/applets/pathways/__init__.py create mode 100644 src/openedx_learning/applets/pathways/admin.py create mode 100644 src/openedx_learning/applets/pathways/models.py create mode 100644 src/openedx_learning/migrations/0002_pathways.py create mode 100644 tests/openedx_learning/applets/pathways/__init__.py create mode 100644 tests/openedx_learning/applets/pathways/test_models.py diff --git a/.importlinter b/.importlinter index 85bba4a0d..fc4286927 100644 --- a/.importlinter +++ b/.importlinter @@ -77,3 +77,18 @@ layers= # to create Learning Packages and manage the draft and publish states for # various types of content. openedx_content.applets.publishing + +# This is "applet" layering, within our Learning djangoapp. +# Every new applet should be added to this list when it is created. +[importlinter:contract:learning_applet_layering] +name = "openedx_learning's internal applets are layered correctly" +type = layers +layers = + # The public API is at the top. None of the internal applets should call to it. + openedx_learning.api + + # The "pathways" applet holds the versioned definition of what a learner must complete + # to earn a larger achievement. The "cbe" applet models learner mastery of competencies. + # These are independent of each other today; if a future fulfillment type lets a + # competency attainment fulfill a Pathway Item, pathways would move above cbe. + openedx_learning.applets.pathways | openedx_learning.applets.cbe diff --git a/docs/openedx_learning/index.rst b/docs/openedx_learning/index.rst index b8d08d8c4..397f27ef4 100644 --- a/docs/openedx_learning/index.rst +++ b/docs/openedx_learning/index.rst @@ -3,7 +3,7 @@ openedx_learning ================ -Django app for learner-facing models including competency-based education. +Django app for what learners are meant to achieve and how they get there: competency-based education, and Pathways. .. toctree:: :maxdepth: 1 diff --git a/src/openedx_learning/README.rst b/src/openedx_learning/README.rst index 33c11ea54..d1c2ccc4e 100644 --- a/src/openedx_learning/README.rst +++ b/src/openedx_learning/README.rst @@ -4,8 +4,14 @@ Learning App The ``openedx_learning`` app holds models and APIs for what learners are meant to achieve and how they get there. Its sibling ``openedx_content`` holds the material itself. -Like ``openedx_content``, it is one Django app split into applets. Its first applet is -``cbe``, for Competency-Based Education; Learning Pathways are expected to follow. +Like ``openedx_content``, it is one Django app split into applets. ``cbe`` covers +Competency-Based Education; ``pathways`` covers Pathways - the versioned definition +of what a learner must complete to earn a larger achievement. -In the layering that ``.importlinter`` enforces, this app sits above ``openedx_content`` -and ``openedx_tagging``. It may build on either of them; neither may import it. +In the layering that ``.importlinter`` enforces, this app sits above ``openedx_catalog``, +``openedx_content``, and ``openedx_tagging``. It may build on any of them; none of them may +import it. ``pathways`` builds on ``openedx_content``'s publishing primitives to version +Pathway definitions and references ``openedx_catalog``'s ``CourseRun``. The catalog's +``CatalogPathway`` in turn points at a Pathway's ``PublishableEntity`` - which it can do +because ``openedx_catalog`` itself sits above ``openedx_content`` - and this app's API is +what sets and resolves that link. diff --git a/src/openedx_learning/admin.py b/src/openedx_learning/admin.py index e065c4048..9b7384d3f 100644 --- a/src/openedx_learning/admin.py +++ b/src/openedx_learning/admin.py @@ -4,3 +4,4 @@ # pylint: disable=wildcard-import from .applets.cbe.admin import * +from .applets.pathways.admin import * diff --git a/src/openedx_learning/applets/pathways/__init__.py b/src/openedx_learning/applets/pathways/__init__.py new file mode 100644 index 000000000..fa23918a4 --- /dev/null +++ b/src/openedx_learning/applets/pathways/__init__.py @@ -0,0 +1,3 @@ +""" +The Pathways applet: what a learner must do to earn a larger achievement. +""" diff --git a/src/openedx_learning/applets/pathways/admin.py b/src/openedx_learning/applets/pathways/admin.py new file mode 100644 index 000000000..8d766a719 --- /dev/null +++ b/src/openedx_learning/applets/pathways/admin.py @@ -0,0 +1,135 @@ +""" +Django Admin pages for Pathways models. + +These pages are read-oriented on purpose. Pathway content is versioned, and versions are immutable, so editing rows in +place here would corrupt the record of what the definition was at a given moment. Create new versions through +``openedx_learning.api`` instead. +""" + +from __future__ import annotations + +from django.contrib import admin +from django.db.models import QuerySet +from django.utils.translation import gettext_lazy as _ + +from openedx_catalog.models_api import CatalogPathway + +from .models import Pathway, PathwayItem, PathwayItemCourseRun, PathwayItemVersion, PathwayVersion, PathwayVersionItem + +__all__ = [ + "PathwayAdmin", + "PathwayVersionAdmin", + "PathwayItemAdmin", + "PathwayItemVersionAdmin", +] + + +class ReadOnlyAdminMixin: + """ + Mixin that makes a versioned model visible in the admin but not editable. + """ + + # pylint: disable=unused-argument + # The arguments are part of the ModelAdmin/InlineModelAdmin signatures Django calls. + + def has_add_permission(self, request, obj=None) -> bool: + return False + + def has_change_permission(self, request, obj=None) -> bool: + return False + + +class PathwayAdmin(ReadOnlyAdminMixin, admin.ModelAdmin): + """ + The Pathway model admin. + """ + + list_display = ["pathway_code", "learning_package", "catalog_pathway", "created"] + list_filter = ["learning_package"] + search_fields = ["pathway_code"] + + def get_queryset(self, request) -> QuerySet[Pathway]: + """Pull in the catalog entry that points at each Pathway, so the list doesn't query per row""" + return super().get_queryset(request).select_related("publishable_entity__catalog_pathway") + + @admin.display(description=_("Catalog Pathway")) + def catalog_pathway(self, obj: Pathway) -> CatalogPathway | None: + """The learner-facing catalog entry this Pathway defines, if one points at it""" + return getattr(obj.publishable_entity, "catalog_pathway", None) + + +admin.site.register(Pathway, PathwayAdmin) + + +class PathwayVersionItemInline(admin.TabularInline): + """The ordered list of Items in a Pathway version.""" + + model = PathwayVersionItem + fields = ["order_num", "pathway_item"] + readonly_fields = ["order_num", "pathway_item"] + can_delete = False + extra = 0 + + def has_add_permission(self, request, obj=None) -> bool: + return False + + +class PathwayVersionAdmin(ReadOnlyAdminMixin, admin.ModelAdmin): + """ + The PathwayVersion model admin. + """ + + list_display = ["__str__", "pathway", "item_count"] + list_filter = ["pathway__learning_package"] + inlines = [PathwayVersionItemInline] + + @admin.display(description=_("Items")) + def item_count(self, obj: PathwayVersion) -> int: + """How many Items this version of the Pathway holds""" + return obj.item_rows.count() # type: ignore + + +admin.site.register(PathwayVersion, PathwayVersionAdmin) + + +class PathwayItemAdmin(ReadOnlyAdminMixin, admin.ModelAdmin): + """ + The PathwayItem model admin. + """ + + list_display = ["item_code", "learning_package", "created"] + list_filter = ["learning_package"] + search_fields = ["item_code"] + + +admin.site.register(PathwayItem, PathwayItemAdmin) + + +class PathwayItemCourseRunInline(admin.TabularInline): + """The course runs that fulfill a version of a Pathway Item.""" + + model = PathwayItemCourseRun + fields = ["order_num", "course_run", "is_default", "enrollment_track"] + readonly_fields = ["order_num", "course_run", "is_default", "enrollment_track"] + can_delete = False + extra = 0 + + def has_add_permission(self, request, obj=None) -> bool: + return False + + +class PathwayItemVersionAdmin(ReadOnlyAdminMixin, admin.ModelAdmin): + """ + The PathwayItemVersion model admin. + """ + + list_display = ["__str__", "pathway_item", "course_run_count"] + inlines = [PathwayItemCourseRunInline] + + @admin.display(description=_("Fulfilling course runs")) + def course_run_count(self, obj: PathwayItemVersion) -> int: + """How many course runs can fulfill this version of the Item""" + return obj.course_run_rows.count() # type: ignore + + +admin.site.register(PathwayItemVersion, PathwayItemVersionAdmin) diff --git a/src/openedx_learning/applets/pathways/models.py b/src/openedx_learning/applets/pathways/models.py new file mode 100644 index 000000000..e0f8980db --- /dev/null +++ b/src/openedx_learning/applets/pathways/models.py @@ -0,0 +1,368 @@ +""" +Models for Pathways: the versioned definition of what a learner must complete. + +The model hierarchy is :class:`Pathway` → :class:`PathwayVersion` → :class:`PathwayVersionItem` → :class:`PathwayItem` → +:class:`PathwayItemVersion` → :class:`PathwayItemCourseRun`. + +A Pathway is split in two (see the ``openedx_learning`` ADR 0007). The catalog half - display name, category, +description, enrollment - lives in ``openedx_catalog`` as :class:`~openedx_catalog.models.CatalogPathway` +and is not versioned. This module is the content half: the *definition* of the Pathway, which is versioned, so that +progress can always be judged against the definition that was in effect at the time. + +The one link between the halves is ``CatalogPathway.content_entity``, which points at a :class:`Pathway`'s +``publishable_entity``. It lives on the catalog side because a context points at its content, never the reverse +(``openedx_catalog`` ADR 0001). ``openedx_catalog`` sits below this app, so it can only type that field as a bare +``PublishableEntity``; the API in this applet is what sets the link and resolves it to a :class:`Pathway`. + +The boundary between :class:`Pathway` and :class:`PathwayItem` (ADR 0005) is what keeps the Pathway level stable while +fulfillment evolves: a Pathway holds an ordered list of Items and computes its completion from theirs, and never reaches +into what fulfills each Item. The mapping from an Item to the things that fulfill it (ADR 0006) is deliberately confined +to :class:`PathwayItemCourseRun`, which is the natural extension point for everything post-MVP. +""" + +from __future__ import annotations + +from typing import NewType, cast + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from openedx_catalog.models_api import CourseRun +from openedx_content.models_api import ( + LearningPackage, + PublishableEntity, + PublishableEntityMixin, + PublishableEntityVersionMixin, +) +from openedx_django_lib.fields import case_sensitive_char_field, code_field, code_field_check + +__all__ = [ + "Pathway", + "PathwayVersion", + "PathwayVersionItem", + "PathwayItem", + "PathwayItemVersion", + "PathwayItemCourseRun", +] + + +class Pathway(PublishableEntityMixin): + """ + The versioned content half of a Pathway: an ordered list of requirements. + + A :class:`Pathway` is 1:1 with a :class:`PublishableEntity` and has matching primary key values, so it is versioned, + published, and reverted through the ordinary publishing machinery. + + A Pathway is *not* a :class:`~openedx_content.models_api.Container`. The parent-child relation here breaks no new + ground structurally, but Pathways don't need Container's full complexity - pinning children to specific versions, + dynamic membership, OLX serialization - so they use their own simple through-model, :class:`PathwayVersionItem`. + + Completion: in the MVP, a Pathway is complete when *all* of its Items are complete. That isn't configurable yet. + Configurable criteria are expected later, and are meant to be expressed in terms of Item completion rather than + in terms of what fulfills each Item - so adding them should not require touching :class:`PathwayItemCourseRun`. + + The learner-facing :class:`~openedx_catalog.models.CatalogPathway` that a Pathway serves points at it through + ``CatalogPathway.content_entity``. That link is unversioned - the catalog entry follows whichever version of the + Pathway is published - and optional in both directions: a Pathway may exist without a catalog entry while it is + being drafted, and a catalog entry may exist before its Pathway does. Use ``get_catalog_pathway_for_pathway()`` and + ``get_pathway_for_catalog_pathway()`` to cross between the two. + + .. no_pii: + """ + + PathwayID = NewType("PathwayID", PublishableEntity.ID) + type ID = PathwayID + + learning_package = models.ForeignKey(LearningPackage, on_delete=models.CASCADE) + """ + Technically redundant - we're already locked to a single LearningPackage through ``publishable_entity`` - but having + the foreign key directly lets us index efficiently by other Pathway fields within a given LearningPackage. This + mirrors what :class:`~openedx_content.models_api.Container` does. + """ + + pathway_code = code_field(unicode=True) + """ + A slug-like identifier that is local to the ``learning_package``. + """ + + @property + def id(self) -> ID: + return cast(Pathway.ID, self.publishable_entity_id) # type: ignore + + class Meta: # type: ignore + verbose_name = _("Pathway") + verbose_name_plural = _("Pathways") + constraints = [ + models.UniqueConstraint( + fields=["learning_package", "pathway_code"], + name="oel_pathways_pathway_uniq_lp_code", + ), + code_field_check("pathway_code", name="oel_pathways_pathway_code_regex", unicode=True), + ] + + +class PathwayVersion(PublishableEntityVersionMixin): + """ + A specific version of a :class:`Pathway`. + + A new version is created when the *definition* changes: an Item is added, removed, or reordered, or the Pathway's + own metadata changes. Catalog edits never create one - that's the point of the split. + + .. no_pii: + """ + + pathway = models.ForeignKey( + Pathway, + on_delete=models.CASCADE, + related_name="versions", + ) + + items: models.ManyToManyField[PathwayItem, PathwayVersionItem] = models.ManyToManyField( + "PathwayItem", + through="PathwayVersionItem", + related_name="pathway_versions", + ) + """ + The Items in this version of the Pathway, in author-defined order. Use ``pathway_version.item_rows`` to read them in + order along with their ``order_num``. + """ + + class Meta: # type: ignore + verbose_name = _("Pathway Version") + verbose_name_plural = _("Pathway Versions") + + +class PathwayVersionItem(models.Model): + """ + One :class:`PathwayItem`'s place in the ordered list of a :class:`PathwayVersion`. + + The order is author-defined and is the order in which Items are presented to learners. It does **not** currently + constrain the order of *completion*; enforcing that is planned for a later iteration (ADR 0005, decision 1). + + References to Items are always unpinned - an Item row points at the :class:`PathwayItem`, not at one of its + versions - so a Pathway always reflects the current state of its Items. Items are declared as dependencies of the + :class:`PathwayVersion` (see the publishing app's ``set_version_dependencies``) so that changes to an Item still + register as changes to the Pathway that contains it. + + .. no_pii: + """ + + id = models.BigAutoField(primary_key=True) + + pathway_version = models.ForeignKey( + PathwayVersion, + on_delete=models.CASCADE, + related_name="item_rows", + ) + pathway_item = models.ForeignKey( + "PathwayItem", + on_delete=models.RESTRICT, + related_name="version_rows", + ) + order_num = models.PositiveIntegerField() + """ + Position within the Pathway, starting at 0. Immutable for a given :class:`PathwayVersion`: reordering means creating + a new PathwayVersion. + """ + + def __str__(self) -> str: + return f"{self.pathway_version} #{self.order_num}: {self.pathway_item}" + + class Meta: + verbose_name = _("Pathway Version Item") + verbose_name_plural = _("Pathway Version Items") + ordering = ["order_num"] + constraints = [ + models.UniqueConstraint( + fields=["pathway_version", "order_num"], + name="oel_pathways_pvi_uniq_version_order", + ), + # An Item appears at most once in a given version of a Pathway. Listing it twice would make "3 of 6 Items + # complete" ambiguous for no benefit. + models.UniqueConstraint( + fields=["pathway_version", "pathway_item"], + name="oel_pathways_pvi_uniq_version_item", + ), + ] + + +class PathwayItem(PublishableEntityMixin): + """ + A single requirement within a Pathway, with its own identity and lifecycle. + + An Item may be fulfilled by one thing today - passing a course - and by something else tomorrow - a competency + attainment, an admin override - without changing its identity, and therefore without changing the Pathway that + contains it. That stability is the whole reason Items are modeled separately from what fulfills them (ADR 0005, + decision 2). + + An Item is complete or not; that is the entire contract for now. It can be extended later to carry grades or other + metadata additively, if Pathway-level criteria or learner-facing displays need it. + + :class:`PathwayItem` is a :class:`PublishableEntity` in its own right, so an author can revise an Item repeatedly in + draft and have only the published result reach learners. + + .. no_pii: + """ + + PathwayItemID = NewType("PathwayItemID", PublishableEntity.ID) + type ID = PathwayItemID + + learning_package = models.ForeignKey(LearningPackage, on_delete=models.CASCADE) + """ + Redundant with ``publishable_entity.learning_package``, for the same indexing reasons as + :attr:`Pathway.learning_package`. + """ + + item_code = code_field(unicode=True) + """ + A slug-like identifier that is local to the ``learning_package``. + """ + + @property + def id(self) -> ID: + return cast(PathwayItem.ID, self.publishable_entity_id) # type: ignore + + class Meta: # type: ignore + verbose_name = _("Pathway Item") + verbose_name_plural = _("Pathway Items") + constraints = [ + models.UniqueConstraint( + fields=["learning_package", "item_code"], + name="oel_pathways_item_uniq_lp_code", + ), + code_field_check("item_code", name="oel_pathways_item_code_regex", unicode=True), + ] + + +class PathwayItemVersion(PublishableEntityVersionMixin): + """ + A specific version of a :class:`PathwayItem`. + + This is where fulfillment is defined: the list of course runs that fulfill the Item, held as + :class:`PathwayItemCourseRun` rows. Versioning it here is what makes "publishing changes to a Pathway Item" a + meaningful event - the third moment at which fulfillment is evaluated (ADR 0006, decision 5). + + .. no_pii: + """ + + pathway_item = models.ForeignKey( + PathwayItem, + on_delete=models.CASCADE, + related_name="versions", + ) + + course_runs: models.ManyToManyField[CourseRun, PathwayItemCourseRun] = models.ManyToManyField( + CourseRun, + through="PathwayItemCourseRun", + related_name="fulfilled_pathway_item_versions", + ) + """ + The course runs that fulfill this version of the Item. Use ``pathway_item_version.course_run_rows`` to read them in + order along with their ``is_default`` and ``enrollment_track`` values. + """ + + @property + def default_course_run_row(self) -> PathwayItemCourseRun | None: + """ + The course run a learner is enrolled in when they begin this Item. + + ``None`` if the author hasn't designated one, which is possible while an Item is still being drafted. + """ + return self.course_run_rows.filter(is_default=True).first() # type: ignore + + class Meta: # type: ignore + verbose_name = _("Pathway Item Version") + verbose_name_plural = _("Pathway Item Versions") + + +class PathwayItemCourseRun(models.Model): + """ + A course run whose passing fulfills a :class:`PathwayItemVersion`. + + Passing *any one* of an Item's runs fulfills it; the runs may belong to different catalog courses. The list is + explicit, rather than "any run of this catalog course", because we don't necessarily want every older run of a + course to count. The cost is that authors must update the list by hand when new runs are created. + + "Passing" is determined by each course's own grading policy. Pathways define no grading of their own and store no + copy of grades, so there is nothing here to keep in sync. + + Edge cases are resolved at this layer and never leak upward: several passed runs fulfilling the same Item just means + the Item is fulfilled, and one passed run fulfilling several Items means each of those Items is fulfilled + independently. + + This model is the mapping layer that ADR 0006 expects to iterate on. Future fulfillment types - section completion, + competency attainment, admin override - plug in here as alternative ways to fulfill an Item, without touching Item + identity, Pathway structure, or Pathway completion criteria. + + .. no_pii: + """ + + id = models.BigAutoField(primary_key=True) + + pathway_item_version = models.ForeignKey( + PathwayItemVersion, + on_delete=models.CASCADE, + related_name="course_run_rows", + ) + course_run = models.ForeignKey( + CourseRun, + on_delete=models.RESTRICT, + related_name="+", + ) + order_num = models.PositiveIntegerField() + """ + Author-defined display order, starting at 0. It carries no meaning for fulfillment - passing any one run fulfills + the Item. + """ + + is_default = models.BooleanField( + default=False, + help_text=_("The run a learner is enrolled in when they begin this Item. At most one per Item version."), + ) + """ + Modeling the default as a flag on the list, rather than as a foreign key on :class:`PathwayItemVersion`, is what + guarantees the default is always one of the runs that actually fulfills the Item. The default can change over time, + which means a new :class:`PathwayItemVersion`. + """ + + enrollment_track = case_sensitive_char_field( + max_length=100, + blank=True, + default="", + help_text=_( + "If the default run uses multiple enrollment tracks, the track to enroll the learner in. " + "Only meaningful on the default run; leave blank otherwise." + ), + ) + + def __str__(self) -> str: + suffix = " (default)" if self.is_default else "" + return f"{self.course_run}{suffix}" + + class Meta: + verbose_name = _("Pathway Item Course Run") + verbose_name_plural = _("Pathway Item Course Runs") + ordering = ["order_num"] + constraints = [ + models.UniqueConstraint( + fields=["pathway_item_version", "course_run"], + name="oel_pathways_picr_uniq_version_run", + ), + models.UniqueConstraint( + fields=["pathway_item_version", "order_num"], + name="oel_pathways_picr_uniq_version_order", + ), + # At most one default run per Item version. + # MySQL and MariaDB ignore conditional unique constraints, so this would only be enforced by the database + # on backends that support partial indexes. However, the API already enforces it on every backend. + models.UniqueConstraint( + fields=["pathway_item_version"], + condition=models.Q(is_default=True), + name="oel_pathways_picr_one_default", + ), + models.CheckConstraint( + condition=models.Q(is_default=True) | models.Q(enrollment_track=""), + name="oel_pathways_picr_track_only_on_default", + violation_error_message=_("An enrollment track can only be set on the default course run."), + ), + ] diff --git a/src/openedx_learning/apps.py b/src/openedx_learning/apps.py index 070504eba..d841a8e07 100644 --- a/src/openedx_learning/apps.py +++ b/src/openedx_learning/apps.py @@ -3,6 +3,12 @@ """ from django.apps import AppConfig +# pylint: disable=import-outside-toplevel +# +# Local imports in AppConfig.ready() are common and expected in Django, since +# Django needs to run initialization before we can query for things like models, +# settings, and app config. + class LearningConfig(AppConfig): """ @@ -13,3 +19,22 @@ class LearningConfig(AppConfig): verbose_name = "Open edX Core > Learning" default_auto_field = "django.db.models.BigAutoField" label = "openedx_learning" + + def register_publishable_models(self): + """ + Register all Publishable -> Version model pairings in our app. + """ + from openedx_content.api import register_publishable_models + + from .models import Pathway, PathwayItem, PathwayItemVersion, PathwayVersion + + register_publishable_models(Pathway, PathwayVersion) + register_publishable_models(PathwayItem, PathwayItemVersion) + + def ready(self): + """ + Currently used to register publishable models. + + May later be used to register signal handlers as well. + """ + self.register_publishable_models() diff --git a/src/openedx_learning/migrations/0002_pathways.py b/src/openedx_learning/migrations/0002_pathways.py new file mode 100644 index 000000000..c1429adee --- /dev/null +++ b/src/openedx_learning/migrations/0002_pathways.py @@ -0,0 +1,307 @@ +# Generated by Django 5.2.17 on 2026-09-15 12:29 + +import re + +import django.core.validators +import django.db.models.deletion +import django.db.models.lookups +from django.db import migrations, models + +import openedx_django_lib.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ("openedx_catalog", "0002_pathways"), + ("openedx_content", "0014_typed_media_id"), + ("openedx_learning", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Pathway", + fields=[ + ( + "publishable_entity", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + serialize=False, + to="openedx_content.publishableentity", + ), + ), + ( + "pathway_code", + openedx_django_lib.fields.MultiCollationCharField( + db_collations={"mysql": "utf8mb4_bin", "sqlite": "BINARY"}, + max_length=255, + validators=[ + django.core.validators.RegexValidator( + re.compile("^[\\w.-]+\\Z"), + 'Enter a valid "code name" consisting of any letters, numbers, underscores, hyphens, or periods.', + "invalid", + ) + ], + ), + ), + ( + "learning_package", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_content.learningpackage" + ), + ), + ], + options={ + "verbose_name": "Pathway", + "verbose_name_plural": "Pathways", + }, + ), + migrations.CreateModel( + name="PathwayItem", + fields=[ + ( + "publishable_entity", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + serialize=False, + to="openedx_content.publishableentity", + ), + ), + ( + "item_code", + openedx_django_lib.fields.MultiCollationCharField( + db_collations={"mysql": "utf8mb4_bin", "sqlite": "BINARY"}, + max_length=255, + validators=[ + django.core.validators.RegexValidator( + re.compile("^[\\w.-]+\\Z"), + 'Enter a valid "code name" consisting of any letters, numbers, underscores, hyphens, or periods.', + "invalid", + ) + ], + ), + ), + ( + "learning_package", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_content.learningpackage" + ), + ), + ], + options={ + "verbose_name": "Pathway Item", + "verbose_name_plural": "Pathway Items", + }, + ), + migrations.CreateModel( + name="PathwayItemCourseRun", + fields=[ + ("id", models.BigAutoField(primary_key=True, serialize=False)), + ("order_num", models.PositiveIntegerField()), + ( + "is_default", + models.BooleanField( + default=False, + help_text="The run a learner is enrolled in when they begin this Item. At most one per Item version.", + ), + ), + ( + "enrollment_track", + openedx_django_lib.fields.MultiCollationCharField( + blank=True, + db_collations={"mysql": "utf8mb4_bin", "sqlite": "BINARY"}, + default="", + help_text="If the default run uses multiple enrollment tracks, the track to enroll the learner in. Only meaningful on the default run; leave blank otherwise.", + max_length=100, + ), + ), + ( + "course_run", + models.ForeignKey( + on_delete=django.db.models.deletion.RESTRICT, related_name="+", to="openedx_catalog.courserun" + ), + ), + ], + options={ + "verbose_name": "Pathway Item Course Run", + "verbose_name_plural": "Pathway Item Course Runs", + "ordering": ["order_num"], + }, + ), + migrations.CreateModel( + name="PathwayItemVersion", + fields=[ + ( + "publishable_entity_version", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + serialize=False, + to="openedx_content.publishableentityversion", + ), + ), + ( + "course_runs", + models.ManyToManyField( + related_name="fulfilled_pathway_item_versions", + through="openedx_learning.PathwayItemCourseRun", + to="openedx_catalog.courserun", + ), + ), + ( + "pathway_item", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="versions", + to="openedx_learning.pathwayitem", + ), + ), + ], + options={ + "verbose_name": "Pathway Item Version", + "verbose_name_plural": "Pathway Item Versions", + }, + ), + migrations.AddField( + model_name="pathwayitemcourserun", + name="pathway_item_version", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="course_run_rows", + to="openedx_learning.pathwayitemversion", + ), + ), + migrations.CreateModel( + name="PathwayVersion", + fields=[ + ( + "publishable_entity_version", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + serialize=False, + to="openedx_content.publishableentityversion", + ), + ), + ( + "pathway", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="versions", + to="openedx_learning.pathway", + ), + ), + ], + options={ + "verbose_name": "Pathway Version", + "verbose_name_plural": "Pathway Versions", + }, + ), + migrations.CreateModel( + name="PathwayVersionItem", + fields=[ + ("id", models.BigAutoField(primary_key=True, serialize=False)), + ("order_num", models.PositiveIntegerField()), + ( + "pathway_item", + models.ForeignKey( + on_delete=django.db.models.deletion.RESTRICT, + related_name="version_rows", + to="openedx_learning.pathwayitem", + ), + ), + ( + "pathway_version", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="item_rows", + to="openedx_learning.pathwayversion", + ), + ), + ], + options={ + "verbose_name": "Pathway Version Item", + "verbose_name_plural": "Pathway Version Items", + "ordering": ["order_num"], + }, + ), + migrations.AddField( + model_name="pathwayversion", + name="items", + field=models.ManyToManyField( + related_name="pathway_versions", + through="openedx_learning.PathwayVersionItem", + to="openedx_learning.pathwayitem", + ), + ), + migrations.AddConstraint( + model_name="pathway", + constraint=models.UniqueConstraint( + fields=("learning_package", "pathway_code"), name="oel_pathways_pathway_uniq_lp_code" + ), + ), + migrations.AddConstraint( + model_name="pathway", + constraint=models.CheckConstraint( + condition=django.db.models.lookups.Regex(models.F("pathway_code"), "^[\\w.-]+\\Z"), + name="oel_pathways_pathway_code_regex", + violation_error_message='Enter a valid "code name" consisting of any letters, numbers, underscores, hyphens, or periods.', + ), + ), + migrations.AddConstraint( + model_name="pathwayitem", + constraint=models.UniqueConstraint( + fields=("learning_package", "item_code"), name="oel_pathways_item_uniq_lp_code" + ), + ), + migrations.AddConstraint( + model_name="pathwayitem", + constraint=models.CheckConstraint( + condition=django.db.models.lookups.Regex(models.F("item_code"), "^[\\w.-]+\\Z"), + name="oel_pathways_item_code_regex", + violation_error_message='Enter a valid "code name" consisting of any letters, numbers, underscores, hyphens, or periods.', + ), + ), + migrations.AddConstraint( + model_name="pathwayitemcourserun", + constraint=models.UniqueConstraint( + fields=("pathway_item_version", "course_run"), name="oel_pathways_picr_uniq_version_run" + ), + ), + migrations.AddConstraint( + model_name="pathwayitemcourserun", + constraint=models.UniqueConstraint( + fields=("pathway_item_version", "order_num"), name="oel_pathways_picr_uniq_version_order" + ), + ), + migrations.AddConstraint( + model_name="pathwayitemcourserun", + constraint=models.UniqueConstraint( + condition=models.Q(("is_default", True)), + fields=("pathway_item_version",), + name="oel_pathways_picr_one_default", + ), + ), + migrations.AddConstraint( + model_name="pathwayitemcourserun", + constraint=models.CheckConstraint( + condition=models.Q(("is_default", True), ("enrollment_track", ""), _connector="OR"), + name="oel_pathways_picr_track_only_on_default", + violation_error_message="An enrollment track can only be set on the default course run.", + ), + ), + migrations.AddConstraint( + model_name="pathwayversionitem", + constraint=models.UniqueConstraint( + fields=("pathway_version", "order_num"), name="oel_pathways_pvi_uniq_version_order" + ), + ), + migrations.AddConstraint( + model_name="pathwayversionitem", + constraint=models.UniqueConstraint( + fields=("pathway_version", "pathway_item"), name="oel_pathways_pvi_uniq_version_item" + ), + ), + ] diff --git a/src/openedx_learning/models.py b/src/openedx_learning/models.py index c008b9905..425317ed8 100644 --- a/src/openedx_learning/models.py +++ b/src/openedx_learning/models.py @@ -5,3 +5,4 @@ # pylint: disable=wildcard-import from .applets.cbe.models import * +from .applets.pathways.models import * diff --git a/src/openedx_learning/models_api.py b/src/openedx_learning/models_api.py index 4f02b4f98..734a0b599 100644 --- a/src/openedx_learning/models_api.py +++ b/src/openedx_learning/models_api.py @@ -1,9 +1,18 @@ """ Models that we want callers to extend or make foreign keys to. -This is also the stable import point for the model class itself, for callers that -need to create competency taxonomies directly. +This is also the stable import point for the model classes themselves, for callers that need to create competency +taxonomies directly. Pathway models should be created through `openedx_learning.api`, which keeps their versioning +consistent; import them here only to make foreign keys to them. """ # pylint: disable=unused-import -from .models import CompetencyTaxonomy +from .models import ( + CompetencyTaxonomy, + Pathway, + PathwayItem, + PathwayItemCourseRun, + PathwayItemVersion, + PathwayVersion, + PathwayVersionItem, +) diff --git a/tests/openedx_learning/applets/pathways/__init__.py b/tests/openedx_learning/applets/pathways/__init__.py new file mode 100644 index 000000000..00c22593a --- /dev/null +++ b/tests/openedx_learning/applets/pathways/__init__.py @@ -0,0 +1,3 @@ +""" +Tests for the Pathways applet. +""" diff --git a/tests/openedx_learning/applets/pathways/test_models.py b/tests/openedx_learning/applets/pathways/test_models.py new file mode 100644 index 000000000..81cce9cb7 --- /dev/null +++ b/tests/openedx_learning/applets/pathways/test_models.py @@ -0,0 +1,107 @@ +""" +Tests of the database-level guarantees the Pathways models make. + +The API validates these too, but the constraints are what protects the data from callers that reach past it. +""" + +from __future__ import annotations + +import pytest +from django.db import IntegrityError, transaction +from django.db.models import RestrictedError + +from openedx_learning import api as learning_api +from openedx_learning.models_api import PathwayItemCourseRun, PathwayVersionItem + +from .test_api import PathwaysTestCase + + +class PathwayConstraintsTest(PathwaysTestCase): + """ + Constraints on Pathways and their Items. + """ + + def test_pathway_code_unique_within_learning_package(self): + self.create_pathway("data-science") + with pytest.raises(IntegrityError), transaction.atomic(): + self.create_pathway("data-science") + + def test_item_code_unique_within_learning_package(self): + self.create_item("item-1") + with pytest.raises(IntegrityError), transaction.atomic(): + self.create_item("item-1") + + def test_an_item_cannot_appear_twice_in_a_version(self): + item_1, _ = self.create_item("item-1") + _pathway, version = self.create_pathway(items=[item_1]) + + with pytest.raises(IntegrityError), transaction.atomic(): + PathwayVersionItem.objects.create(pathway_version=version, pathway_item=item_1, order_num=1) + + def test_two_items_cannot_share_a_position(self): + item_1, _ = self.create_item("item-1") + item_2, _ = self.create_item("item-2") + _pathway, version = self.create_pathway(items=[item_1]) + + with pytest.raises(IntegrityError), transaction.atomic(): + PathwayVersionItem.objects.create(pathway_version=version, pathway_item=item_2, order_num=0) + + def test_an_item_in_use_cannot_be_deleted(self): + """ + RESTRICT, so that removing an Item from a Pathway is a new version rather than a hole in an old one. + """ + item_1, _ = self.create_item("item-1") + self.create_pathway(items=[item_1]) + + with pytest.raises(RestrictedError), transaction.atomic(): + item_1.delete() + + +class PathwayItemCourseRunConstraintsTest(PathwaysTestCase): + """ + Constraints on the mapping from an Item to the runs that fulfill it. + """ + + def test_a_run_cannot_be_listed_twice(self): + _item, version = self.create_item("item-1", course_runs=[self.run_a1]) + with pytest.raises(IntegrityError), transaction.atomic(): + PathwayItemCourseRun.objects.create(pathway_item_version=version, course_run=self.run_a1, order_num=1) + + def test_two_runs_cannot_share_a_position(self): + _item, version = self.create_item("item-1", course_runs=[self.run_a1]) + with pytest.raises(IntegrityError), transaction.atomic(): + PathwayItemCourseRun.objects.create(pathway_item_version=version, course_run=self.run_a2, order_num=0) + + def test_at_most_one_default_run(self): + """ + Conditional unique constraints are ignored by MySQL and MariaDB, so on those backends this invariant rests on + the API check alone. + """ + _item, version = self.create_item( + "item-1", + course_runs=[learning_api.FulfillingCourseRun(course_run=self.run_a1, is_default=True)], + ) + with pytest.raises(IntegrityError), transaction.atomic(): + PathwayItemCourseRun.objects.create( + pathway_item_version=version, + course_run=self.run_a2, + order_num=1, + is_default=True, + ) + + def test_enrollment_track_requires_the_default_flag(self): + _item, version = self.create_item("item-1", course_runs=[self.run_a1]) + with pytest.raises(IntegrityError), transaction.atomic(): + PathwayItemCourseRun.objects.create( + pathway_item_version=version, + course_run=self.run_a2, + order_num=1, + is_default=False, + enrollment_track="verified", + ) + + def test_a_run_in_use_cannot_be_deleted(self): + """RESTRICT, so a run can't vanish out of an Item's fulfillment list.""" + self.create_item("item-1", course_runs=[self.run_a1]) + with pytest.raises(RestrictedError), transaction.atomic(): + self.run_a1.delete()