diff --git a/docs/decisions/0023-pathways.rst b/docs/decisions/0023-pathways.rst new file mode 100644 index 00000000..c296b600 --- /dev/null +++ b/docs/decisions/0023-pathways.rst @@ -0,0 +1,149 @@ +23. Pathways +============ + +Context +------- + +Open edX needs a way to group multiple courses (and, in the future, other learning contexts) into structured learning collections that learners can enroll in and progress through. + +The pathways applet provides foundational models for defining pathways, managing enrollment, tracking learner progress, and evaluating completion criteria. It lives in ``openedx_content.applets.pathways`` as part of the Open edX Core. + +Decisions +--------- + +1. Model Overview +~~~~~~~~~~~~~~~~~ + +The following diagram shows all pathway models and their relationships. + +.. Run `dot -Tsvg pathways-diagram.dot > pathways-diagram.svg` to regenerate the diagram after making changes to the data model in `images/pathways-diagram.dot`. +.. image:: images/pathways-diagram.svg + :alt: Pathways Data Model + :width: 100% + +2. Pathway Structure +~~~~~~~~~~~~~~~~~~~~ + +A **Pathway** is an ordered collection of steps that a learner progresses through. Each **PathwayStep** represents a single step within the pathway, which references a learning context (e.g., a course) that the learner must complete to fulfill that step. + +**Publishing and Versioning** + +Both ``Pathway`` and ``PathwayStep`` use ``PublishableEntityMixin`` / ``PublishableEntityVersionMixin``, linking to ``PublishableEntity`` and ``PublishableEntityVersion`` via OneToOneField (as established by the publishing applet). This provides: + +- Draft/publish workflows — operators can prepare pathway changes without exposing incomplete versions to learners. +- Version history — every published change is tracked, enabling rollback and audit. +- Consistent tooling — the same publish/draft infrastructure used by other content types (Components, Units, etc.). + +``PublishableEntity`` already provides ``uuid``, ``key``, ``learning_package``, ``created``, and ``created_by``. ``PublishableEntityVersion`` provides ``uuid``, ``title``, ``version_num``, ``created``, and ``created_by``. The pathway models extend these with domain-specific fields. + +**Pathway** (uses ``PublishableEntityMixin``): + +- ``key`` — ``PathwayKeyField`` with format ``path-v1:{org}+{path_id}`` (extends ``LearningContextKeyField``). Note It provides pathway key validation and parsing. +- ``org`` — FK to ``Organization``. Ties the pathway to a specific organization. + +**PathwayVersion** (uses ``PublishableEntityVersionMixin``): + +- ``pathway`` — FK back to ``Pathway``. +- ``description`` — detailed description (``title`` is inherited from ``PublishableEntityVersion``). +- ``sequential`` — whether steps must be completed in order. +- ``invite_only`` — whether enrollment is restricted to the allowlist. +- ``default_step_criteria`` — CEL expression applied to steps that don't override it. +- ``completion_criteria`` — CEL expression for pathway-level completion. +- ``metadata`` — JSONField for operator-specific extensibility (duration, difficulty, learning outcomes, etc.). + +**PathwayStep** (uses ``PublishableEntityMixin``): + +- ``pathway`` — FK to ``Pathway``. + +**PathwayStepVersion** (uses ``PublishableEntityVersionMixin``): + +- ``step`` — FK back to ``PathwayStep``. +- ``context_key`` — ``LearningContextKeyField`` referencing the learning context (e.g., ``course-v1:OpenedX+DemoX+DemoCourse``). TODO: replace it with proper foreign keys (e.g., to ``CourseRun``) before finalizing the data model. +- ``step_type`` — explicit type label (e.g., ``course``, ``pathway``) stored for query efficiency and validation rather than derived from the key at query time. +- ``order`` — position within the pathway (0-indexed). +- ``criteria`` — optional CEL expression overriding the pathway's ``default_step_criteria``. +- ``group`` — optional label for grouping steps in pathway-level expressions (e.g., "core", "elective"). + +Step display name and description are derived from the referenced learning context (course, subsection, etc.) and are not stored on the step itself. + +Pathways can be **sequential** (steps must be completed in order) or **non-sequential** (any order). Pathways can be **invite-only**, restricting enrollment to allowlisted users. + +Open Questions: +*************** + +#. Do we need a ``PathwayType``? If yes, what use cases would it serve that couldn't be handled within metadata? +#. Do we need ``step_type``, after all? Could we derive it from the key prefix (e.g., "course-v1" vs "path-v1")? Storing it explicitly allows for easier querying and validation, but introduces redundancy. + +3. Enrollment +~~~~~~~~~~~~~ + +Enrollment is modeled after the existing Open edX course enrollment patterns: + +- **PathwayEnrollment** tracks user-pathway enrollment state with an ``is_active`` flag for soft unenrollment. +- **PathwayEnrollmentAllowed** provides a pre-registration allowlist for invite-only pathways, enabling invitations before users register accounts. +- **PathwayEnrollmentAudit** records all enrollment state transitions for compliance and debugging. +- A **signal receiver** listens for ``User`` ``post_save`` and converts pending ``PathwayEnrollmentAllowed`` records into real enrollments when a new user registers. + +4. Completion Criteria via CEL +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Completion criteria use the `Common Expression Language `_ (CEL), a safe, sandboxed expression language with no side effects and guaranteed termination. + +Criteria operate at two levels: + +**Step-level criteria** define when an individual step is considered "passed": + +- Evaluated per-step against two variables: ``grade`` (0.0–1.0) and ``completion`` (0.0–1.0). +- ``PathwayVersion.default_step_criteria`` sets the default for all steps in the pathway. +- ``PathwayStepVersion.criteria`` can override the default for a specific step. +- An empty expression means the step always passes (no requirement). +- Example: ``grade >= 0.7 && completion >= 0.8`` + +**Pathway-level criteria** define when the overall pathway is considered "complete": + +- Evaluated once against a ``steps`` list, where each entry is a map containing ``grade``, ``completion``, ``passed`` (computed from step-level criteria), ``group``, and ``order``. +- ``PathwayVersion.completion_criteria`` defines the expression. An empty expression means all steps must pass. +- ``PathwayStepVersion.group`` provides an optional label for grouping steps in pathway-level expressions. +- Examples: + + - ``steps.filter(s, s.passed).size() >= 4`` — pass any 4 steps + - ``steps.filter(s, s.group == "core" && s.passed).size() >= 3 && steps.filter(s, s.group == "elective" && s.passed).size() >= 2`` — pass 3 core and 2 elective steps + - ``steps.all(s, s.grade >= 0.8)`` — all steps must have grade at least 80%. TODO: This overrides the step-level criteria, so it could lead to a step being considered "not passed" at the step level but still contributing to pathway completion if its grade is high enough. + - ``steps.filter(s, s.completion >= 1.0).size() == steps.size()`` — all steps must be fully completed + - ``steps.filter(s, s.order <= 2 && s.passed).size() == 3 && steps.filter(s, s.order > 2 && s.grade >= 0.9).size() >= 1`` — first 3 steps must pass, plus at least 1 later step requires a grade of at least 90% + - ``steps.map(s, s.grade).reduce(acc, val, acc + val) / steps.size() >= 0.75`` — average grade across all steps must be at least 75% + +5. Learner Progress Tracking +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**PathwayStepAttempt** — records a student's progress on a given attempt to fulfill a pathway step. This data is stored for efficiency in evaluating criteria and supporting progress displays without having to query the underlying learning context (e.g., course) for each step on the fly. + +- ``user`` — FK to the user. +- ``step`` — FK to ``PathwayStep``. +- ``grade`` — current grade (0.0–1.0). +- ``completion_level`` — current completion (0.0–1.0). +- ``created``, ``updated`` — timestamps. + +This stores one row per student per attempt (e.g., per course run), not per grade change. Detailed progress history is handled by eventing/analytics. + +Whether a student has completed a step or the overall pathway is computed on-the-fly by evaluating the CEL criteria against the attempt data — there are no separate status models. + +Open Questions: +*************** + +#. **Completion tracking performance**: For grades, we can rely on persistent grades. However, calculating completion currently requires building the course tree for each user, which is slow (and updating this entity may happen thousands of times for each learner within a course). Tracking earned/possible completions in ``PathwayStepAttempt`` could be an option, but introduces invalidation complexity: completions would need to be recalculated on course publish and when gated content becomes available (e.g., due to release dates set in the future). +#. **Multiple course runs**: The initial assumption was that a Pathway can reference only a single course run. However, one of the described use cases is changing a course run while keeping the same pathway. How do we want to support this? + +6. Retrieving data from openedx-platform +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +TODO. + +Consequences +------------ + +- The pathways applet provides a stable foundation for pathway management within Open edX Core, following the applet pattern established in ADR 0020. +- Using ``PublishableEntityMixin`` / ``PublishableEntityVersionMixin`` gives pathways proper draft/publish workflows and version history, consistent with other content types in the system. +- CEL-based criteria provide a flexible, safe way to express arbitrarily complex completion requirements without custom code. +- The two-level criteria design (step-level + pathway-level) supports both simple per-step thresholds and complex aggregate requirements like "pass N of M steps" or grouped criteria. +- External consumers (``openedx-platform``, plugins) can interact through either the public Python API or the REST API (both TBD). diff --git a/docs/decisions/images/pathways-diagram.dot b/docs/decisions/images/pathways-diagram.dot new file mode 100644 index 00000000..fa58ae62 --- /dev/null +++ b/docs/decisions/images/pathways-diagram.dot @@ -0,0 +1,63 @@ +digraph pathways { + node [shape=record fontname="Helvetica" fontsize=10] + edge [fontname="Helvetica" fontsize=9] + + subgraph cluster_publishing { + label="Publishing Layer" + style=dashed + color=gray + + PE [label="{PublishableEntity|uuid\llearning_package (FK)\lkey\lcreated\lcreated_by (FK)\l}"] + PEV [label="{PublishableEntityVersion|uuid\lentity (FK)\ltitle\lversion_num\lcreated\lcreated_by (FK)\l}"] + PE -> PEV [label="1:N" style=dashed color=gray] + } + + subgraph cluster_pathway { + label="Pathway Definition" + style=solid + + Pathway [label="{Pathway|publishable_entity (1:1 PK)\lkey (PathwayKeyField)\lorg (FK Organization)\l}"] + PathwayVersion [label="{PathwayVersion|publishable_entity_version (1:1 PK)\lpathway (FK)\ldescription\lsequential\linvite_only\ldefault_step_criteria (CEL)\lcompletion_criteria (CEL)\lmetadata (JSON)\l}"] + PathwayStep [label="{PathwayStep|publishable_entity (1:1 PK)\lpathway (FK)\l}"] + PathwayStepVersion [label="{PathwayStepVersion|publishable_entity_version (1:1 PK)\lstep (FK)\lstep_type\lcontext_key (LearningContextKeyField)\lorder\lcriteria (CEL)\lgroup\l}"] + + Pathway -> PathwayVersion [label="1:N"] + Pathway -> PathwayStep [label="1:N"] + PathwayStep -> PathwayStepVersion [label="1:N"] + } + + subgraph cluster_enrollment { + label="Enrollment" + style=solid + + User1 [label="{User}" style=dashed] + PathwayEnrollment [label="{PathwayEnrollment|user (FK)\lpathway (FK)\lis_active\lcreated\lmodified\l}"] + PathwayEnrollmentAllowed [label="{PathwayEnrollmentAllowed|email\lpathway (FK)\lcreated\l}"] + PathwayEnrollmentAudit [label="{PathwayEnrollmentAudit|enrollment (FK)\laction\ltimestamp\l}"] + + User1 -> PathwayEnrollment [label="1:N"] + User1 -> PathwayEnrollmentAllowed [label="1:N"] + PathwayEnrollment -> PathwayEnrollmentAudit [label="1:N"] + PathwayEnrollmentAllowed -> PathwayEnrollmentAudit [label="1:N"] + } + + subgraph cluster_progress { + label="Learner Progress" + style=solid + + User2 [label="{User}" style=dashed] + PathwayStepAttempt [label="{PathwayStepAttempt|user (FK)\lstep (FK PathwayStep)\lgrade\lcompletion_level\lcreated\lupdated\l}"] + + User2 -> PathwayStepAttempt [label="1:N"] + } + + PE -> Pathway [label="1:1" style=dotted] + PE -> PathwayStep [label="1:1" style=dotted] + PEV -> PathwayVersion [label="1:1" style=dotted] + PEV -> PathwayStepVersion [label="1:1" style=dotted] + + Pathway -> PathwayEnrollment [label="1:N"] + Pathway -> PathwayEnrollmentAllowed [label="1:N"] + + PathwayStep -> PathwayStepAttempt [label="1:N"] +} diff --git a/docs/decisions/images/pathways-diagram.svg b/docs/decisions/images/pathways-diagram.svg new file mode 100644 index 00000000..7d7fefdc --- /dev/null +++ b/docs/decisions/images/pathways-diagram.svg @@ -0,0 +1,275 @@ + + + + + + +pathways + + +cluster_publishing + +Publishing Layer + + +cluster_pathway + +Pathway Definition + + +cluster_enrollment + +Enrollment + + +cluster_progress + +Learner Progress + + + +PE + +PublishableEntity + +uuid +learning_package (FK) +key +created +created_by (FK) + + + +PEV + +PublishableEntityVersion + +uuid +entity (FK) +title +version_num +created +created_by (FK) + + + +PE->PEV + + +1:N + + + +Pathway + +Pathway + +publishable_entity (1:1 PK) +key (PathwayKeyField) +org (FK Organization) + + + +PE->Pathway + + +1:1 + + + +PathwayStep + +PathwayStep + +publishable_entity (1:1 PK) +pathway (FK) + + + +PE->PathwayStep + + +1:1 + + + +PathwayVersion + +PathwayVersion + +publishable_entity_version (1:1 PK) +pathway (FK) +description +sequential +invite_only +default_step_criteria (CEL) +completion_criteria (CEL) +metadata (JSON) + + + +PEV->PathwayVersion + + +1:1 + + + +PathwayStepVersion + +PathwayStepVersion + +publishable_entity_version (1:1 PK) +step (FK) +step_type +context_key (LearningContextKeyField) +order +criteria (CEL) +group + + + +PEV->PathwayStepVersion + + +1:1 + + + +Pathway->PathwayVersion + + +1:N + + + +Pathway->PathwayStep + + +1:N + + + +PathwayEnrollment + +PathwayEnrollment + +user (FK) +pathway (FK) +is_active +created +modified + + + +Pathway->PathwayEnrollment + + +1:N + + + +PathwayEnrollmentAllowed + +PathwayEnrollmentAllowed + +email +pathway (FK) +created + + + +Pathway->PathwayEnrollmentAllowed + + +1:N + + + +PathwayStep->PathwayStepVersion + + +1:N + + + +PathwayStepAttempt + +PathwayStepAttempt + +user (FK) +step (FK PathwayStep) +grade +completion_level +created +updated + + + +PathwayStep->PathwayStepAttempt + + +1:N + + + +User1 + +User + + + +User1->PathwayEnrollment + + +1:N + + + +User1->PathwayEnrollmentAllowed + + +1:N + + + +PathwayEnrollmentAudit + +PathwayEnrollmentAudit + +enrollment (FK) +action +timestamp + + + +PathwayEnrollment->PathwayEnrollmentAudit + + +1:N + + + +PathwayEnrollmentAllowed->PathwayEnrollmentAudit + + +1:N + + + +User2 + +User + + + +User2->PathwayStepAttempt + + +1:N + + + diff --git a/setup.py b/setup.py index 929a19a3..99cf7721 100755 --- a/setup.py +++ b/setup.py @@ -77,6 +77,11 @@ def is_requirement(line): install_requires=load_requirements('requirements/base.in'), python_requires=">=3.11", license="AGPL 3.0", + entry_points={ + 'context_key': [ + 'path-v1 = openedx_content.applets.pathways.keys:PathwayKey', + ], + }, zip_safe=False, keywords='Python edx', classifiers=[ diff --git a/src/openedx_content/admin.py b/src/openedx_content/admin.py index f603a5d5..ff18cb30 100644 --- a/src/openedx_content/admin.py +++ b/src/openedx_content/admin.py @@ -7,6 +7,7 @@ from .applets.collections.admin import * from .applets.components.admin import * from .applets.contents.admin import * +from .applets.pathways.admin import * from .applets.publishing.admin import * from .applets.sections.admin import * from .applets.subsections.admin import * diff --git a/src/openedx_content/applets/pathways/__init__.py b/src/openedx_content/applets/pathways/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/openedx_content/applets/pathways/admin.py b/src/openedx_content/applets/pathways/admin.py new file mode 100644 index 00000000..d652b59b --- /dev/null +++ b/src/openedx_content/applets/pathways/admin.py @@ -0,0 +1,144 @@ +"""Django admin for Pathways.""" + +from django.contrib import admin + +from openedx_django_lib.admin_utils import ReadOnlyModelAdmin + +from .models import Pathway, PathwayEnrollment, PathwayEnrollmentAllowed, PathwayEnrollmentAudit, PathwayStep + + +class PathwayStepInline(admin.TabularInline): + """Inline table for pathway steps within a pathway.""" + + model = PathwayStep + fields = ["order", "step_type", "context_key"] + ordering = ["order"] + extra = 0 + + +@admin.register(Pathway) +class PathwayAdmin(admin.ModelAdmin): + """Admin for Pathway model.""" + + list_display = ["key", "display_name", "org", "is_active", "sequential", "created"] + list_filter = ["is_active", "sequential", "invite_only", "org"] + search_fields = ["key", "display_name"] + inlines = [PathwayStepInline] + + +class PathwayEnrollmentAuditInline(admin.TabularInline): + """Inline admin for PathwayEnrollmentAudit records.""" + + model = PathwayEnrollmentAudit + fk_name = "enrollment" + extra = 0 + exclude = ["enrollment_allowed"] + readonly_fields = [ + "state_transition", + "enrolled_by", + "reason", + "org", + "role", + "created", + ] + + def has_add_permission(self, request, obj=None): + """Disable manual creation of audit records.""" + return False + + def has_delete_permission(self, request, obj=None): + """Disable deletion of audit records.""" + return False + + +@admin.register(PathwayEnrollment) +class PathwayEnrollmentAdmin(admin.ModelAdmin): + """Admin for PathwayEnrollment model.""" + + raw_id_fields = ("user",) + autocomplete_fields = ["pathway"] + list_display = ["id", "user", "pathway", "is_active", "created"] + list_filter = ["pathway__key", "created", "is_active"] + search_fields = ["id", "user__username", "pathway__key", "pathway__display_name"] + inlines = [PathwayEnrollmentAuditInline] + + +class PathwayEnrollmentAllowedAuditInline(admin.TabularInline): + """Inline admin for PathwayEnrollmentAudit records related to enrollment allowed.""" + + model = PathwayEnrollmentAudit + fk_name = "enrollment_allowed" + extra = 0 + exclude = ["enrollment"] + readonly_fields = [ + "state_transition", + "enrolled_by", + "reason", + "org", + "role", + "created", + ] + + def has_add_permission(self, request, obj=None): + """Disable manual creation of audit records.""" + return False + + def has_delete_permission(self, request, obj=None): + """Disable deletion of audit records.""" + return False + + +@admin.register(PathwayEnrollmentAllowed) +class PathwayEnrollmentAllowedAdmin(admin.ModelAdmin): + """Admin for PathwayEnrollmentAllowed model.""" + + autocomplete_fields = ["pathway"] + list_display = ["id", "email", "get_user", "pathway", "created"] + list_filter = ["pathway", "created"] + search_fields = ["email", "user__username", "user__email", "pathway__key"] + readonly_fields = ["user", "created"] + inlines = [PathwayEnrollmentAllowedAuditInline] + + def get_user(self, obj): + """Get the associated user, if any.""" + return obj.user.username if obj.user else "-" + + get_user.short_description = "User" # type: ignore[attr-defined] + + +@admin.register(PathwayEnrollmentAudit) +class PathwayEnrollmentAuditAdmin(ReadOnlyModelAdmin): + """Admin configuration for PathwayEnrollmentAudit model.""" + + list_display = ["id", "state_transition", "enrolled_by", "get_enrollee", "get_pathway", "created", "org", "role"] + list_filter = ["state_transition", "created", "org", "role"] + search_fields = [ + "enrolled_by__username", + "enrolled_by__email", + "enrollment__user__username", + "enrollment__user__email", + "enrollment_allowed__email", + "enrollment__pathway__key", + "enrollment_allowed__pathway__key", + "reason", + ] + + def get_enrollee(self, obj): + """Get the enrollee (user or email).""" + if obj.enrollment: + return obj.enrollment.user.username + elif obj.enrollment_allowed: + return obj.enrollment_allowed.user.username if obj.enrollment_allowed.user else obj.enrollment_allowed.email + return "-" + + get_enrollee.short_description = "Enrollee" # type: ignore[attr-defined] + + def get_pathway(self, obj): + """Get the pathway title.""" + if obj.enrollment: + return obj.enrollment.pathway_id + elif obj.enrollment_allowed: + return obj.enrollment_allowed.pathway_id + return "-" + + get_pathway.short_description = "Pathway" # type: ignore[attr-defined] diff --git a/src/openedx_content/applets/pathways/keys.py b/src/openedx_content/applets/pathways/keys.py new file mode 100644 index 00000000..fefc96a5 --- /dev/null +++ b/src/openedx_content/applets/pathways/keys.py @@ -0,0 +1,72 @@ +""" +Opaque key for Pathways. + +Format: path-v1:{org}+{path_id} + +Can be moved to opaque-keys later if needed. +""" + +import re +from typing import Self + +from django.core.exceptions import ValidationError +from opaque_keys import InvalidKeyError, OpaqueKey +from opaque_keys.edx.django.models import LearningContextKeyField +from opaque_keys.edx.keys import LearningContextKey + +PATHWAY_NAMESPACE = "path-v1" +PATHWAY_PATTERN = r"([^+]+)\+([^+]+)" +PATHWAY_URL_PATTERN = rf"(?P{PATHWAY_NAMESPACE}:{PATHWAY_PATTERN})" + + +class PathwayKey(LearningContextKey): + """ + Key for identifying a Pathway. + + Format: path-v1:{org}+{path_id} + Example: path-v1:OpenedX+DemoPathway + """ + + CANONICAL_NAMESPACE = PATHWAY_NAMESPACE + KEY_FIELDS = ("org", "path_id") + CHECKED_INIT = False + + __slots__ = KEY_FIELDS + _pathway_key_regex = re.compile(PATHWAY_PATTERN) + + def __init__(self, org: str, path_id: str): + super().__init__(org=org, path_id=path_id) + + @classmethod + def _from_string(cls, serialized: str) -> Self: + """Return an instance of this class constructed from the given string.""" + match = cls._pathway_key_regex.fullmatch(serialized) + if not match: + raise InvalidKeyError(cls, serialized) + return cls(*match.groups()) + + def _to_string(self) -> str: + """Return a string representing this key.""" + return f"{self.org}+{self.path_id}" # type: ignore[attr-defined] + + +class PathwayKeyField(LearningContextKeyField): + """Django model field for PathwayKey.""" + + description = "A PathwayKey object" + KEY_CLASS = PathwayKey + # Declare the field types for the django-stubs mypy type hint plugin: + _pyi_private_set_type: PathwayKey | str | None + _pyi_private_get_type: PathwayKey | None + + def __init__(self, *args, **kwargs): + kwargs.setdefault("max_length", 255) + super().__init__(*args, **kwargs) + + def to_python(self, value) -> None | OpaqueKey: + """Convert the input value to a PathwayKey object.""" + try: + return super().to_python(value) + except InvalidKeyError: + # pylint: disable=raise-missing-from + raise ValidationError("Invalid format. Use: 'path-v1:{org}+{path_id}'") diff --git a/src/openedx_content/applets/pathways/models/__init__.py b/src/openedx_content/applets/pathways/models/__init__.py new file mode 100644 index 00000000..6b961676 --- /dev/null +++ b/src/openedx_content/applets/pathways/models/__init__.py @@ -0,0 +1,13 @@ +"""Models that comprise the pathways applet.""" + +from .enrollment import PathwayEnrollment, PathwayEnrollmentAllowed, PathwayEnrollmentAudit +from .pathway import Pathway +from .pathway_step import PathwayStep + +__all__ = [ + "Pathway", + "PathwayEnrollment", + "PathwayEnrollmentAllowed", + "PathwayEnrollmentAudit", + "PathwayStep", +] diff --git a/src/openedx_content/applets/pathways/models/enrollment.py b/src/openedx_content/applets/pathways/models/enrollment.py new file mode 100644 index 00000000..98e6e823 --- /dev/null +++ b/src/openedx_content/applets/pathways/models/enrollment.py @@ -0,0 +1,139 @@ +"""Enrollment models for Pathways.""" + +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from openedx_django_lib.validators import validate_utc_datetime + +from .pathway import Pathway + + +class PathwayEnrollment(models.Model): + """ + Tracks a user's enrollment in a pathway. + + .. no_pii: + """ + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="pathway_enrollments") + pathway = models.ForeignKey(Pathway, on_delete=models.CASCADE, related_name="enrollments") + is_active = models.BooleanField(default=True, help_text=_("Indicates whether the learner is enrolled.")) + created = models.DateTimeField(auto_now_add=True, validators=[validate_utc_datetime]) + modified = models.DateTimeField(auto_now=True, validators=[validate_utc_datetime]) + + def __str__(self) -> str: + """User-friendly string representation of this model.""" + return f"PathwayEnrollment of user={self.user_id} in {self.pathway_id}" + + class Meta: + """Model options.""" + + verbose_name = _("Pathway Enrollment") + verbose_name_plural = _("Pathway Enrollments") + constraints = [ + models.UniqueConstraint( + fields=["user", "pathway"], + name="oel_pathway_enroll_uniq", + ), + ] + + +class PathwayEnrollmentAllowed(models.Model): + """ + Pre-registration allowlist for invite-only pathways. + + These entities are created when learners are invited/enrolled before they register an account. + + .. pii: The email field is not retired to allow future learners to enroll. + .. pii_types: email_address + .. pii_retirement: retained + """ + + pathway = models.ForeignKey(Pathway, on_delete=models.CASCADE, related_name="enrollment_allowed") + email = models.EmailField(db_index=True) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True) + is_active = models.BooleanField( + default=True, db_index=True, help_text=_("Indicates if the enrollment allowance is active") + ) + created = models.DateTimeField(auto_now_add=True, validators=[validate_utc_datetime]) + + def __str__(self) -> str: + """User-friendly string representation of this model.""" + return f"PathwayEnrollmentAllowed for {self.email} in {self.pathway_id}" + + class Meta: + """Model options.""" + + verbose_name = _("Pathway Enrollment Allowed") + verbose_name_plural = _("Pathway Enrollments Allowed") + constraints = [ + models.UniqueConstraint( + fields=["pathway", "email"], + name="oel_pathway_enrollallow_uniq", + ), + ] + + +# TODO: Create receivers to automatically create audit records. +class PathwayEnrollmentAudit(models.Model): + """ + Audit log for pathway enrollment changes. + + .. no_pii: + """ + + # State transition constants (copied from openedx-platform to maintain consistency) + UNENROLLED_TO_ALLOWEDTOENROLL = "from unenrolled to allowed to enroll" + ALLOWEDTOENROLL_TO_ENROLLED = "from allowed to enroll to enrolled" + ENROLLED_TO_ENROLLED = "from enrolled to enrolled" + ENROLLED_TO_UNENROLLED = "from enrolled to unenrolled" + UNENROLLED_TO_ENROLLED = "from unenrolled to enrolled" + ALLOWEDTOENROLL_TO_UNENROLLED = "from allowed to enroll to unenrolled" + UNENROLLED_TO_UNENROLLED = "from unenrolled to unenrolled" + DEFAULT_TRANSITION_STATE = "N/A" + + TRANSITION_STATES = ( + (UNENROLLED_TO_ALLOWEDTOENROLL, UNENROLLED_TO_ALLOWEDTOENROLL), + (ALLOWEDTOENROLL_TO_ENROLLED, ALLOWEDTOENROLL_TO_ENROLLED), + (ENROLLED_TO_ENROLLED, ENROLLED_TO_ENROLLED), + (ENROLLED_TO_UNENROLLED, ENROLLED_TO_UNENROLLED), + (UNENROLLED_TO_ENROLLED, UNENROLLED_TO_ENROLLED), + (ALLOWEDTOENROLL_TO_UNENROLLED, ALLOWEDTOENROLL_TO_UNENROLLED), + (UNENROLLED_TO_UNENROLLED, UNENROLLED_TO_UNENROLLED), + (DEFAULT_TRANSITION_STATE, DEFAULT_TRANSITION_STATE), + ) + + enrolled_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="pathway_enrollment_audits" + ) + enrollment = models.ForeignKey(PathwayEnrollment, on_delete=models.CASCADE, null=True, related_name="audit_log") + enrollment_allowed = models.ForeignKey( + PathwayEnrollmentAllowed, on_delete=models.CASCADE, null=True, related_name="audit_log" + ) + state_transition = models.CharField(max_length=255, choices=TRANSITION_STATES, default=DEFAULT_TRANSITION_STATE) + reason = models.TextField(blank=True) + org = models.CharField(max_length=255, blank=True, db_index=True) + role = models.CharField(max_length=255, blank=True) + created = models.DateTimeField(auto_now_add=True, validators=[validate_utc_datetime]) + + def __str__(self): + """User-friendly string representation of this model.""" + enrollee = "unknown" + pathway = "unknown" + + if self.enrollment: + enrollee = self.enrollment.user + pathway = self.enrollment.pathway_id + elif self.enrollment_allowed: + enrollee = self.enrollment_allowed.user or self.enrollment_allowed.email + pathway = self.enrollment_allowed.pathway_id + + return f"{self.state_transition} for {enrollee} in {pathway}" + + class Meta: + """Model options.""" + + verbose_name = _("Pathway Enrollment Audit") + verbose_name_plural = _("Pathway Enrollment Audits") + ordering = ["-created"] diff --git a/src/openedx_content/applets/pathways/models/pathway.py b/src/openedx_content/applets/pathways/models/pathway.py new file mode 100644 index 00000000..0c39fcf8 --- /dev/null +++ b/src/openedx_content/applets/pathways/models/pathway.py @@ -0,0 +1,92 @@ +"""Pathway model.""" + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +# from organizations.models import Organization +from openedx_django_lib.fields import case_insensitive_char_field +from openedx_django_lib.validators import validate_utc_datetime + +from ..keys import PathwayKeyField + + +class Pathway(models.Model): + """ + A pathway is an ordered sequence of steps that a learner progresses through. + + For now, steps can only reference courses, but in the future they could also reference pathways + or learning contexts, such as sections, subsections, or units. + + This model consists of only the core fields needed to define a pathway. + + .. no_pii: + """ + + key = PathwayKeyField( + max_length=255, + unique=True, + db_index=True, + db_column="_key", + help_text=_("Unique identifier: path-v1:{org}+{path_id}"), + ) + + # TODO: Make this a ForeignKey to the Organization model. + # org = models.ForeignKey( + # Organization, + # to_field="short_name", + # on_delete=models.PROTECT, + # null=False, + # editable=False, + # ) + org = models.CharField( + max_length=255, + null=False, + editable=False, + help_text=_("A temporary placeholder for the organization short name."), + ) + + display_name = case_insensitive_char_field(max_length=255, blank=False) + + description = models.TextField(blank=True, max_length=10_000) + + sequential = models.BooleanField( + default=True, + help_text=_("If True, learners must complete steps in order. If False, steps can be completed in any order."), + ) + + is_active = models.BooleanField( + default=True, + help_text=_("If False, this pathway is treated as archived and should not be offered to new learners."), + ) + + invite_only = models.BooleanField( + default=True, + help_text=_( + "If enabled, users can only enroll if they are on the allowlist. " + "This is True by default to prevent accidentally exposing learning paths to all users. " + "Only enrolled users can see this learning path." + ), + ) + + metadata = models.JSONField( + default=dict, + blank=True, + help_text=_( + "It can include any additional information about the pathway, such as its duration, difficulty level," + "learning outcomes, etc. This field is not used by the core pathway logic. Instead, it aims to provide " + "flexibility for operators to store additional information and process it in plugins." + ), + ) + + created = models.DateTimeField(auto_now_add=True, validators=[validate_utc_datetime]) + modified = models.DateTimeField(auto_now=True, validators=[validate_utc_datetime]) + + def __str__(self) -> str: + """User-friendly string representation of this model.""" + return f"{self.key}" + + class Meta: + """Model options.""" + + verbose_name = _("Pathway") + verbose_name_plural = _("Pathways") diff --git a/src/openedx_content/applets/pathways/models/pathway_step.py b/src/openedx_content/applets/pathways/models/pathway_step.py new file mode 100644 index 00000000..4b770e3b --- /dev/null +++ b/src/openedx_content/applets/pathways/models/pathway_step.py @@ -0,0 +1,71 @@ +"""PathwayStep model.""" + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from opaque_keys.edx.django.models import LearningContextKeyField + +from .pathway import Pathway + + +class PathwayStep(models.Model): + """ + A single step in a pathway. + + For now, steps reference courses via a LearningContextKey. + In the future, we want to switch to the CourseRuns (https://github.com/openedx/openedx-learning/issues/469). + + + Steps are ordered within a pathway. The ``step_type`` is stored explicitly + for query efficiency and validation -- it is NOT derived from the key at + query time. + + .. no_pii: + """ + + class StepType(models.TextChoices): + COURSE = "course", _("Course") + PATHWAY = "pathway", _("Pathway") + + pathway = models.ForeignKey(Pathway, on_delete=models.CASCADE, related_name="steps") + context_key = LearningContextKeyField( + max_length=255, help_text=_("Opaque key of the learning context (e.g. 'course-v1:OpenedX+DemoX+DemoCourse').") + ) + + step_type = models.CharField( + max_length=32, + choices=StepType.choices, + help_text=_("Type of learning context this step references."), + ) + + order = models.PositiveIntegerField(help_text=_("Position of this step within the pathway (0-indexed).")) + + def __str__(self) -> str: + """User-friendly string representation of this model.""" + return f"{self.pathway.key} #{self.order}: {self.context_key}" + + @classmethod + def get_step_type_for_key(cls, key) -> str: + """Determine the StepType from a LearningContextKey's namespace.""" + + namespace = getattr(key, "CANONICAL_NAMESPACE", str(key).split(":")[0]) + type_mapping = { + "course-v1": cls.StepType.COURSE, + "path-v1": cls.StepType.PATHWAY, + } + return type_mapping.get(namespace, cls.StepType.COURSE) + + class Meta: + """Model options.""" + verbose_name = _("Pathway Step") + verbose_name_plural = _("Pathway Steps") + ordering = ["order"] + constraints = [ + models.UniqueConstraint( + fields=["pathway", "order"], + name="oel_pathway_step_uniq_order", + ), + models.UniqueConstraint( + fields=["pathway", "context_key"], + name="oel_pathway_step_uniq_ctx", + ), + ] diff --git a/src/openedx_content/migrations/0003_pathways.py b/src/openedx_content/migrations/0003_pathways.py new file mode 100644 index 00000000..d8607fa3 --- /dev/null +++ b/src/openedx_content/migrations/0003_pathways.py @@ -0,0 +1,292 @@ +# Generated by Django 5.2.11 on 2026-02-11 23:20 + +import django.db.models.deletion +import opaque_keys.edx.django.models +from django.conf import settings +from django.db import migrations, models + +import openedx_content.applets.pathways.keys +import openedx_django_lib.fields +import openedx_django_lib.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ("openedx_content", "0002_rename_tables_to_openedx_content"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Pathway", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "key", + openedx_content.applets.pathways.keys.PathwayKeyField( + db_column="_key", + db_index=True, + help_text="Unique identifier: path-v1:{org}+{path_id}", + max_length=255, + unique=True, + ), + ), + ( + "org", + models.CharField( + editable=False, + help_text="A temporary placeholder for the organization short name.", + max_length=255, + ), + ), + ( + "display_name", + openedx_django_lib.fields.MultiCollationCharField( + db_collations={"mysql": "utf8mb4_unicode_ci", "sqlite": "NOCASE"}, max_length=255 + ), + ), + ("description", models.TextField(blank=True, max_length=10000)), + ( + "sequential", + models.BooleanField( + default=True, + help_text="If True, learners must complete steps in order. If False, steps can be completed in any order.", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="If False, this pathway is treated as archived and should not be offered to new learners.", + ), + ), + ( + "invite_only", + models.BooleanField( + default=True, + help_text="If enabled, users can only enroll if they are on the allowlist. This is True by default to prevent accidentally exposing learning paths to all users. Only enrolled users can see this learning path.", + ), + ), + ( + "metadata", + models.JSONField( + blank=True, + default=dict, + help_text="It can include any additional information about the pathway, such as its duration, difficulty level,learning outcomes, etc. This field is not used by the core pathway logic. Instead, it aims to provide flexibility for operators to store additional information and process it in plugins.", + ), + ), + ( + "created", + models.DateTimeField( + auto_now_add=True, validators=[openedx_django_lib.validators.validate_utc_datetime] + ), + ), + ( + "modified", + models.DateTimeField( + auto_now=True, validators=[openedx_django_lib.validators.validate_utc_datetime] + ), + ), + ], + options={ + "verbose_name": "Pathway", + "verbose_name_plural": "Pathways", + }, + ), + migrations.CreateModel( + name="PathwayEnrollment", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "is_active", + models.BooleanField(default=True, help_text="Indicates whether the learner is enrolled."), + ), + ( + "created", + models.DateTimeField( + auto_now_add=True, validators=[openedx_django_lib.validators.validate_utc_datetime] + ), + ), + ( + "modified", + models.DateTimeField( + auto_now=True, validators=[openedx_django_lib.validators.validate_utc_datetime] + ), + ), + ( + "pathway", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="enrollments", + to="openedx_content.pathway", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="pathway_enrollments", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Pathway Enrollment", + "verbose_name_plural": "Pathway Enrollments", + }, + ), + migrations.CreateModel( + name="PathwayEnrollmentAllowed", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("email", models.EmailField(db_index=True, max_length=254)), + ( + "is_active", + models.BooleanField( + db_index=True, default=True, help_text="Indicates if the enrollment allowance is active" + ), + ), + ( + "created", + models.DateTimeField( + auto_now_add=True, validators=[openedx_django_lib.validators.validate_utc_datetime] + ), + ), + ( + "pathway", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="enrollment_allowed", + to="openedx_content.pathway", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "verbose_name": "Pathway Enrollment Allowed", + "verbose_name_plural": "Pathway Enrollments Allowed", + }, + ), + migrations.CreateModel( + name="PathwayEnrollmentAudit", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "state_transition", + models.CharField( + choices=[ + ("from unenrolled to allowed to enroll", "from unenrolled to allowed to enroll"), + ("from allowed to enroll to enrolled", "from allowed to enroll to enrolled"), + ("from enrolled to enrolled", "from enrolled to enrolled"), + ("from enrolled to unenrolled", "from enrolled to unenrolled"), + ("from unenrolled to enrolled", "from unenrolled to enrolled"), + ("from allowed to enroll to unenrolled", "from allowed to enroll to unenrolled"), + ("from unenrolled to unenrolled", "from unenrolled to unenrolled"), + ("N/A", "N/A"), + ], + default="N/A", + max_length=255, + ), + ), + ("reason", models.TextField(blank=True)), + ("org", models.CharField(blank=True, db_index=True, max_length=255)), + ("role", models.CharField(blank=True, max_length=255)), + ( + "created", + models.DateTimeField( + auto_now_add=True, validators=[openedx_django_lib.validators.validate_utc_datetime] + ), + ), + ( + "enrolled_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="pathway_enrollment_audits", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "enrollment", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="audit_log", + to="openedx_content.pathwayenrollment", + ), + ), + ( + "enrollment_allowed", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="audit_log", + to="openedx_content.pathwayenrollmentallowed", + ), + ), + ], + options={ + "verbose_name": "Pathway Enrollment Audit", + "verbose_name_plural": "Pathway Enrollment Audits", + "ordering": ["-created"], + }, + ), + migrations.CreateModel( + name="PathwayStep", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "context_key", + opaque_keys.edx.django.models.LearningContextKeyField( + help_text="Opaque key of the learning context (e.g. 'course-v1:OpenedX+DemoX+DemoCourse').", + max_length=255, + ), + ), + ( + "step_type", + models.CharField( + choices=[("course", "Course"), ("pathway", "Pathway")], + help_text="Type of learning context this step references.", + max_length=32, + ), + ), + ( + "order", + models.PositiveIntegerField(help_text="Position of this step within the pathway (0-indexed)."), + ), + ( + "pathway", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="steps", to="openedx_content.pathway" + ), + ), + ], + options={ + "verbose_name": "Pathway Step", + "verbose_name_plural": "Pathway Steps", + "ordering": ["order"], + }, + ), + migrations.AddConstraint( + model_name="pathwayenrollment", + constraint=models.UniqueConstraint(fields=("user", "pathway"), name="oel_pathway_enroll_uniq"), + ), + migrations.AddConstraint( + model_name="pathwayenrollmentallowed", + constraint=models.UniqueConstraint(fields=("pathway", "email"), name="oel_pathway_enrollallow_uniq"), + ), + migrations.AddConstraint( + model_name="pathwaystep", + constraint=models.UniqueConstraint(fields=("pathway", "order"), name="oel_pathway_step_uniq_order"), + ), + migrations.AddConstraint( + model_name="pathwaystep", + constraint=models.UniqueConstraint(fields=("pathway", "context_key"), name="oel_pathway_step_uniq_ctx"), + ), + ] diff --git a/src/openedx_content/models.py b/src/openedx_content/models.py index 56f672a6..2888bd44 100644 --- a/src/openedx_content/models.py +++ b/src/openedx_content/models.py @@ -11,6 +11,7 @@ from .applets.collections.models import * from .applets.components.models import * from .applets.contents.models import * +from .applets.pathways.models import * from .applets.publishing.models import * from .applets.sections.models import * from .applets.subsections.models import *