diff --git a/.importlinter b/.importlinter index 13bbad04c..36ff3ed17 100644 --- a/.importlinter +++ b/.importlinter @@ -80,6 +80,21 @@ layers= # 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 + # Mirrors openedx-platform's isolated-apps contract (see test_utils/isolated_apps_contract.py): # code outside an isolated app may only import its public modules (api, models_api, data, # tests). src_layering above only controls which top-level packages may depend on which. diff --git a/docs/openedx_learning/decisions/0006-pathway-item-fulfillment.rst b/docs/openedx_learning/decisions/0006-pathway-item-fulfillment.rst index 34dcbd26b..db4098c12 100644 --- a/docs/openedx_learning/decisions/0006-pathway-item-fulfillment.rst +++ b/docs/openedx_learning/decisions/0006-pathway-item-fulfillment.rst @@ -25,12 +25,17 @@ Decisions - Each Item holds an **author-defined list of course runs** that fulfill it. Passing *any one* of them fulfills the Item. The runs may belong to different catalog courses. + - Items are never shared: each belongs to exactly one Pathway. Course runs are what Pathways share - the same run + may appear in the lists of Items in several Pathways. - The list is explicit rather than "any run of this catalog course", because we don't necessarily want every possible older version of a course to count. - "Passing" is determined by each course's own grading policy. The grade and passed/failed state are read directly from the course; Pathways define no grading of their own. - - Each Item designates a **default course run** - the one a learner is enrolled in when they begin the Item - and, - if that course uses multiple enrollment tracks, the track to enroll them in. The default can change over time. + - The list is **ordered by priority**. Priority decides which run a learner is enrolled in when they begin the Item + and, for a learner already enrolled in several of the runs, which one to show them. It plays no part in + fulfillment, which always considers every run in the list. Putting a newer run ahead of an older one is how an + author gives a learner who failed the older run another attempt. Each run also carries the enrollment track to + enroll learners in, if that course uses several. The order can change over time. 2. Fulfillment types attach at this layer only. Potential future types - section completion, competency attainment, admin override - plug in as alternative ways to fulfill an Item, without touching Item identity, Pathway structure, @@ -89,8 +94,17 @@ Consequences - Because passing state comes directly from course grading, there is no Pathway-side duplication of grades to keep in sync. - Only the third trigger needs an async task: the first two act on a single learner, while publishing an Item change - fans out across everyone enrolled in the Pathways that contain it. + fans out across everyone enrolled in the Pathway that contains it. - Publishing an Item change on a large Pathway is therefore not instantaneous. Retroactive credentials appear once the task completes. - The fulfillment mapping is the natural extension point for everything post-MVP, and the part of the model we expect to iterate on in code. + +Changelog +--------- + +2026-09-23: + +* Replaced the single default course run with a priority order over the whole list of runs, so that a learner enrolled + in several of an Item's runs is shown a well-defined one. +* Stated that Items belong to exactly one Pathway, while course runs may be shared between Pathways. diff --git a/docs/openedx_learning/decisions/0007-pathway-catalog-content-split.rst b/docs/openedx_learning/decisions/0007-pathway-catalog-content-split.rst index ecb9966a0..401a11f13 100644 --- a/docs/openedx_learning/decisions/0007-pathway-catalog-content-split.rst +++ b/docs/openedx_learning/decisions/0007-pathway-catalog-content-split.rst @@ -38,8 +38,9 @@ Decisions ``openedx_catalog`` app. - **Pathway content** - the definition of the Pathway: its Items and its completion criteria. The content is - **versioned**, so that we can always tell what the definition was at any given moment. A version of the Pathway - content *implements* a Catalog Pathway. + **versioned**, so that we can always tell what the definition was at any given moment. The Pathway content + *implements* exactly one Catalog Pathway, and a Catalog Pathway is implemented by at most one Pathway. That link + is fixed for the Pathway's whole life, so it is not versioned: every version implements the same Catalog Pathway. 2. The **Category** is a student-facing label for the kind of Pathway (e.g. "Master's Degree", "Annual Training"). Learners see the Category rather than the word "Pathway". It is always required: rather than falling back to @@ -63,15 +64,15 @@ Decisions Example content of each model: -============================ =================================== +============================ ===================================== Catalog Pathway Pathway content -============================ =================================== +============================ ===================================== Display name Pathway Items Category Completion criteria Description References to CourseRuns SEO metadata Link to the related Catalog Pathway -Enrollment -============================ =================================== +Enrollment (one-to-one, not versioned) +============================ ===================================== .. Run `dot -Tsvg images/pathway-catalog-content.dot > images/pathway-catalog-content.svg` to regenerate the diagram after making changes to `images/pathway-catalog-content.dot`. @@ -87,5 +88,19 @@ Consequences - Because evaluation follows the published version rather than the enrollment-time version, edits to a Pathway apply to learners who are already enrolled, which is what we want, but it means edits need care and re-evaluation. - The unversioned Catalog Pathway can be long-lived even if its content definition is changed significantly over time. +- Because the link is fixed and one-to-one, a Catalog Pathway can't be switched over to a different Pathway; changing + what it requires means a new version of the Pathway that already implements it. In exchange, anything that records a + content version, such as a credential, can always tell which Catalog Pathway that version implemented. - The dependency direction means a Catalog Pathway cannot, on its own, tell which content implements it. Queries in that direction start from the content side. + +Changelog +--------- + +2026-09-23: + +* The link to the Catalog Pathway is held by the Pathway content as a whole rather than by each of its versions, and is + one-to-one: a Pathway implements exactly one Catalog Pathway for its whole life, and a Catalog Pathway is implemented + by at most one Pathway. +* The diagram now shows the Pathway content in ``openedx_learning``, where it lives, built on the versioning that + ``openedx_content`` provides. diff --git a/docs/openedx_learning/decisions/images/pathway-catalog-content.dot b/docs/openedx_learning/decisions/images/pathway-catalog-content.dot index 10bafb89f..bffcceecb 100644 --- a/docs/openedx_learning/decisions/images/pathway-catalog-content.dot +++ b/docs/openedx_learning/decisions/images/pathway-catalog-content.dot @@ -16,13 +16,17 @@ digraph pathway_catalog_content { } subgraph cluster_content { - label="openedx_content (versioned)"; + label="openedx_learning (versioned)"; + labelloc="b"; fontsize=10; fontcolor="#4d4d4d"; style=dashed; color="#b3b3b3"; - v1 [label="content v1\n\nItems, criteria"]; - v2 [label="content v2\n\nItems, criteria\n(currently published)"]; + pathway [label="Pathway"]; + v1 [label="version 1\n\nItems, criteria"]; + v2 [label="version 2\n\nItems, criteria\n(currently published)"]; + pathway -> v1 [style=dotted, color="#808080", arrowsize=0.7]; + pathway -> v2 [style=dotted, color="#808080", arrowsize=0.7]; v1 -> v2 [style=dotted, color="#808080", arrowsize=0.7, label="revision"]; } @@ -33,8 +37,8 @@ digraph pathway_catalog_content { enrollment -> catalog [label="enrolled in"]; enrollment -> v2 [label="progress evaluated\nagainst the currently\npublished version", style=dashed, color="#808080", fontcolor="#4d4d4d"]; - v2 -> catalog [label="implements"]; + pathway -> catalog [label="implements\n(one-to-one, fixed)"]; v2 -> courserun [label="Items reference"]; - direction [label="dependency direction:\nopenedx_content knows about\nopenedx_catalog, never the reverse", shape=plaintext, fontsize=10, fontcolor="#4d4d4d"]; + direction [label="dependency direction:\nthe content side knows about\nopenedx_catalog, never the reverse", shape=plaintext, fontsize=10, fontcolor="#4d4d4d"]; } diff --git a/docs/openedx_learning/decisions/images/pathway-catalog-content.svg b/docs/openedx_learning/decisions/images/pathway-catalog-content.svg index df9a619a9..2b772edaa 100644 --- a/docs/openedx_learning/decisions/images/pathway-catalog-content.svg +++ b/docs/openedx_learning/decisions/images/pathway-catalog-content.svg @@ -1,14 +1,14 @@ - - - + + pathway_catalog_content - + cluster_catalog @@ -16,8 +16,8 @@ cluster_content - -openedx_content (versioned) + +openedx_learning (versioned) @@ -33,83 +33,102 @@ CourseRun - + +pathway + +Pathway + + + +pathway->catalog + + +implements +(one-to-one, fixed) + + + v1 - -content v1 -Items, criteria + +version 1 +Items, criteria + + + +pathway->v1 + + - + v2 -content v2 +version 2 Items, criteria (currently published) + + +pathway->v2 + + + - + v1->v2 - - -revision - - - -v2->catalog - - -implements + + +revision - + v2->courserun Items reference - + user - -User + +User - + enrollment - -Enrollment + +Enrollment - + user->enrollment - - -learner + + +learner - + enrollment->catalog - - -enrolled in + + +enrolled in - + enrollment->v2 - - -progress evaluated -against the currently -published version + + +progress evaluated +against the currently +published version - + direction -dependency direction: -openedx_content knows about -openedx_catalog, never the reverse +dependency direction: +the content side knows about +openedx_catalog, never the reverse diff --git a/docs/openedx_learning/decisions/images/pathway-item-fulfillment.dot b/docs/openedx_learning/decisions/images/pathway-item-fulfillment.dot index bf92643f3..34ea50d35 100644 --- a/docs/openedx_learning/decisions/images/pathway-item-fulfillment.dot +++ b/docs/openedx_learning/decisions/images/pathway-item-fulfillment.dot @@ -5,9 +5,9 @@ digraph pathway_item_fulfillment { edge [fontname="Helvetica", fontsize=10]; item [label="Pathway Item"]; - runA [label="Course Run A1\n(default: enroll here)"]; - runB [label="Course Run A2\n(older run, still counts)"]; - runC [label="Course Run B1\n(different catalog course)"]; + runA [label="1. Course Run A1\n(top priority: enroll here)"]; + runB [label="2. Course Run A2\n(older run, still counts)"]; + runC [label="3. Course Run B1\n(different catalog course)"]; future [label="future fulfillment types:\nsection completion,\ncompetency, admin override", style="rounded,dashed"]; item -> runA [label="pass any one"]; diff --git a/docs/openedx_learning/decisions/images/pathway-item-fulfillment.svg b/docs/openedx_learning/decisions/images/pathway-item-fulfillment.svg index 94b29dc44..e883676aa 100644 --- a/docs/openedx_learning/decisions/images/pathway-item-fulfillment.svg +++ b/docs/openedx_learning/decisions/images/pathway-item-fulfillment.svg @@ -1,7 +1,7 @@ - runA - -Course Run A1 -(default: enroll here) + +1. Course Run A1 +(top priority: enroll here) @@ -33,7 +33,7 @@ runB -Course Run A2 +2. Course Run A2 (older run, still counts) @@ -46,7 +46,7 @@ runC -Course Run B1 +3. Course Run B1 (different catalog course) 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/pyproject.toml b/pyproject.toml index b6336ba0b..6bc546035 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,9 @@ multi_line_output = 3 [tool.ruff] line-length = 120 +[tool.ruff.lint] +ignore = ["RUF012"] + [tool.setuptools_scm] version_scheme = "only-version" local_scheme = "no-local-version" diff --git a/src/openedx_learning/README.rst b/src/openedx_learning/README.rst index ba21bcfb8..4a594bb4a 100644 --- a/src/openedx_learning/README.rst +++ b/src/openedx_learning/README.rst @@ -4,11 +4,15 @@ 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. - -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 may import it. +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``, +``openedx_catalog``, and ``openedx_tagging``. It may build on any of them; none of them may +import it. ``pathways`` uses that in both directions: it builds on ``openedx_content``'s +publishing primitives to version Pathway definitions, and references ``openedx_catalog``'s +``CourseRun`` and ``CatalogPathway`` - a dependency that only ever points this way. Setting up a consuming project ------------------------------ 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/api.py b/src/openedx_learning/api.py index 789a315f3..3e52e44e3 100644 --- a/src/openedx_learning/api.py +++ b/src/openedx_learning/api.py @@ -1,6 +1,7 @@ """ This is the public API for learning-domain features in Open edX Core. """ -# This wildcard import is okay because the applet api module declares __all__. +# These wildcard imports are okay because the applet api modules declare __all__. # pylint: disable=wildcard-import from .applets.cbe.api import * +from .applets.pathways.api import * diff --git a/src/openedx_learning/applets/pathways/README.rst b/src/openedx_learning/applets/pathways/README.rst new file mode 100644 index 000000000..32f45ec65 --- /dev/null +++ b/src/openedx_learning/applets/pathways/README.rst @@ -0,0 +1,93 @@ +Pathways Applet +=============== + +A Pathway is a set of requirements a learner works through to earn some larger achievement. Each requirement is a +**Pathway Item**. + +The three ADRs behind this applet are in ``docs/openedx_learning/decisions/``: 0005 (the Pathway/Item boundary), +0006 (mapping Items to what fulfills them), and 0007 (the catalog/content split), + +Where things live +----------------- + +A Pathway is split in two, for the same reasons courses are. + +The **catalog half** is ``openedx_catalog.models.CatalogPathway``: display name, ``PathwayCategory``, description, +and enrollment. It is *not* versioned, because marketing copy is revised frequently and casually and is +typically maintained by different people than the definition. + +The **content half** is this applet: ``Pathway`` and ``PathwayItem``, both ``PublishableEntity`` subclasses, so the +definition is versioned and can always be inspected as of a given moment. + +The link between them, ``Pathway.catalog_pathway``, lives here and never in ``openedx_catalog`` - that app must not know +about anything above it. It is one-to-one, so a Catalog Pathway is implemented by at most one Pathway, and it is fixed +for the Pathway's whole life, so it isn't versioned. A ``CatalogPathway`` cannot tell you what implements it; ask +``get_pathway_for_catalog_pathway()``, which looks it up from this side. To list Pathways, list their Catalog Pathways +with ``openedx_catalog.api.get_catalog_pathways()``, which filters by org and category. + +Each Pathway has a Learning Package of its own, named after its Catalog Pathway's key and never shared with another +Pathway, so that publishing, backing up, or deleting the package does exactly that to the one Pathway and its Items. + +The Pathway/Item boundary +------------------------- + +``Pathway`` holds an ordered list of ``PathwayItem`` objects through ``PathwayVersionItem``. The order is author-defined +and is the presentation order; it does not yet constrain the order of completion. + +A Pathway owns its Items: each ``PathwayItem`` belongs to exactly one Pathway (``PathwayItem.pathway``) and is never +listed in another. What Pathways share is course runs - the same run may fulfill Items in several Pathways. Because an +Item needs its Pathway to exist first, a new Pathway starts with an empty version; its Items are created afterwards and +listed in the next version. + +Item completion and Pathway completion are separate concerns. An Item is complete or not, decided by its own fulfillment +rules. A Pathway's completion is computed from its Items' completion and never reaches past that boundary. In the MVP a +Pathway is complete when all of its Items are; configurable criteria are expected later. + +That boundary is why new fulfillment types - section completion, competency attainment, admin override - can be added +without restructuring Pathways or rewriting how Pathway completion is computed. + +Fulfillment, today +------------------ + +``PathwayItemCourseRun`` maps a ``PathwayItemVersion`` to the course runs that fulfill it. Passing *any one* of them +fulfills the Item, and the runs may belong to different catalog courses. The list is explicit rather than "any run of +this catalog course", because not every older run should necessarily count - the trade-off being that authors update the +list by hand as new runs appear. + +The list is ordered by **priority**, and priority matters only outside fulfillment. It picks the run a learner is +enrolled in when they begin the Item, and, for a learner already enrolled in several of them, the run to show on their +dashboard; each row also carries the track to enroll them in if that run uses several. Putting a newer run ahead of an +older one is how an author gives a learner who failed the older run another attempt. Fulfillment itself ignores the +order and considers every run in the list. + +Pathways define no grading. Whether a learner passed a run is decided by that course's own grading policy and is read +from the course; nothing here stores a copy, so there are no grades to keep in sync. + +What isn't here yet +------------------- + +**Evaluating fulfillment for a learner.** ADR 0006 describes three moments at which it should happen: + +1. A course passing-status signal, re-evaluating the Items whose run list includes that run, for the Pathways that + learner is enrolled in. +2. Pathway enrollment, a one-time pass over everything the learner has already passed - this is what makes prior work + count. +3. Publishing a change to a Pathway Item, which fans out asynchronously over the learners enrolled in the Pathway + containing it. This trigger exists because the first two miss the case where a learner passes a run, enrolls, and + *then* an author edits an Item so that run fulfills it. + +Only the third needs an async task; the first two act on a single learner. Retroactive evaluation only ever grants - +narrowing an Item by dropping a run does not revoke a credential already awarded. + +The read-only lookups those triggers need already exist. For the first, ``get_pathways_containing_course_run()`` finds +every Pathway the run counts towards, and ``get_pathway_items_fulfilled_by_course_run()`` the Items to re-evaluate. For +the third, the Item's own ``pathway`` is the only Pathway affected. + +**Reading what a learner has passed.** ``openedx-core`` has no grades app, and ADR 0006 rules out duplicating grades on +the Pathway side. So the evaluation code should reach course passing state through a pluggable provider, resolved from a +Django setting, whose real implementation is backed by ``PersistentCourseGrade``. That implementation belongs in +``openedx-platform`` or in a standalone plugin, not here. + +**Learner-state models.** No fulfillment or completion records exist yet; the credentials design is not settled. Nothing +in the current models has to be revisited to add them - Item completion is a yes/no contract that can be extended +additively. 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..7af123dc4 --- /dev/null +++ b/src/openedx_learning/applets/pathways/admin.py @@ -0,0 +1,113 @@ +""" +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.utils.translation import gettext_lazy as _ + +from openedx_django_lib.admin_utils import ReadOnlyModelAdmin + +from .models import Pathway, PathwayItem, PathwayItemCourseRun, PathwayItemVersion, PathwayVersion, PathwayVersionItem + +__all__ = [ + "PathwayAdmin", + "PathwayItemAdmin", + "PathwayItemVersionAdmin", + "PathwayVersionAdmin", +] + + +class PathwayAdmin(ReadOnlyModelAdmin): + """ + The Pathway model admin. + """ + + list_display = ["catalog_pathway", "learning_package", "created"] + list_filter = ["catalog_pathway__category"] + list_select_related = ["catalog_pathway__org", "learning_package", "publishable_entity"] + search_fields = ["catalog_pathway__title", "catalog_pathway__pathway_code"] + + +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(ReadOnlyModelAdmin): + """ + The PathwayVersion model admin. + """ + + list_display = ["__str__", "pathway", "item_count"] + list_filter = ["pathway__catalog_pathway__category"] + list_select_related = ["pathway__catalog_pathway__org", "publishable_entity_version__entity"] + 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(ReadOnlyModelAdmin): + """ + The PathwayItem model admin. + """ + + list_display = ["item_code", "pathway", "created"] + list_filter = ["pathway__catalog_pathway__category"] + list_select_related = ["pathway__catalog_pathway__org", "publishable_entity"] + search_fields = ["item_code", "pathway__catalog_pathway__title", "pathway__catalog_pathway__pathway_code"] + + +admin.site.register(PathwayItem, PathwayItemAdmin) + + +class PathwayItemCourseRunInline(admin.TabularInline): + """The course runs that fulfill a version of a Pathway Item, in priority order.""" + + model = PathwayItemCourseRun + fields = ["order_num", "course_run", "enrollment_track"] + readonly_fields = ["order_num", "course_run", "enrollment_track"] + can_delete = False + extra = 0 + + def has_add_permission(self, request, obj=None) -> bool: + return False + + +class PathwayItemVersionAdmin(ReadOnlyModelAdmin): + """ + 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/api.py b/src/openedx_learning/applets/pathways/api.py new file mode 100644 index 000000000..f729c7862 --- /dev/null +++ b/src/openedx_learning/applets/pathways/api.py @@ -0,0 +1,651 @@ +""" +Public API for Pathways. + +This module manages the *content* half of a Pathway - its Items and, for now, the course runs that fulfill them. The +catalog half (display name, category, description, enrollment) is managed through ``openedx_catalog.api``. + +Each Pathway implements exactly one Catalog Pathway, and lives in a Learning Package of its own. It owns its Items: each +Item belongs to exactly one Pathway, and can only be created once that Pathway exists. Course runs, on the other hand, +are shared: the same run may fulfill Items in several Pathways. Building a Pathway therefore goes: + +1. `create_pathway_and_version()` creates the Pathway for a Catalog Pathway, with an empty first version; +2. `create_pathway_item_and_version()` creates each of its Items; +3. `create_next_pathway_version()` lists those Items in the Pathway, in order. + +To find the Pathway of a Catalog Pathway, use `get_pathway_for_catalog_pathway()`. To list Pathways, list their Catalog +Pathways through ``openedx_catalog.api``, which is where the org, category and other catalog data live. + +Two things this API deliberately does not do: + +* It defines no grading. Whether a learner passed a course run is determined by that course's own grading policy, and is + read from the course. Nothing here stores a copy. +* It does not evaluate fulfillment for a learner. See the applet README for the three moments at which that is meant to + happen, and where that code belongs. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +from datetime import UTC, datetime + +from django.core.exceptions import ValidationError +from django.db.models import QuerySet +from django.db.transaction import atomic + +from openedx_catalog.models_api import CatalogPathway, CourseRun +from openedx_content import api as content_api +from openedx_content.models_api import LearningPackage, PublishableEntity + +from .models import Pathway, PathwayItem, PathwayItemCourseRun, PathwayItemVersion, PathwayVersion, PathwayVersionItem + +# `create_pathway`, `create_pathway_version`, `create_pathway_item` and `create_pathway_item_version` are deliberately +# left out: they are low-level building blocks for import/restore flows. See their docstrings. +__all__ = [ + "CourseRunListEntry", + "FulfillingCourseRun", + "PathwayItemListEntry", + "create_next_pathway_item_version", + "create_next_pathway_version", + "create_pathway_and_version", + "create_pathway_item_and_version", + "get_course_runs_for_item", + "get_items_in_pathway", + "get_pathway", + "get_pathway_for_catalog_pathway", + "get_pathway_item", + "get_pathway_item_by_code", + "get_pathway_items_fulfilled_by_course_run", + "get_pathways_containing_course_run", +] + + +@dataclass(frozen=True) +class FulfillingCourseRun: + """ + One course run in the author-defined list that fulfills a Pathway Item. + + Position in that list is a priority, and priority says nothing about fulfillment: passing any one of an Item's runs + fulfills it. It only decides which run a learner is enrolled in or shown, and ``enrollment_track`` is the track to + enroll them in if this run uses several. + """ + + course_run: CourseRun + enrollment_track: str = "" + + +def _reset_cached_draft(entity: PublishableEntity) -> None: + """ + Drop a cached ``draft`` relation after creating a new version. + + Without this, a caller that holds the model instance it passed in would keep reading the previous draft. Mirrors + what the containers API does. + """ + if PublishableEntity.draft.is_cached(entity): # type: ignore # pylint: disable=no-member + PublishableEntity.draft.related.delete_cached_value(entity) # type: ignore # pylint: disable=no-member + + +def _created_or_now(created: datetime | None) -> datetime: + """ + Resolve an optional ``created`` argument, defaulting to the current time. + + Resolve it once per call and pass the result down, so that every row a single call creates shares one timestamp. + """ + return created if created is not None else datetime.now(tz=UTC) + + +@dataclass(frozen=True) +class PathwayItemListEntry: + """ + One Item's place in a Pathway, resolved to a specific version of that Item. + """ + + pathway_item_version: PathwayItemVersion + order_num: int + + @property + def pathway_item(self) -> PathwayItem: + return self.pathway_item_version.pathway_item + + +@dataclass(frozen=True) +class CourseRunListEntry: + """ + One course run that fulfills a version of a Pathway Item. ``order_num`` is its priority, lowest first. + """ + + course_run: CourseRun + order_num: int + enrollment_track: str + + +def _validate_fulfilling_course_runs(course_runs: list[FulfillingCourseRun]) -> None: + """ + Reject a run listed twice, which would make its priority ambiguous. + + The database enforces this too, but raising here gives callers a `ValidationError` rather than an `IntegrityError`. + """ + seen: set[CourseRun.ID] = set() + for entry in course_runs: + if entry.course_run.id in seen: + raise ValidationError(f'Course run "{entry.course_run}" is listed more than once for this Pathway Item.') + seen.add(entry.course_run.id) + + +def _get_or_create_own_package(catalog_pathway: CatalogPathway, created: datetime) -> LearningPackage: + """ + Get the Learning Package of the Pathway that implements ``catalog_pathway``, creating it if it doesn't exist yet. + + The package is named after the Catalog Pathway's key, which is unique across the instance, so that it's easy to + recognize. The name isn't what keeps Pathways and packages one-to-one, though; the database constraints on + `Pathway` are. So an existing package with that name is reused, and creating the Pathway in it fails unless no + Pathway lives there yet - as when a deleted Pathway left its package behind. + """ + package_ref = catalog_pathway.key_str + try: + return content_api.get_learning_package_by_ref(package_ref) + except LearningPackage.DoesNotExist: + return content_api.create_learning_package( + package_ref=package_ref, + title=catalog_pathway.title, + created=created, + ) + + +def create_pathway( + learning_package_id: LearningPackage.ID, + /, + catalog_pathway: CatalogPathway, + created: datetime, + created_by: int | None, + *, + can_stand_alone: bool = True, +) -> Pathway: + """ + Create a new `Pathway` implementing ``catalog_pathway``, with no versions yet. + + This is a low-level building block for import/restore flows, which recreate an entity and then replay its version + history with the original version numbers and timestamps. It is not part of the public API, because a Pathway with + no versions is incomplete; use `create_pathway_and_version()` instead. + + The package must not hold another Pathway, and ``catalog_pathway`` must not be implemented by one; the database + enforces both. The ``entity_ref`` is conventionally ``"pathway"``, since the package holds only this one, although + callers should not assume that this will always be true. + """ + with atomic(): + publishable_entity = content_api.create_publishable_entity( + learning_package_id, + "pathway", + created, + created_by, + can_stand_alone=can_stand_alone, + ) + pathway = Pathway.objects.create( + publishable_entity=publishable_entity, + catalog_pathway=catalog_pathway, + learning_package_id=learning_package_id, + ) + return pathway + + +def create_pathway_version( + pathway_id: Pathway.ID, + /, + version_num: int, + *, + title: str, + items: Iterable[PathwayItem] = (), + created: datetime, + created_by: int | None = None, +) -> PathwayVersion: + """ + Create a new version of a `Pathway`, with an explicit version number. + + This is a low-level building block for import/restore flows, and is not part of the public API. Use + `create_next_pathway_version()` instead, which numbers the version and carries unchanged fields forward. + + Args: + pathway_id: The Pathway to create a version of. + version_num: The version number, starting at 1. + title: The Pathway's authoring-side title. + items: The Pathway's Items, in the order learners see them. Each must belong to this Pathway. + created: The creation date. + created_by: The ID of the user who created this version. + + The Items are referenced unpinned and are declared as dependencies of this version, so that publishing a change to + an Item registers as a change to the Pathway that contains it. + + Raises `ValidationError` if any of the Items belongs to a different Pathway. + """ + item_list = list(items) + foreign_items = [item for item in item_list if item.pathway_id != pathway_id] # type: ignore + if foreign_items: + codes = ", ".join(f'"{item.item_code}"' for item in foreign_items) + raise ValidationError(f"Pathway Items {codes} belong to a different Pathway and can't be listed in this one.") + + with atomic(): + publishable_entity_version = content_api.create_publishable_entity_version( + pathway_id, + version_num=version_num, + title=title, + created=created, + created_by=created_by, + dependencies=[item.id for item in item_list], + ) + pathway_version = PathwayVersion.objects.create( + publishable_entity_version=publishable_entity_version, + pathway_id=pathway_id, + ) + PathwayVersionItem.objects.bulk_create( + PathwayVersionItem( + pathway_version=pathway_version, + pathway_item=item, + order_num=order_num, + ) + for order_num, item in enumerate(item_list) + ) + return pathway_version + + +def create_pathway_and_version( + *, + catalog_pathway: CatalogPathway, + title: str, + created: datetime | None = None, + created_by: int | None = None, + can_stand_alone: bool = True, +) -> tuple[Pathway, PathwayVersion]: + """ + Create the `Pathway` implementing ``catalog_pathway``, with a first `PathwayVersion` that lists no Items yet. + + The Pathway gets a Learning Package of its own, named after the key of ``catalog_pathway``, so that publishing the + package publishes exactly this Pathway and its Items. + + Items belong to their Pathway, so they can only be created once it exists: create them with + `create_pathway_item_and_version()`, then list them with `create_next_pathway_version()`. + + Args: + catalog_pathway: The learner-facing Catalog Pathway this Pathway implements, for its whole life. + title: The Pathway's authoring-side title. + created: Defaults to now. + created_by: The ID of the user creating the Pathway. + + Raises `IntegrityError` if ``catalog_pathway`` is already implemented by a Pathway. + """ + created = _created_or_now(created) + with atomic(): + learning_package = _get_or_create_own_package(catalog_pathway, created) + pathway = create_pathway( + learning_package.id, + catalog_pathway, + created, + created_by, + can_stand_alone=can_stand_alone, + ) + pathway_version = create_pathway_version( + pathway.id, + 1, + title=title, + created=created, + created_by=created_by, + ) + return pathway, pathway_version + + +def create_next_pathway_version( + pathway: Pathway | Pathway.ID, + /, + *, + title: str | None = None, + items: Iterable[PathwayItem] | None = None, + created: datetime | None = None, + created_by: int | None = None, +) -> PathwayVersion: + """ + Create the next version of a `Pathway`. + + Pass `None` for ``title`` or ``items`` to keep it as-is. ``created`` defaults to now. + + A new version is what records a change to the *definition*: an Item added, removed, or reordered, or the Pathway's + own metadata changed. Editing the catalog copy never comes through here, and which Catalog Pathway the Pathway + implements can't change at all. + + Raises `ValidationError` if any of the Items belongs to a different Pathway. + """ + created = _created_or_now(created) + with atomic(): + if isinstance(pathway, int): + pathway = get_pathway(pathway) + last_version = pathway.versioning.latest + if last_version is None: + raise PathwayVersion.DoesNotExist(f"{pathway} has no versions yet; use create_pathway_version().") + assert isinstance(last_version, PathwayVersion) + + if items is None: + items = [row.pathway_item for row in last_version.item_rows.all()] # type: ignore + + next_version = create_pathway_version( + pathway.id, + last_version.version_num + 1, + title=title if title is not None else last_version.title, + items=items, + created=created, + created_by=created_by, + ) + + _reset_cached_draft(pathway.publishable_entity) + return next_version + + +def get_pathway(pathway_id: Pathway.ID, /) -> Pathway: + """ + Get a `Pathway` by its ID. + """ + return Pathway.objects.get(pk=pathway_id) + + +def get_pathway_for_catalog_pathway(catalog_pathway: CatalogPathway | CatalogPathway.ID, /) -> Pathway | None: + """ + Get the `Pathway` implementing ``catalog_pathway``, or `None` if there isn't one yet. + + The link isn't versioned, so this doesn't depend on what has been published: a Pathway implements its Catalog + Pathway from the moment it's created, even while it has no published version, and even if it has been + soft-deleted. Check ``pathway.versioning`` for that. + """ + catalog_pathway_id = catalog_pathway.id if isinstance(catalog_pathway, CatalogPathway) else catalog_pathway + try: + return Pathway.objects.get(catalog_pathway_id=catalog_pathway_id) + except Pathway.DoesNotExist: + return None + + +def create_pathway_item( + pathway: Pathway | Pathway.ID, + /, + item_code: str, + created: datetime, + created_by: int | None, + *, + can_stand_alone: bool = False, +) -> PathwayItem: + """ + Create a new `PathwayItem` of ``pathway``, with no versions yet. + + This is a low-level building block for import/restore flows, and is not part of the public API, because an Item + with no versions is incomplete. Use `create_pathway_item_and_version()` instead. + + The Item is created in the Pathway's Learning Package. Its ``entity_ref`` is conventionally derived as + ``"pathway-item:{item_code}"``, although callers should not assume that this will always be true. + + ``can_stand_alone`` defaults to `False` because an Item only makes sense as part of its Pathway. + """ + if isinstance(pathway, int): + pathway = get_pathway(pathway) + entity_ref = f"pathway-item:{item_code}" + with atomic(): + publishable_entity = content_api.create_publishable_entity( + pathway.learning_package_id, + entity_ref, + created, + created_by, + can_stand_alone=can_stand_alone, + ) + pathway_item = PathwayItem.objects.create( + publishable_entity=publishable_entity, + pathway=pathway, + item_code=item_code, + ) + return pathway_item + + +def create_pathway_item_version( + pathway_item_id: PathwayItem.ID, + /, + version_num: int, + *, + title: str, + course_runs: Iterable[FulfillingCourseRun | CourseRun] = (), + created: datetime, + created_by: int | None = None, +) -> PathwayItemVersion: + """ + Create a new version of a `PathwayItem`, with an explicit version number. + + This is a low-level building block for import/restore flows, and is not part of the public API. Use + `create_next_pathway_item_version()` instead, which numbers the version and carries unchanged fields forward. + + Args: + pathway_item_id: The Item to create a version of. + version_num: The version number, starting at 1. + title: The Item's title, as shown to learners and authors. + course_runs: The runs that fulfill this Item, highest priority first. Passing a bare `CourseRun` is shorthand + for one with no enrollment track. Priority decides which run a learner is enrolled in or shown; every run + in the list fulfills the Item regardless of its position. + created: The creation date. + created_by: The ID of the user who created this version. + + Raises `ValidationError` if a run is listed twice. + """ + entries = [ + run if isinstance(run, FulfillingCourseRun) else FulfillingCourseRun(course_run=run) for run in course_runs + ] + _validate_fulfilling_course_runs(entries) + + with atomic(): + publishable_entity_version = content_api.create_publishable_entity_version( + pathway_item_id, + version_num=version_num, + title=title, + created=created, + created_by=created_by, + ) + item_version = PathwayItemVersion.objects.create( + publishable_entity_version=publishable_entity_version, + pathway_item_id=pathway_item_id, + ) + PathwayItemCourseRun.objects.bulk_create( + PathwayItemCourseRun( + pathway_item_version=item_version, + course_run=entry.course_run, + order_num=order_num, + enrollment_track=entry.enrollment_track, + ) + for order_num, entry in enumerate(entries) + ) + return item_version + + +def create_pathway_item_and_version( + pathway: Pathway | Pathway.ID, + /, + item_code: str, + *, + title: str, + course_runs: Iterable[FulfillingCourseRun | CourseRun] = (), + created: datetime | None = None, + created_by: int | None = None, + can_stand_alone: bool = False, +) -> tuple[PathwayItem, PathwayItemVersion]: + """ + Create a new `PathwayItem` of ``pathway`` and its first `PathwayItemVersion` together. + + The Item belongs to ``pathway`` for good, but creating it doesn't list it in the Pathway yet; do that with + `create_next_pathway_version()`. ``created`` defaults to now. See `create_pathway_item_version()` for + ``course_runs``. + """ + created = _created_or_now(created) + with atomic(): + pathway_item = create_pathway_item( + pathway, + item_code, + created, + created_by, + can_stand_alone=can_stand_alone, + ) + item_version = create_pathway_item_version( + pathway_item.id, + 1, + title=title, + course_runs=course_runs, + created=created, + created_by=created_by, + ) + return pathway_item, item_version + + +def create_next_pathway_item_version( + pathway_item: PathwayItem | PathwayItem.ID, + /, + *, + title: str | None = None, + course_runs: Iterable[FulfillingCourseRun | CourseRun] | None = None, + created: datetime | None = None, + created_by: int | None = None, +) -> PathwayItemVersion: + """ + Create the next version of a `PathwayItem`. + + Pass `None` for ``title`` or ``course_runs`` to keep it as-is. ``created`` defaults to now. + + Note that narrowing an Item - dropping a course run that used to fulfill it - is not meant to take a credential away + from a learner who has already earned one. Retroactive evaluation only ever grants. + """ + created = _created_or_now(created) + with atomic(): + if isinstance(pathway_item, int): + pathway_item = get_pathway_item(pathway_item) + last_version = pathway_item.versioning.latest + if last_version is None: + raise PathwayItemVersion.DoesNotExist( + f'Pathway Item "{pathway_item.item_code}" has no versions yet; use create_pathway_item_version().' + ) + assert isinstance(last_version, PathwayItemVersion) + + if course_runs is None: + course_runs = [ + FulfillingCourseRun(course_run=row.course_run, enrollment_track=row.enrollment_track) + for row in last_version.course_run_rows.select_related("course_run") # type: ignore + ] + + next_version = create_pathway_item_version( + pathway_item.id, + last_version.version_num + 1, + title=title if title is not None else last_version.title, + course_runs=course_runs, + created=created, + created_by=created_by, + ) + + _reset_cached_draft(pathway_item.publishable_entity) + return next_version + + +def get_pathway_item(pathway_item_id: PathwayItem.ID, /) -> PathwayItem: + """ + Get a `PathwayItem` by its ID. + """ + return PathwayItem.objects.get(pk=pathway_item_id) + + +def get_pathway_item_by_code(pathway: Pathway | Pathway.ID, /, item_code: str) -> PathwayItem: + """ + Get a `PathwayItem` by its code, within the Pathway it belongs to. + """ + pathway_id = pathway.id if isinstance(pathway, Pathway) else pathway + return PathwayItem.objects.get(pathway_id=pathway_id, item_code=item_code) + + +def get_items_in_pathway(pathway: Pathway, *, published: bool) -> list[PathwayItemListEntry]: + """ + Get the Items in the draft or published version of a `Pathway`, in order. + + Items are referenced unpinned, so each Item is resolved to its own draft or published version, matching the + ``published`` argument. Items that have no version in that state - never published, or soft-deleted - are skipped, + so the returned list can be shorter than the Pathway's list of Items. + + Raises `PathwayVersion.DoesNotExist` if the Pathway itself has no version in the requested state. + """ + pathway_version = pathway.versioning.published if published else pathway.versioning.draft + if pathway_version is None: + raise PathwayVersion.DoesNotExist(f"{pathway} has no {'published' if published else 'draft'} version.") + assert isinstance(pathway_version, PathwayVersion) + + entries = [] + rows = pathway_version.item_rows.select_related( # type: ignore + "pathway_item__publishable_entity__draft__version", + "pathway_item__publishable_entity__published__version", + ) + for row in rows: + item_version = row.pathway_item.versioning.published if published else row.pathway_item.versioning.draft + if item_version is None: + continue + assert isinstance(item_version, PathwayItemVersion) + entries.append(PathwayItemListEntry(pathway_item_version=item_version, order_num=row.order_num)) + return entries + + +def get_course_runs_for_item(pathway_item: PathwayItem, *, published: bool) -> list[CourseRunListEntry]: + """ + Get the course runs that fulfill the draft or published version of an Item, highest priority first. + + Passing any one of these runs fulfills the Item, whatever its priority. Returns an empty list if the Item has no + version in the requested state. + """ + item_version = pathway_item.versioning.published if published else pathway_item.versioning.draft + if item_version is None: + return [] + assert isinstance(item_version, PathwayItemVersion) + return [ + CourseRunListEntry( + course_run=row.course_run, + order_num=row.order_num, + enrollment_track=row.enrollment_track, + ) + for row in item_version.course_run_rows.select_related("course_run") # type: ignore + ] + + +def get_pathway_items_fulfilled_by_course_run( + course_run: CourseRun | CourseRun.ID, + *, + published: bool, +) -> QuerySet[PathwayItem]: + """ + Get the Items that passing this course run would fulfill. + + This is the "which Items does this run count towards" query that a course passing-status signal needs in order to + know what to re-evaluate. It answers for the whole site; narrowing to one learner's Pathways is the caller's job. + """ + course_run_id = course_run.id if isinstance(course_run, CourseRun) else course_run + state = "published" if published else "draft" + return PathwayItem.objects.filter( + **{ + f"publishable_entity__{state}__version__pathwayitemversion__course_run_rows__course_run_id": course_run_id, + } + ).distinct() + + +def get_pathways_containing_course_run( + course_run: CourseRun | CourseRun.ID, + *, + published: bool, +) -> QuerySet[Pathway]: + """ + Get the Pathways whose draft or published definition this course run counts towards. + + Items are never shared between Pathways, but course runs are, so one run may count towards several Pathways through + different Items. This is the fan-out behind a course passing-status signal: narrow the result to the Pathways the + learner is enrolled in, then re-evaluate their Items with `get_pathway_items_fulfilled_by_course_run()`. + + Both the Pathway and its Items are resolved in the requested state, so an Item that is no longer listed in the + Pathway's version, or whose version no longer lists the run, doesn't count, even though the Item still belongs to + the Pathway. + """ + course_run_id = course_run.id if isinstance(course_run, CourseRun) else course_run + state = "published" if published else "draft" + return Pathway.objects.filter( + **{ + f"publishable_entity__{state}__version__pathwayversion__item_rows__pathway_item__" + f"publishable_entity__{state}__version__pathwayitemversion__course_run_rows__course_run_id": course_run_id, + } + ).distinct() diff --git a/src/openedx_learning/applets/pathways/models.py b/src/openedx_learning/applets/pathways/models.py new file mode 100644 index 000000000..b18563ada --- /dev/null +++ b/src/openedx_learning/applets/pathways/models.py @@ -0,0 +1,371 @@ +""" +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 :attr:`Pathway.catalog_pathway`, and it lives here rather than in +``openedx_catalog``, which stays unaware of everything above it (``openedx_catalog`` ADR 0001, decision 2). + +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 CatalogPathway, 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", + "PathwayItem", + "PathwayItemCourseRun", + "PathwayItemVersion", + "PathwayVersion", + "PathwayVersionItem", +] + + +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`. + + A Pathway owns its Items (:attr:`PathwayItem.pathway`); they are never shared with another Pathway. What Pathways + do share is course runs: the same run may fulfill Items in several of them. + + .. no_pii: + """ + + PathwayID = NewType("PathwayID", PublishableEntity.ID) + type ID = PathwayID + + catalog_pathway = models.OneToOneField( + CatalogPathway, + on_delete=models.PROTECT, + related_name="+", + help_text=_("The learner-facing Catalog Pathway that this Pathway implements."), + ) + """ + One-to-one, so each Catalog Pathway is implemented by at most one Pathway. The link is fixed for the Pathway's whole + life, which is why it isn't versioned: the Catalog Pathway of any version is ``version.pathway.catalog_pathway``, + and can't have been anything else when that version was current. ``PROTECT`` because deleting the catalog half out + from under published content would leave learners enrolled in something with no definition. + + The link lives here, on the content side, because ``openedx_catalog`` must not know about anything above it. A + :class:`CatalogPathway` therefore cannot tell you which Pathway implements it; ``get_pathway_for_catalog_pathway()`` + looks that up from this side. + """ + + learning_package = models.OneToOneField( + LearningPackage, + on_delete=models.CASCADE, + related_name="+", + ) + """ + The Learning Package holding this Pathway and its Items, and nothing else. It's also reachable through + ``publishable_entity``; having it here, one-to-one, is what guarantees that no other Pathway shares it, so that + publishing, backing up or deleting the package does exactly that to this Pathway. + """ + + @property + def id(self) -> ID: + return cast(Pathway.ID, self.publishable_entity_id) # type: ignore + + def __str__(self) -> str: + # The entity_ref is the same for every Pathway (each has a package of its own), so it can't identify one. + return f"Pathway for {self.catalog_pathway}" + + class Meta: # type: ignore + verbose_name = _("Pathway") + verbose_name_plural = _("Pathways") + + +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. Neither does anything change + which Catalog Pathway is implemented: that is fixed on the :class:`Pathway`. + + .. 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. + + A version may only list its own Pathway's Items. The database can't enforce that, because it would have to compare + :attr:`PathwayItem.pathway` with ``pathway_version.pathway`` across tables, so the API does. + + .. 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 + + pathway = models.ForeignKey( + Pathway, + on_delete=models.CASCADE, + related_name="items", + ) + """ + The Pathway this Item belongs to, for its whole life. ``pathway.items`` is therefore every Item the Pathway has ever + had, including ones its current version no longer lists; for the current list, read the version's ``item_rows``. + + The Item lives in its Pathway's Learning Package. + """ + + item_code = code_field(unicode=True) + """ + A slug-like identifier that is local to the ``pathway``. + """ + + @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=["pathway", "item_code"], + name="oel_pathways_item_uniq_pathway_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, in priority order. Use + ``pathway_item_version.course_run_rows`` to read them in that order along with their ``enrollment_track`` values. + """ + + @property + def preferred_course_run_row(self) -> PathwayItemCourseRun | None: + """ + The highest-priority run: the one to enroll a learner in when they begin this Item. + + ``None`` if the author hasn't listed any runs yet, which is possible while an Item is still being drafted. + + To decide what to *show* a learner who is already enrolled in several of the Item's runs, take the + highest-priority row among the ones they are enrolled in rather than this one. + """ + return self.course_run_rows.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. + + The list is **ordered by priority**, and that order matters only outside fulfillment: it picks the run to enroll a + learner in when they begin the Item, and, for a learner already enrolled in several of them, the one to show on + their dashboard. Adding a newer run ahead of an older one is how an author gives a learner who failed the older run + another attempt. Fulfillment itself always considers every run in the list, in no particular order. + + "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() + """ + Priority within the Item's list of runs, starting at 0; lower comes first. Priority decides which run a learner is + enrolled in or shown, never whether the Item is fulfilled. Reordering means creating a new + :class:`PathwayItemVersion`, like any other change to a version. + """ + + enrollment_track = case_sensitive_char_field( + max_length=100, + blank=True, + default="", + help_text=_( + "If this run uses multiple enrollment tracks, the track to enroll the learner in. Leave blank otherwise." + ), + ) + + def __str__(self) -> str: + return str(self.course_run) + + 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", + ), + # Priorities are a total order, so no two runs may share one. + models.UniqueConstraint( + fields=["pathway_item_version", "order_num"], + name="oel_pathways_picr_uniq_version_order", + ), + ] 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/0016_pathways.py b/src/openedx_learning/migrations/0016_pathways.py new file mode 100644 index 000000000..8b22492eb --- /dev/null +++ b/src/openedx_learning/migrations/0016_pathways.py @@ -0,0 +1,269 @@ +# 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", "0015_criteria_group_unique_constraints"), + ] + + 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", + ), + ), + ( + "catalog_pathway", + models.OneToOneField( + help_text="The learner-facing Catalog Pathway that this Pathway implements.", + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="openedx_catalog.catalogpathway", + ), + ), + ( + "learning_package", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + 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", + ) + ], + ), + ), + ( + "pathway", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="items", + to="openedx_learning.pathway", + ), + ), + ], + 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()), + ( + "enrollment_track", + openedx_django_lib.fields.MultiCollationCharField( + blank=True, + db_collations={"mysql": "utf8mb4_bin", "sqlite": "BINARY"}, + default="", + help_text="If this run uses multiple enrollment tracks, the track to enroll the learner in. 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="pathwayitem", + constraint=models.UniqueConstraint( + fields=("pathway", "item_code"), name="oel_pathways_item_uniq_pathway_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="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 998dd99ca..e7d786ae0 100644 --- a/src/openedx_learning/models_api.py +++ b/src/openedx_learning/models_api.py @@ -13,6 +13,12 @@ CompetencyRuleProfile, CompetencyTaxonomy, MasteryStatus, + Pathway, + PathwayItem, + PathwayItemCourseRun, + PathwayItemVersion, + PathwayVersion, + PathwayVersionItem, RuleType, StudentCompetencyCriterionStatus, ) 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_api.py b/tests/openedx_learning/applets/pathways/test_api.py new file mode 100644 index 000000000..18c86e544 --- /dev/null +++ b/tests/openedx_learning/applets/pathways/test_api.py @@ -0,0 +1,603 @@ +""" +Tests of the Pathways API: the versioned content half of a Pathway. +""" +# mypy: disable-error-code="misc" +# (Ignore 'Unexpected attribute "org_code" for model "CatalogCourse"' until +# https://github.com/typeddjango/django-stubs/issues/1034 is fixed.) + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction +from django.test import TestCase +from freezegun import freeze_time +from opaque_keys.edx.locator import CourseLocator +from organizations.api import ensure_organization # type: ignore[import] + +from openedx_catalog import api as catalog_api +from openedx_catalog.models import CatalogCourse, CatalogPathway, CourseRun, PathwayCategory +from openedx_content.applets.publishing import api as publishing_api +from openedx_learning import api as learning_api +from openedx_learning.applets.pathways import api as pathways_api # For the import/restore building blocks. +from openedx_learning.models_api import Pathway, PathwayItem, PathwayItemVersion, PathwayVersion + + +class PathwaysTestCase(TestCase): + """ + Base class holding a Catalog Pathway, an empty Pathway implementing it, and some course runs. + """ + + category: PathwayCategory + catalog_pathway: CatalogPathway + pathway: Pathway + now: datetime + run_a1: CourseRun + run_a2: CourseRun + run_b1: CourseRun + + @classmethod + def setUpTestData(cls) -> None: + cls.now = datetime(2026, 5, 8, tzinfo=UTC) + ensure_organization("Org1") + cls.category = catalog_api.get_pathway_category("pathway") # Created by the initial migration. + cls.catalog_pathway = catalog_api.create_catalog_pathway( + org_code="Org1", + pathway_code="DataScience", + category=cls.category, + title="Data Science Professional Certificate", + ) + cls.pathway, _version = learning_api.create_pathway_and_version( + catalog_pathway=cls.catalog_pathway, + title="Pathway", + created=cls.now, + ) + course_a = CatalogCourse.objects.create(org_code="Org1", course_code="CourseA") + course_b = CatalogCourse.objects.create(org_code="Org1", course_code="CourseB") + cls.run_a1 = cls._make_run(course_a, "2026") + cls.run_a2 = cls._make_run(course_a, "2025") + cls.run_b1 = cls._make_run(course_b, "2026") + + @classmethod + def _make_run(cls, catalog_course: CatalogCourse, run_code: str) -> CourseRun: + """Create a CourseRun of the given catalog course.""" + return CourseRun.objects.create( + catalog_course=catalog_course, + run_code=run_code, + course_key=CourseLocator( + org=catalog_course.org_code, + course=catalog_course.course_code, + run=run_code, + ), + ) + + def create_pathway(self, catalog_pathway_code: str) -> Pathway: + """Helper to create another Catalog Pathway and a Pathway, with an empty first version, implementing it.""" + catalog_pathway = catalog_api.create_catalog_pathway( + org_code="Org1", pathway_code=catalog_pathway_code, category=self.category + ) + pathway, _version = learning_api.create_pathway_and_version( + catalog_pathway=catalog_pathway, + title="Another Pathway", + created=self.now, + ) + return pathway + + def create_item( + self, + item_code: str, + *, + title: str = "Item", + course_runs=(), + pathway: Pathway | None = None, + ) -> tuple[PathwayItem, PathwayItemVersion]: + """Helper to create an Item of ``pathway`` (by default, of ``self.pathway``) and its first version.""" + return learning_api.create_pathway_item_and_version( + pathway or self.pathway, + item_code, + title=title, + course_runs=course_runs, + created=self.now, + ) + + def set_items(self, items, *, pathway: Pathway | None = None) -> PathwayVersion: + """Helper to list Items, in order, in a new version of ``pathway`` (by default, of ``self.pathway``).""" + return learning_api.create_next_pathway_version(pathway or self.pathway, items=items, created=self.now) + + def publish(self, pathway: Pathway | None = None) -> None: + """Publish every draft of ``pathway`` (by default, of ``self.pathway``), which has a package of its own.""" + publishing_api.publish_all_drafts((pathway or self.pathway).learning_package_id, published_at=self.now) + + +class PathwayCreationTest(PathwaysTestCase): + """ + Creating a Pathway: its first version, its own Learning Package, and the default timestamp. + """ + + def test_create_pathway_and_version(self): + """A new Pathway implements its Catalog Pathway and starts with a single version, listing no Items yet.""" + statistics = catalog_api.create_catalog_pathway(org_code="Org1", pathway_code="Stats", category=self.category) + pathway, version = learning_api.create_pathway_and_version( + catalog_pathway=statistics, + title="Pathway", + created=self.now, + ) + + assert isinstance(pathway, Pathway) + assert isinstance(version, PathwayVersion) + assert pathway.catalog_pathway == statistics + assert version.version_num == 1 + assert pathway.versioning.draft == version + assert pathway.versioning.published is None + assert pathway.versioning.has_unpublished_changes + assert not version.item_rows.exists() + + def test_a_pathway_gets_a_learning_package_of_its_own(self): + """Named after the Catalog Pathway, whose key is unique across the instance, so it's easy to recognize.""" + statistics = catalog_api.create_catalog_pathway( + org_code="Org1", pathway_code="Stats", category=self.category, title="Statistics" + ) + pathway, _version = learning_api.create_pathway_and_version(catalog_pathway=statistics, title="Statistics") + + assert pathway.learning_package != self.pathway.learning_package + assert pathway.learning_package.package_ref == statistics.key_str + assert pathway.learning_package.title == "Statistics" + + def test_a_package_left_behind_by_a_deleted_pathway_is_reused(self): + statistics = catalog_api.create_catalog_pathway( + org_code="Org1", pathway_code="Stats", category=self.category, title="Statistics" + ) + package = publishing_api.create_learning_package(package_ref=statistics.key_str, title="Left behind") + + pathway, _version = learning_api.create_pathway_and_version(catalog_pathway=statistics, title="Statistics") + assert pathway.learning_package == package + + def test_a_catalog_pathway_cannot_get_a_second_pathway(self): + """To change what a Catalog Pathway requires, create a new version of its Pathway instead.""" + with pytest.raises(IntegrityError), transaction.atomic(): + learning_api.create_pathway_and_version(catalog_pathway=self.catalog_pathway, title="Second") + + def test_created_defaults_to_now(self): + """A single call stamps everything it creates with the same time.""" + created_at = datetime(2026, 6, 1, tzinfo=UTC) + with freeze_time(created_at): + item, item_version = learning_api.create_pathway_item_and_version(self.pathway, "item-1", title="Item") + version = learning_api.create_next_pathway_version(self.pathway, items=[item]) + + assert item.publishable_entity.created == created_at + assert item_version.created == created_at + assert version.created == created_at + + +class CatalogPathwayLookupTest(PathwaysTestCase): + """ + Finding the Pathway that implements a Catalog Pathway, from the content side, where the link lives. + """ + + def test_the_pathway_implementing_a_catalog_pathway(self): + assert learning_api.get_pathway_for_catalog_pathway(self.catalog_pathway) == self.pathway + assert learning_api.get_pathway_for_catalog_pathway(self.catalog_pathway.id) == self.pathway + + def test_a_catalog_pathway_nothing_implements_yet(self): + """A Catalog Pathway may be a placeholder for a Pathway that doesn't exist yet.""" + placeholder = catalog_api.create_catalog_pathway( + org_code="Org1", pathway_code="Placeholder", category=self.category + ) + assert learning_api.get_pathway_for_catalog_pathway(placeholder) is None + + def test_the_link_does_not_depend_on_publishing(self): + """ + The link isn't versioned: it holds before anything is published and after the Pathway is soft-deleted. Whether + there is a definition to show is up to the Pathway's versions. + """ + assert self.pathway.versioning.published is None + assert learning_api.get_pathway_for_catalog_pathway(self.catalog_pathway) == self.pathway + + publishing_api.soft_delete_draft(self.pathway.id) + pathway = learning_api.get_pathway_for_catalog_pathway(self.catalog_pathway) + assert pathway == self.pathway + assert pathway.versioning.draft is None + + +class PathwayVersioningTest(PathwaysTestCase): + """ + The Pathway definition is versioned; the catalog copy is not. + """ + + def test_catalog_edits_do_not_create_versions(self): + """ + Editing the catalog half never touches the content half. This is the whole reason the two are separate. + """ + catalog_api.update_catalog_pathway(self.catalog_pathway, title="Renamed by marketing") + + pathway = learning_api.get_pathway(self.pathway.id) + assert pathway.versioning.latest.version_num == 1 + + def test_definition_edits_do_create_versions(self): + """Reordering, adding, or removing Items is a change to the definition.""" + item_1, _ = self.create_item("item-1") + item_2, _ = self.create_item("item-2") + + v2 = self.set_items([item_1, item_2]) + assert v2.version_num == 2 + assert [row.pathway_item for row in v2.item_rows.all()] == [item_1, item_2] + + def test_next_version_keeps_unspecified_fields(self): + """Passing None keeps the previous value, so a metadata-only edit is cheap.""" + item_1, _ = self.create_item("item-1") + v2 = self.set_items([item_1]) + + v3 = learning_api.create_next_pathway_version(self.pathway, title="Renamed", created=self.now) + assert v3.title == "Renamed" + assert v3.version_num == v2.version_num + 1 + assert [row.pathway_item for row in v3.item_rows.all()] == [item_1] + + def test_next_version_by_pathway_id(self): + v2 = learning_api.create_next_pathway_version(self.pathway.id, title="Renamed", created=self.now) + assert v2.pathway == self.pathway + assert v2.version_num == 2 + + def test_a_pathway_with_no_versions_has_no_next_version(self): + """Only the import/restore building block can leave a Pathway without versions, and nothing builds on that.""" + statistics = catalog_api.create_catalog_pathway(org_code="Org1", pathway_code="Stats", category=self.category) + package = publishing_api.create_learning_package(package_ref="no-versions", title="No versions") + pathway = pathways_api.create_pathway(package.id, statistics, self.now, None) + + with pytest.raises(PathwayVersion.DoesNotExist): + learning_api.create_next_pathway_version(pathway, title="Next", created=self.now) + + +class PathwayItemOwnershipTest(PathwaysTestCase): + """ + A Pathway owns its Items: they are never shared, unlike the course runs that fulfill them. + """ + + def test_an_item_belongs_to_its_pathway(self): + item_1, _ = self.create_item("item-1") + + assert item_1.pathway == self.pathway + assert item_1.publishable_entity.learning_package_id == self.pathway.learning_package_id + assert learning_api.get_pathway_item_by_code(self.pathway, "item-1") == item_1 + + def test_a_version_cannot_list_another_pathways_item(self): + """The database can't compare an Item's Pathway with the version's, so the API does.""" + other = self.create_pathway("other") + foreign_item, _ = self.create_item("item-1", pathway=other) + + with pytest.raises(ValidationError): + self.set_items([foreign_item]) + + def test_creating_an_item_does_not_list_it(self): + """An Item is listed in its Pathway by a new version of the Pathway, not by being created.""" + self.create_item("item-1") + + pathway = learning_api.get_pathway(self.pathway.id) + assert not learning_api.get_items_in_pathway(pathway, published=False) + + +class PathwayItemOrderingTest(PathwaysTestCase): + """ + A Pathway holds an ordered list of Items (ADR 0005, decision 1). + """ + + def test_items_come_back_in_author_defined_order(self): + item_1, _ = self.create_item("item-1", title="First") + item_2, _ = self.create_item("item-2", title="Second") + item_3, _ = self.create_item("item-3", title="Third") + self.set_items([item_3, item_1, item_2]) + + entries = learning_api.get_items_in_pathway(self.pathway, published=False) + assert [entry.pathway_item for entry in entries] == [item_3, item_1, item_2] + assert [entry.order_num for entry in entries] == [0, 1, 2] + assert [entry.pathway_item_version.title for entry in entries] == ["Third", "First", "Second"] + + def test_reordering_creates_a_new_version_and_leaves_the_old_one_intact(self): + """ + Versions are immutable, which is what lets us say what the definition was at a given moment. + """ + item_1, _ = self.create_item("item-1") + item_2, _ = self.create_item("item-2") + v2 = self.set_items([item_1, item_2]) + + self.set_items([item_2, item_1]) + assert [row.pathway_item for row in v2.item_rows.all()] == [item_1, item_2] + + def test_items_are_unpinned(self): + """ + A Pathway tracks the current state of its Items, not a snapshot. Revising an Item is visible through the Pathway + without a new Pathway version. + """ + item_1, _ = self.create_item("item-1", title="Original title") + self.set_items([item_1]) + + learning_api.create_next_pathway_item_version(item_1, title="Revised title", created=self.now) + pathway = learning_api.get_pathway(self.pathway.id) + entries = learning_api.get_items_in_pathway(pathway, published=False) + assert [entry.pathway_item_version.title for entry in entries] == ["Revised title"] + + def test_new_version_is_visible_on_the_instance_passed_in(self): + """ + Creating a version invalidates the draft cached on the caller's instance, so they don't keep reading the version + they just replaced. + """ + item_1, _ = self.create_item("item-1") + item_2, _ = self.create_item("item-2") + pathway = learning_api.get_pathway(self.pathway.id) + learning_api.create_next_pathway_version(pathway, items=[item_1], created=self.now) + assert [entry.pathway_item for entry in learning_api.get_items_in_pathway(pathway, published=False)] == [item_1] + + learning_api.create_next_pathway_version(pathway, items=[item_1, item_2], created=self.now) + entries = learning_api.get_items_in_pathway(pathway, published=False) + assert [entry.pathway_item for entry in entries] == [item_1, item_2] + + def test_items_are_dependencies_of_the_pathway_version(self): + """ + Declaring Items as dependencies is what makes a change to an Item register as a change to the Pathway + containing it. + """ + item_1, _ = self.create_item("item-1") + v2 = self.set_items([item_1]) + + dependencies = v2.publishable_entity_version.dependencies.all() + assert list(dependencies) == [item_1.publishable_entity] + + +class PathwayPublishingTest(PathwaysTestCase): + """ + Drafts and published versions are separate views of the same Pathway. + """ + + def test_published_and_draft_views_differ(self): + item_1, _ = self.create_item("item-1") + item_2, _ = self.create_item("item-2") + self.set_items([item_1]) + self.publish() + + self.set_items([item_1, item_2]) + pathway = learning_api.get_pathway(self.pathway.id) + + published = learning_api.get_items_in_pathway(pathway, published=True) + draft = learning_api.get_items_in_pathway(pathway, published=False) + assert [entry.pathway_item for entry in published] == [item_1] + assert [entry.pathway_item for entry in draft] == [item_1, item_2] + + def test_publishing_a_pathway_publishes_its_items(self): + """ + Because Items are declared as dependencies of the Pathway version, publishing the Pathway carries its + unpublished Items along. A learner never sees a Pathway pointing at an Item that isn't published yet. + """ + item_1, _ = self.create_item("item-1") + self.set_items([item_1]) + self.publish() + + item_2, _ = self.create_item("item-2") + self.set_items([item_1, item_2]) + assert learning_api.get_pathway_item(item_2.id).versioning.published is None + + publishing_api.publish_from_drafts( + self.pathway.learning_package_id, + draft_qset=publishing_api.get_all_drafts(self.pathway.learning_package_id).filter( + entity=self.pathway.publishable_entity, + ), + ) + pathway = learning_api.get_pathway(self.pathway.id) + published = learning_api.get_items_in_pathway(pathway, published=True) + assert [entry.pathway_item for entry in published] == [item_1, item_2] + + def test_deleted_items_are_skipped(self): + """ + An Item whose draft has been soft-deleted has no version to resolve to, so it drops out of the Pathway's list + rather than breaking the query. + """ + item_1, _ = self.create_item("item-1") + item_2, _ = self.create_item("item-2") + self.set_items([item_1, item_2]) + + publishing_api.soft_delete_draft(item_2.id) + + pathway = learning_api.get_pathway(self.pathway.id) + draft = learning_api.get_items_in_pathway(pathway, published=False) + assert [entry.pathway_item for entry in draft] == [item_1] + + def test_pathway_with_no_published_version(self): + with pytest.raises(PathwayVersion.DoesNotExist): + learning_api.get_items_in_pathway(self.pathway, published=True) + + +class PathwayItemFulfillmentTest(PathwaysTestCase): + """ + Mapping Items to the course runs that fulfill them (ADR 0006, decisions 1-3). + """ + + def test_an_item_can_list_runs_of_different_catalog_courses(self): + """ + Passing any one of the listed runs fulfills the Item, and the list is explicit rather than "any run of this + catalog course". + """ + _item, _version = self.create_item( + "item-1", + course_runs=[ + learning_api.FulfillingCourseRun(course_run=self.run_a1, enrollment_track="verified"), + self.run_a2, + self.run_b1, + ], + ) + item = learning_api.get_pathway_item_by_code(self.pathway, "item-1") + entries = learning_api.get_course_runs_for_item(item, published=False) + assert [entry.course_run for entry in entries] == [self.run_a1, self.run_a2, self.run_b1] + assert [entry.order_num for entry in entries] == [0, 1, 2] + assert entries[0].enrollment_track == "verified" + + def test_the_preferred_run_is_the_first_in_the_list(self): + """ + Priority is the list order, so the run to enroll a learner in is always one that fulfills the Item. + """ + _item, version = self.create_item("item-1", course_runs=[self.run_a2, self.run_a1]) + preferred_row = version.preferred_course_run_row + assert preferred_row is not None + assert preferred_row.course_run == self.run_a2 + assert preferred_row.course_run in [row.course_run for row in version.course_run_rows.all()] + + def test_an_item_with_no_runs_has_no_preferred_run(self): + _item, version = self.create_item("item-1") + assert version.preferred_course_run_row is None + + def test_every_run_may_carry_an_enrollment_track(self): + """The track belongs to the run, not to its priority: a learner may be enrolled in any of them.""" + _item, version = self.create_item( + "item-1", + course_runs=[ + learning_api.FulfillingCourseRun(course_run=self.run_a1, enrollment_track="verified"), + learning_api.FulfillingCourseRun(course_run=self.run_a2, enrollment_track="audit"), + ], + ) + assert [row.enrollment_track for row in version.course_run_rows.all()] == ["verified", "audit"] + + def test_a_run_cannot_be_listed_twice(self): + with pytest.raises(ValidationError): + self.create_item("item-1", course_runs=[self.run_a1, self.run_a1]) + + def test_reprioritizing_runs_creates_a_new_item_version(self): + """ + Giving a learner who failed the old run another attempt means putting a newer run first, in a new version. + """ + item, v1 = self.create_item("item-1", course_runs=[self.run_a2, self.run_a1]) + v2 = learning_api.create_next_pathway_item_version( + item, + course_runs=[self.run_a1, self.run_a2], + created=self.now, + ) + assert v2.version_num == 2 + assert v1.preferred_course_run_row.course_run == self.run_a2 + assert v2.preferred_course_run_row.course_run == self.run_a1 + # Both runs still fulfill the Item; only the priority changed. + assert [row.course_run for row in v2.course_run_rows.all()] == [self.run_a1, self.run_a2] + + def test_next_item_version_keeps_the_run_list_by_default(self): + item, _v1 = self.create_item( + "item-1", + course_runs=[ + learning_api.FulfillingCourseRun(course_run=self.run_a1, enrollment_track="verified"), + self.run_a2, + ], + ) + v2 = learning_api.create_next_pathway_item_version(item, title="Renamed", created=self.now) + assert [row.course_run for row in v2.course_run_rows.all()] == [self.run_a1, self.run_a2] + assert v2.preferred_course_run_row.course_run == self.run_a1 + assert v2.preferred_course_run_row.enrollment_track == "verified" + + def test_narrowing_an_item_is_allowed(self): + """ + Dropping a run is an ordinary edit. Whether an already-granted credential survives it is a question for the + evaluation layer, not this one. + """ + item, _v1 = self.create_item("item-1", course_runs=[self.run_a1, self.run_a2]) + v2 = learning_api.create_next_pathway_item_version(item, course_runs=[self.run_a1], created=self.now) + assert [row.course_run for row in v2.course_run_rows.all()] == [self.run_a1] + + def test_items_by_pathway_and_item_id(self): + """Both creating an Item and versioning it accept IDs as well as model instances.""" + item, _v1 = learning_api.create_pathway_item_and_version( + self.pathway.id, + "item-1", + title="Item", + course_runs=[self.run_a1], + created=self.now, + ) + assert item.pathway == self.pathway + + v2 = learning_api.create_next_pathway_item_version(item.id, title="Renamed", created=self.now) + assert v2.pathway_item == item + assert v2.version_num == 2 + assert [row.course_run for row in v2.course_run_rows.all()] == [self.run_a1] + + def test_an_item_with_no_versions_has_no_next_version(self): + """Only the import/restore building block can leave an Item without versions, and nothing builds on that.""" + item = pathways_api.create_pathway_item(self.pathway, "item-1", self.now, None) + + with pytest.raises(PathwayItemVersion.DoesNotExist): + learning_api.create_next_pathway_item_version(item, title="Next", created=self.now) + + def test_course_runs_of_an_item_with_no_version_in_that_state(self): + """An Item that was never published has no published runs; that's an empty list, not an error.""" + item, _v1 = self.create_item("item-1", course_runs=[self.run_a1]) + + assert not learning_api.get_course_runs_for_item(item, published=True) + assert [entry.course_run for entry in learning_api.get_course_runs_for_item(item, published=False)] == [ + self.run_a1 + ] + + +class PathwayFulfillmentQueriesTest(PathwaysTestCase): + """ + The queries that ADR 0006's evaluation triggers are built on. + """ + + def test_one_run_can_fulfill_several_items(self): + """ + Each Item is fulfilled independently; the overlap is resolved at this layer and never leaks up to the Pathway. + """ + item_1, _ = self.create_item("item-1", course_runs=[self.run_a1]) + item_2, _ = self.create_item("item-2", course_runs=[self.run_a1, self.run_b1]) + self.set_items([item_1, item_2]) + self.publish() + + fulfilled = learning_api.get_pathway_items_fulfilled_by_course_run(self.run_a1, published=True) + assert set(fulfilled) == {item_1, item_2} + + def test_run_that_fulfills_nothing(self): + item_1, _ = self.create_item("item-1", course_runs=[self.run_a1]) + self.set_items([item_1]) + self.publish() + + assert not learning_api.get_pathway_items_fulfilled_by_course_run(self.run_b1, published=True).exists() + + def test_draft_only_changes_are_invisible_to_the_published_query(self): + """ + An author can revise an Item repeatedly; only the published result should reach learners (ADR 0006, decision 7). + """ + item_1, _ = self.create_item("item-1", course_runs=[self.run_a1]) + self.set_items([item_1]) + self.publish() + + learning_api.create_next_pathway_item_version(item_1, course_runs=[self.run_a1, self.run_b1], created=self.now) + published = learning_api.get_pathway_items_fulfilled_by_course_run(self.run_b1, published=True) + draft = learning_api.get_pathway_items_fulfilled_by_course_run(self.run_b1, published=False) + assert not published.exists() + assert set(draft) == {item_1} + + def test_a_run_can_count_towards_several_pathways(self): + """ + Items are never shared, but course runs are: one run can count towards several Pathways through different + Items. + """ + other = self.create_pathway("Stats") + item_1, _ = self.create_item("item-1", course_runs=[self.run_a1]) + item_2, _ = self.create_item("item-2", course_runs=[self.run_b1]) + item_3, _ = self.create_item("item-3", course_runs=[self.run_a1], pathway=other) + self.set_items([item_1, item_2]) + self.set_items([item_3], pathway=other) + self.publish() + self.publish(other) + + def containing(course_run): + return set(learning_api.get_pathways_containing_course_run(course_run, published=True)) + + assert containing(self.run_a1) == {self.pathway, other} + assert containing(self.run_b1) == {self.pathway} + assert not containing(self.run_a2) + + def test_an_item_dropped_from_the_pathway_no_longer_counts(self): + """ + An Item still belongs to its Pathway after being dropped from the Pathway's list, but its runs stop counting + towards the Pathway - in the draft straight away, and in the published view once that is published. + """ + item_1, _ = self.create_item("item-1", course_runs=[self.run_a1]) + self.set_items([item_1]) + self.publish() + + self.set_items([]) + assert set(learning_api.get_pathways_containing_course_run(self.run_a1, published=True)) == {self.pathway} + assert not learning_api.get_pathways_containing_course_run(self.run_a1, published=False).exists() 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..9e0913149 --- /dev/null +++ b/tests/openedx_learning/applets/pathways/test_models.py @@ -0,0 +1,157 @@ +""" +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 ProtectedError, RestrictedError + +from openedx_catalog import api as catalog_api +from openedx_content import api as content_api +from openedx_learning.models_api import Pathway, PathwayItem, PathwayItemCourseRun, PathwayVersionItem + +from .test_api import PathwaysTestCase + + +class PathwayConstraintsTest(PathwaysTestCase): + """ + Constraints on Pathways and their Items. + """ + + def test_a_catalog_pathway_is_implemented_by_at_most_one_pathway(self): + """Even by a Pathway in a different Learning Package, which the API would never create.""" + package = content_api.create_learning_package(package_ref="elsewhere", title="Elsewhere") + entity = content_api.create_publishable_entity(package.id, "pathway", self.now, None) + + with pytest.raises(IntegrityError), transaction.atomic(): + Pathway.objects.create( + publishable_entity=entity, + learning_package=package, + catalog_pathway=self.catalog_pathway, + ) + + def test_a_learning_package_holds_at_most_one_pathway(self): + """Even under a different entity_ref, so that publishing a package always means publishing one Pathway.""" + statistics = catalog_api.create_catalog_pathway(org_code="Org1", pathway_code="Stats", category=self.category) + package = self.pathway.learning_package + entity = content_api.create_publishable_entity(package.id, "another-pathway", self.now, None) + + with pytest.raises(IntegrityError), transaction.atomic(): + Pathway.objects.create(publishable_entity=entity, learning_package=package, catalog_pathway=statistics) + + def test_item_code_unique_within_a_pathway(self): + self.create_item("item-1") + with pytest.raises(IntegrityError), transaction.atomic(): + self.create_item("item-1") + + def test_pathways_may_use_the_same_item_codes(self): + other = self.create_pathway("Other") + self.create_item("item-1") + self.create_item("item-1", pathway=other) + + assert PathwayItem.objects.filter(item_code="item-1").count() == 2 + + def test_an_item_cannot_appear_twice_in_a_version(self): + item_1, _ = self.create_item("item-1") + version = self.set_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") + version = self.set_items([item_1]) + + with pytest.raises(IntegrityError), transaction.atomic(): + PathwayVersionItem.objects.create(pathway_version=version, pathway_item=item_2, order_num=0) + + def test_version_item_string_representation(self): + """A version's Item row reads as the version, the Item's position, and the Item.""" + item_1, _ = self.create_item("item-1") + version = self.set_items([item_1]) + + assert str(version.item_rows.get()) == "pathway @ v2 - Pathway #0: pathway-item:item-1" + + 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.set_items([item_1]) + + with pytest.raises(RestrictedError), transaction.atomic(): + item_1.delete() + + def test_deleting_the_learning_package_deletes_its_pathways_and_items(self): + """ + Before anything is published, the RESTRICT from a version's Item rows doesn't block this, because those rows go + in the same cascade. After publishing, something else does; see the next test. + """ + item_1, _ = self.create_item("item-1") + self.set_items([item_1]) + + self.pathway.learning_package.delete() + + assert not Pathway.objects.exists() + assert not PathwayItem.objects.exists() + + def test_publishing_blocks_deleting_the_learning_package(self): + """ + A limitation of ``openedx_content``, not of Pathways, demonstrated here because every published Pathway hits it. + + The Pathway depends on its Items, so publishing them together records a ``PublishSideEffect`` between their + publish records. ``PublishSideEffect.cause`` and ``.effect`` are RESTRICT foreign keys that nothing cascades to, + so once a publish has recorded one, the package can no longer be cascade-deleted. Content containers are + affected too, and even before publishing, because ``EntityListRow.entity`` is RESTRICT as well. + + If this starts failing, the limitation has been lifted: replace this test with a published variant of the one + above. + """ + item_1, _ = self.create_item("item-1") + self.set_items([item_1]) + self.publish() + + with pytest.raises(RestrictedError) as exc_info, transaction.atomic(): + self.pathway.learning_package.delete() + + # The side effects are the only thing in the way; nothing in the Pathway models is. + assert {type(obj).__name__ for obj in exc_info.value.restricted_objects} == {"PublishSideEffect"} + + def test_a_catalog_pathway_in_use_cannot_be_deleted(self): + """ + PROTECT, so that deleting the catalog half can't leave learners enrolled in something with no definition. + """ + with pytest.raises(ProtectedError), transaction.atomic(): + self.catalog_pathway.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_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() + + def test_string_representation(self): + """A row reads as the course run it lists; its priority is shown by its position, not in its name.""" + _item, version = self.create_item("item-1", course_runs=[self.run_a1]) + assert str(version.course_run_rows.get()) == str(self.run_a1)