Skip to content

Commit e4451b6

Browse files
committed
feat: implement Pathways
1 parent 235a456 commit e4451b6

11 files changed

Lines changed: 830 additions & 0 deletions

File tree

setup.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ def is_requirement(line):
7777
install_requires=load_requirements('requirements/base.in'),
7878
python_requires=">=3.11",
7979
license="AGPL 3.0",
80+
entry_points={
81+
'context_key': [
82+
'path-v1 = openedx_content.applets.pathways.keys:PathwayKey',
83+
],
84+
},
8085
zip_safe=False,
8186
keywords='Python edx',
8287
classifiers=[

src/openedx_content/admin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from .applets.collections.admin import *
88
from .applets.components.admin import *
99
from .applets.contents.admin import *
10+
from .applets.pathways.admin import *
1011
from .applets.publishing.admin import *
1112
from .applets.sections.admin import *
1213
from .applets.subsections.admin import *

src/openedx_content/applets/pathways/__init__.py

Whitespace-only changes.
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Django admin for Pathways."""
2+
3+
from django.contrib import admin
4+
5+
from openedx_django_lib.admin_utils import ReadOnlyModelAdmin
6+
7+
from .models import Pathway, PathwayEnrollment, PathwayEnrollmentAllowed, PathwayEnrollmentAudit, PathwayStep
8+
9+
10+
class PathwayStepInline(admin.TabularInline):
11+
"""Inline table for pathway steps within a pathway."""
12+
13+
model = PathwayStep
14+
fields = ["order", "step_type", "context_key"]
15+
ordering = ["order"]
16+
extra = 0
17+
18+
19+
@admin.register(Pathway)
20+
class PathwayAdmin(admin.ModelAdmin):
21+
"""Admin for Pathway model."""
22+
23+
list_display = ["key", "display_name", "org", "is_active", "sequential", "created"]
24+
list_filter = ["is_active", "sequential", "invite_only", "org"]
25+
search_fields = ["key", "display_name"]
26+
inlines = [PathwayStepInline]
27+
28+
29+
class PathwayEnrollmentAuditInline(admin.TabularInline):
30+
"""Inline admin for PathwayEnrollmentAudit records."""
31+
32+
model = PathwayEnrollmentAudit
33+
fk_name = "enrollment"
34+
extra = 0
35+
exclude = ["enrollment_allowed"]
36+
readonly_fields = [
37+
"state_transition",
38+
"enrolled_by",
39+
"reason",
40+
"org",
41+
"role",
42+
"created",
43+
]
44+
45+
def has_add_permission(self, request, obj=None):
46+
"""Disable manual creation of audit records."""
47+
return False
48+
49+
def has_delete_permission(self, request, obj=None):
50+
"""Disable deletion of audit records."""
51+
return False
52+
53+
54+
@admin.register(PathwayEnrollment)
55+
class PathwayEnrollmentAdmin(admin.ModelAdmin):
56+
"""Admin for PathwayEnrollment model."""
57+
58+
raw_id_fields = ("user",)
59+
autocomplete_fields = ["pathway"]
60+
list_display = ["id", "user", "pathway", "is_active", "created"]
61+
list_filter = ["pathway__key", "created", "is_active"]
62+
search_fields = ["id", "user__username", "pathway__key", "pathway__display_name"]
63+
inlines = [PathwayEnrollmentAuditInline]
64+
65+
66+
class PathwayEnrollmentAllowedAuditInline(admin.TabularInline):
67+
"""Inline admin for PathwayEnrollmentAudit records related to enrollment allowed."""
68+
69+
model = PathwayEnrollmentAudit
70+
fk_name = "enrollment_allowed"
71+
extra = 0
72+
exclude = ["enrollment"]
73+
readonly_fields = [
74+
"state_transition",
75+
"enrolled_by",
76+
"reason",
77+
"org",
78+
"role",
79+
"created",
80+
]
81+
82+
def has_add_permission(self, request, obj=None):
83+
"""Disable manual creation of audit records."""
84+
return False
85+
86+
def has_delete_permission(self, request, obj=None):
87+
"""Disable deletion of audit records."""
88+
return False
89+
90+
91+
@admin.register(PathwayEnrollmentAllowed)
92+
class PathwayEnrollmentAllowedAdmin(admin.ModelAdmin):
93+
"""Admin for PathwayEnrollmentAllowed model."""
94+
95+
autocomplete_fields = ["pathway"]
96+
list_display = ["id", "email", "get_user", "pathway", "created"]
97+
list_filter = ["pathway", "created"]
98+
search_fields = ["email", "user__username", "user__email", "pathway__key"]
99+
readonly_fields = ["user", "created"]
100+
inlines = [PathwayEnrollmentAllowedAuditInline]
101+
102+
def get_user(self, obj):
103+
"""Get the associated user, if any."""
104+
return obj.user.username if obj.user else "-"
105+
106+
get_user.short_description = "User" # type: ignore[attr-defined]
107+
108+
109+
@admin.register(PathwayEnrollmentAudit)
110+
class PathwayEnrollmentAuditAdmin(ReadOnlyModelAdmin):
111+
"""Admin configuration for PathwayEnrollmentAudit model."""
112+
113+
list_display = ["id", "state_transition", "enrolled_by", "get_enrollee", "get_pathway", "created", "org", "role"]
114+
list_filter = ["state_transition", "created", "org", "role"]
115+
search_fields = [
116+
"enrolled_by__username",
117+
"enrolled_by__email",
118+
"enrollment__user__username",
119+
"enrollment__user__email",
120+
"enrollment_allowed__email",
121+
"enrollment__pathway__key",
122+
"enrollment_allowed__pathway__key",
123+
"reason",
124+
]
125+
126+
def get_enrollee(self, obj):
127+
"""Get the enrollee (user or email)."""
128+
if obj.enrollment:
129+
return obj.enrollment.user.username
130+
elif obj.enrollment_allowed:
131+
return obj.enrollment_allowed.user.username if obj.enrollment_allowed.user else obj.enrollment_allowed.email
132+
return "-"
133+
134+
get_enrollee.short_description = "Enrollee" # type: ignore[attr-defined]
135+
136+
def get_pathway(self, obj):
137+
"""Get the pathway title."""
138+
if obj.enrollment:
139+
return obj.enrollment.pathway_id
140+
elif obj.enrollment_allowed:
141+
return obj.enrollment_allowed.pathway_id
142+
return "-"
143+
144+
get_pathway.short_description = "Pathway" # type: ignore[attr-defined]
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""
2+
Opaque key for Pathways.
3+
4+
Format: path-v1:{org}+{path_id}
5+
6+
Can be moved to opaque-keys later if needed.
7+
"""
8+
9+
import re
10+
from typing import Self
11+
12+
from django.core.exceptions import ValidationError
13+
from opaque_keys import InvalidKeyError, OpaqueKey
14+
from opaque_keys.edx.django.models import LearningContextKeyField
15+
from opaque_keys.edx.keys import LearningContextKey
16+
17+
PATHWAY_NAMESPACE = "path-v1"
18+
PATHWAY_PATTERN = r"([^+]+)\+([^+]+)"
19+
PATHWAY_URL_PATTERN = rf"(?P<pathway_key_str>{PATHWAY_NAMESPACE}:{PATHWAY_PATTERN})"
20+
21+
22+
class PathwayKey(LearningContextKey):
23+
"""
24+
Key for identifying a Pathway.
25+
26+
Format: path-v1:{org}+{path_id}
27+
Example: path-v1:OpenedX+DemoPathway
28+
"""
29+
30+
CANONICAL_NAMESPACE = PATHWAY_NAMESPACE
31+
KEY_FIELDS = ("org", "path_id")
32+
CHECKED_INIT = False
33+
34+
__slots__ = KEY_FIELDS
35+
_pathway_key_regex = re.compile(PATHWAY_PATTERN)
36+
37+
def __init__(self, org: str, path_id: str):
38+
super().__init__(org=org, path_id=path_id)
39+
40+
@classmethod
41+
def _from_string(cls, serialized: str) -> Self:
42+
"""Return an instance of this class constructed from the given string."""
43+
match = cls._pathway_key_regex.fullmatch(serialized)
44+
if not match:
45+
raise InvalidKeyError(cls, serialized)
46+
return cls(*match.groups())
47+
48+
def _to_string(self) -> str:
49+
"""Return a string representing this key."""
50+
return f"{self.org}+{self.path_id}" # type: ignore[attr-defined]
51+
52+
53+
class PathwayKeyField(LearningContextKeyField):
54+
"""Django model field for PathwayKey."""
55+
56+
description = "A PathwayKey object"
57+
KEY_CLASS = PathwayKey
58+
# Declare the field types for the django-stubs mypy type hint plugin:
59+
_pyi_private_set_type: PathwayKey | str | None
60+
_pyi_private_get_type: PathwayKey | None
61+
62+
def __init__(self, *args, **kwargs):
63+
kwargs.setdefault("max_length", 255)
64+
super().__init__(*args, **kwargs)
65+
66+
def to_python(self, value) -> None | OpaqueKey:
67+
"""Convert the input value to a PathwayKey object."""
68+
try:
69+
return super().to_python(value)
70+
except InvalidKeyError:
71+
# pylint: disable=raise-missing-from
72+
raise ValidationError("Invalid format. Use: 'path-v1:{org}+{path_id}'")
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Models that comprise the pathways applet."""
2+
3+
from .enrollment import PathwayEnrollment, PathwayEnrollmentAllowed, PathwayEnrollmentAudit
4+
from .pathway import Pathway
5+
from .pathway_step import PathwayStep
6+
7+
__all__ = [
8+
"Pathway",
9+
"PathwayEnrollment",
10+
"PathwayEnrollmentAllowed",
11+
"PathwayEnrollmentAudit",
12+
"PathwayStep",
13+
]
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""Enrollment models for Pathways."""
2+
3+
from django.conf import settings
4+
from django.db import models
5+
from django.utils.translation import gettext_lazy as _
6+
7+
from openedx_django_lib.validators import validate_utc_datetime
8+
9+
from .pathway import Pathway
10+
11+
12+
class PathwayEnrollment(models.Model):
13+
"""
14+
Tracks a user's enrollment in a pathway.
15+
16+
.. no_pii:
17+
"""
18+
19+
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="pathway_enrollments")
20+
pathway = models.ForeignKey(Pathway, on_delete=models.CASCADE, related_name="enrollments")
21+
is_active = models.BooleanField(default=True, help_text=_("Indicates whether the learner is enrolled."))
22+
created = models.DateTimeField(auto_now_add=True, validators=[validate_utc_datetime])
23+
modified = models.DateTimeField(auto_now=True, validators=[validate_utc_datetime])
24+
25+
def __str__(self) -> str:
26+
"""User-friendly string representation of this model."""
27+
return f"PathwayEnrollment of user={self.user_id} in {self.pathway_id}"
28+
29+
class Meta:
30+
"""Model options."""
31+
32+
verbose_name = _("Pathway Enrollment")
33+
verbose_name_plural = _("Pathway Enrollments")
34+
constraints = [
35+
models.UniqueConstraint(
36+
fields=["user", "pathway"],
37+
name="oel_pathway_enroll_uniq",
38+
),
39+
]
40+
41+
42+
class PathwayEnrollmentAllowed(models.Model):
43+
"""
44+
Pre-registration allowlist for invite-only pathways.
45+
46+
These entities are created when learners are invited/enrolled before they register an account.
47+
48+
.. pii: The email field is not retired to allow future learners to enroll.
49+
.. pii_types: email_address
50+
.. pii_retirement: retained
51+
"""
52+
53+
pathway = models.ForeignKey(Pathway, on_delete=models.CASCADE, related_name="enrollment_allowed")
54+
email = models.EmailField(db_index=True)
55+
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True)
56+
is_active = models.BooleanField(
57+
default=True, db_index=True, help_text=_("Indicates if the enrollment allowance is active")
58+
)
59+
created = models.DateTimeField(auto_now_add=True, validators=[validate_utc_datetime])
60+
61+
def __str__(self) -> str:
62+
"""User-friendly string representation of this model."""
63+
return f"PathwayEnrollmentAllowed for {self.email} in {self.pathway_id}"
64+
65+
class Meta:
66+
"""Model options."""
67+
68+
verbose_name = _("Pathway Enrollment Allowed")
69+
verbose_name_plural = _("Pathway Enrollments Allowed")
70+
constraints = [
71+
models.UniqueConstraint(
72+
fields=["pathway", "email"],
73+
name="oel_pathway_enrollallow_uniq",
74+
),
75+
]
76+
77+
78+
# TODO: Create receivers to automatically create audit records.
79+
class PathwayEnrollmentAudit(models.Model):
80+
"""
81+
Audit log for pathway enrollment changes.
82+
83+
.. no_pii:
84+
"""
85+
86+
# State transition constants (copied from openedx-platform to maintain consistency)
87+
UNENROLLED_TO_ALLOWEDTOENROLL = "from unenrolled to allowed to enroll"
88+
ALLOWEDTOENROLL_TO_ENROLLED = "from allowed to enroll to enrolled"
89+
ENROLLED_TO_ENROLLED = "from enrolled to enrolled"
90+
ENROLLED_TO_UNENROLLED = "from enrolled to unenrolled"
91+
UNENROLLED_TO_ENROLLED = "from unenrolled to enrolled"
92+
ALLOWEDTOENROLL_TO_UNENROLLED = "from allowed to enroll to unenrolled"
93+
UNENROLLED_TO_UNENROLLED = "from unenrolled to unenrolled"
94+
DEFAULT_TRANSITION_STATE = "N/A"
95+
96+
TRANSITION_STATES = (
97+
(UNENROLLED_TO_ALLOWEDTOENROLL, UNENROLLED_TO_ALLOWEDTOENROLL),
98+
(ALLOWEDTOENROLL_TO_ENROLLED, ALLOWEDTOENROLL_TO_ENROLLED),
99+
(ENROLLED_TO_ENROLLED, ENROLLED_TO_ENROLLED),
100+
(ENROLLED_TO_UNENROLLED, ENROLLED_TO_UNENROLLED),
101+
(UNENROLLED_TO_ENROLLED, UNENROLLED_TO_ENROLLED),
102+
(ALLOWEDTOENROLL_TO_UNENROLLED, ALLOWEDTOENROLL_TO_UNENROLLED),
103+
(UNENROLLED_TO_UNENROLLED, UNENROLLED_TO_UNENROLLED),
104+
(DEFAULT_TRANSITION_STATE, DEFAULT_TRANSITION_STATE),
105+
)
106+
107+
enrolled_by = models.ForeignKey(
108+
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="pathway_enrollment_audits"
109+
)
110+
enrollment = models.ForeignKey(PathwayEnrollment, on_delete=models.CASCADE, null=True, related_name="audit_log")
111+
enrollment_allowed = models.ForeignKey(
112+
PathwayEnrollmentAllowed, on_delete=models.CASCADE, null=True, related_name="audit_log"
113+
)
114+
state_transition = models.CharField(max_length=255, choices=TRANSITION_STATES, default=DEFAULT_TRANSITION_STATE)
115+
reason = models.TextField(blank=True)
116+
org = models.CharField(max_length=255, blank=True, db_index=True)
117+
role = models.CharField(max_length=255, blank=True)
118+
created = models.DateTimeField(auto_now_add=True, validators=[validate_utc_datetime])
119+
120+
def __str__(self):
121+
"""User-friendly string representation of this model."""
122+
enrollee = "unknown"
123+
pathway = "unknown"
124+
125+
if self.enrollment:
126+
enrollee = self.enrollment.user
127+
pathway = self.enrollment.pathway_id
128+
elif self.enrollment_allowed:
129+
enrollee = self.enrollment_allowed.user or self.enrollment_allowed.email
130+
pathway = self.enrollment_allowed.pathway_id
131+
132+
return f"{self.state_transition} for {enrollee} in {pathway}"
133+
134+
class Meta:
135+
"""Model options."""
136+
137+
verbose_name = _("Pathway Enrollment Audit")
138+
verbose_name_plural = _("Pathway Enrollment Audits")
139+
ordering = ["-created"]

0 commit comments

Comments
 (0)