diff --git a/.importlinter b/.importlinter
index 17dd176f6..fc4286927 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.
@@ -69,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_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 over every PublishableEntity in the system.
+ if obj: # editing an existing object
+ return ("content_entity", "org", "pathway_code")
+ return ("content_entity",)
+
+ @admin.display(description="Organization", ordering="org__short_name")
+ def org_display(self, obj: CatalogPathway) -> str:
+ """Display the organization, only showing the short_name if different from full name"""
+ if obj.org.name == obj.org.short_name:
+ return obj.org.short_name
+ return str(obj.org)
+
+ @admin.display(description=_("Created"), ordering="created")
+ def created_date(self, obj: CatalogPathway) -> datetime.date:
+ """Display the created date without the timestamp"""
+ return obj.created.date()
+
+
+admin.site.register(CatalogPathway, CatalogPathwayAdmin)
+
+
+class PathwayEnrollmentAdmin(admin.ModelAdmin):
+ """
+ The PathwayEnrollment model admin.
+ """
+
+ list_display = ["user", "catalog_pathway", "is_active", "created_date", "modified"]
+ list_filter = ["is_active", "catalog_pathway__category"]
+ # There may be very many users and a fair number of pathways, so don't use
+ 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/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_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}"
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()