Skip to content

Commit 3663d2b

Browse files
committed
feat: implement Pathways
1 parent e42d2d9 commit 3663d2b

File tree

11 files changed

+838
-0
lines changed

11 files changed

+838
-0
lines changed

openedx_learning/apps/openedx_content/admin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@
1010
from .applets.publishing.admin import *
1111
from .applets.sections.admin import *
1212
from .applets.subsections.admin import *
13+
from .applets.pathways.admin import *
1314
from .applets.units.admin import *

openedx_learning/apps/openedx_content/applets/pathways/__init__.py

Whitespace-only changes.
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Django admin for Pathways."""
2+
3+
from django.contrib import admin
4+
5+
from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin
6+
7+
from .models import (
8+
Pathway,
9+
PathwayEnrollment,
10+
PathwayEnrollmentAllowed,
11+
PathwayEnrollmentAudit,
12+
PathwayStep,
13+
)
14+
15+
16+
class PathwayStepInline(admin.TabularInline):
17+
"""Inline table for pathway steps within a pathway."""
18+
19+
model = PathwayStep
20+
fields = ["order", "step_type", "context_key"]
21+
ordering = ["order"]
22+
extra = 0
23+
24+
25+
@admin.register(Pathway)
26+
class PathwayAdmin(admin.ModelAdmin):
27+
"""Admin for Pathway model."""
28+
29+
list_display = ["key", "display_name", "org", "is_active", "sequential", "created"]
30+
list_filter = ["is_active", "sequential", "invite_only", "org"]
31+
search_fields = ["key", "display_name"]
32+
inlines = [PathwayStepInline]
33+
34+
35+
class PathwayEnrollmentAuditInline(admin.TabularInline):
36+
"""Inline admin for PathwayEnrollmentAudit records."""
37+
38+
model = PathwayEnrollmentAudit
39+
fk_name = "enrollment"
40+
extra = 0
41+
exclude = ["enrollment_allowed"]
42+
readonly_fields = [
43+
"state_transition",
44+
"enrolled_by",
45+
"reason",
46+
"org",
47+
"role",
48+
"created",
49+
]
50+
51+
def has_add_permission(self, request, obj=None):
52+
"""Disable manual creation of audit records."""
53+
return False
54+
55+
def has_delete_permission(self, request, obj=None):
56+
"""Disable deletion of audit records."""
57+
return False
58+
59+
60+
@admin.register(PathwayEnrollment)
61+
class PathwayEnrollmentAdmin(admin.ModelAdmin):
62+
"""Admin for PathwayEnrollment model."""
63+
64+
raw_id_fields = ("user",)
65+
autocomplete_fields = ["pathway"]
66+
list_display = ["id", "user", "pathway", "is_active", "created"]
67+
list_filter = ["pathway__key", "created", "is_active"]
68+
search_fields = ["id", "user__username", "pathway__key", "pathway__display_name"]
69+
inlines = [PathwayEnrollmentAuditInline]
70+
71+
72+
class PathwayEnrollmentAllowedAuditInline(admin.TabularInline):
73+
"""Inline admin for PathwayEnrollmentAudit records related to enrollment allowed."""
74+
75+
model = PathwayEnrollmentAudit
76+
fk_name = "enrollment_allowed"
77+
extra = 0
78+
exclude = ["enrollment"]
79+
readonly_fields = [
80+
"state_transition",
81+
"enrolled_by",
82+
"reason",
83+
"org",
84+
"role",
85+
"created",
86+
]
87+
88+
def has_add_permission(self, request, obj=None):
89+
"""Disable manual creation of audit records."""
90+
return False
91+
92+
def has_delete_permission(self, request, obj=None):
93+
"""Disable deletion of audit records."""
94+
return False
95+
96+
97+
@admin.register(PathwayEnrollmentAllowed)
98+
class PathwayEnrollmentAllowedAdmin(admin.ModelAdmin):
99+
"""Admin for PathwayEnrollmentAllowed model."""
100+
101+
autocomplete_fields = ["pathway"]
102+
list_display = ["id", "email", "get_user", "pathway", "created"]
103+
list_filter = ["pathway", "created"]
104+
search_fields = ["email", "user__username", "user__email", "pathway__key"]
105+
readonly_fields = ["user", "created"]
106+
inlines = [PathwayEnrollmentAllowedAuditInline]
107+
108+
def get_user(self, obj):
109+
"""Get the associated user, if any."""
110+
return obj.user.username if obj.user else "-"
111+
112+
get_user.short_description = "User"
113+
114+
115+
@admin.register(PathwayEnrollmentAudit)
116+
class PathwayEnrollmentAuditAdmin(ReadOnlyModelAdmin):
117+
"""Admin configuration for PathwayEnrollmentAudit model."""
118+
119+
list_display = ["id", "state_transition", "enrolled_by", "get_enrollee", "get_pathway", "created", "org", "role"]
120+
list_filter = ["state_transition", "created", "org", "role"]
121+
search_fields = [
122+
"enrolled_by__username",
123+
"enrolled_by__email",
124+
"enrollment__user__username",
125+
"enrollment__user__email",
126+
"enrollment_allowed__email",
127+
"enrollment__pathway__key",
128+
"enrollment_allowed__pathway__key",
129+
"reason",
130+
]
131+
132+
def get_enrollee(self, obj):
133+
"""Get the enrollee (user or email)."""
134+
if obj.enrollment:
135+
return obj.enrollment.user.username
136+
elif obj.enrollment_allowed:
137+
return obj.enrollment_allowed.user.username if obj.enrollment_allowed.user else obj.enrollment_allowed.email
138+
return "-"
139+
140+
get_enrollee.short_description = "Enrollee"
141+
142+
def get_pathway(self, obj):
143+
"""Get the pathway title."""
144+
if obj.enrollment:
145+
return obj.enrollment.pathway_id
146+
elif obj.enrollment_allowed:
147+
return obj.enrollment_allowed.pathway_id
148+
return "-"
149+
150+
get_pathway.short_description = "Pathway"
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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+
from django.core.exceptions import ValidationError
12+
from opaque_keys import InvalidKeyError, OpaqueKey
13+
from opaque_keys.edx.keys import LearningContextKey
14+
from opaque_keys.edx.django.models import LearningContextKeyField
15+
16+
PATHWAY_NAMESPACE = "path-v1"
17+
PATHWAY_PATTERN = r"([^+]+)\+([^+]+)"
18+
PATHWAY_URL_PATTERN = rf"(?P<pathway_key_str>{PATHWAY_NAMESPACE}:{PATHWAY_PATTERN})"
19+
20+
21+
class PathwayKey(LearningContextKey):
22+
"""
23+
Key for identifying a Pathway.
24+
25+
Format: path-v1:{org}+{path_id}
26+
Example: path-v1:OpenedX+DemoPathway
27+
"""
28+
29+
CANONICAL_NAMESPACE = PATHWAY_NAMESPACE
30+
KEY_FIELDS = ("org", "path_id")
31+
CHECKED_INIT = False
32+
33+
__slots__ = KEY_FIELDS
34+
_pathway_key_regex = re.compile(PATHWAY_PATTERN)
35+
36+
def __init__(self, org: str, path_id: str):
37+
super().__init__(org=org, path_id=path_id)
38+
39+
@classmethod
40+
def _from_string(cls, serialized: str) -> Self:
41+
"""Return an instance of this class constructed from the given string."""
42+
match = cls._pathway_key_regex.fullmatch(serialized)
43+
if not match:
44+
raise InvalidKeyError(cls, serialized)
45+
return cls(*match.groups())
46+
47+
def _to_string(self) -> str:
48+
"""Return a string representing this key."""
49+
return f"{self.org}+{self.path_id}"
50+
51+
52+
class PathwayKeyField(LearningContextKeyField):
53+
"""Django model field for PathwayKey."""
54+
55+
description = "A PathwayKey object"
56+
KEY_CLASS = PathwayKey
57+
# Declare the field types for the django-stubs mypy type hint plugin:
58+
_pyi_private_set_type: PathwayKey | str | None
59+
_pyi_private_get_type: PathwayKey | None
60+
61+
def __init__(self, *args, **kwargs):
62+
kwargs.setdefault("max_length", 255)
63+
super().__init__(*args, **kwargs)
64+
65+
def to_python(self, value) -> None | OpaqueKey:
66+
"""Convert the input value to a PathwayKey object."""
67+
try:
68+
return super().to_python(value)
69+
except InvalidKeyError:
70+
raise ValidationError("Invalid format. Use: 'path-v1:{org}+{path_id}'")
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Models that comprise the pathways applet."""
2+
3+
from .enrollment import (
4+
PathwayEnrollment,
5+
PathwayEnrollmentAllowed,
6+
PathwayEnrollmentAudit,
7+
)
8+
from .pathway import Pathway
9+
from .pathway_step import PathwayStep
10+
11+
__all__ = [
12+
"Pathway",
13+
"PathwayEnrollment",
14+
"PathwayEnrollmentAllowed",
15+
"PathwayEnrollmentAudit",
16+
"PathwayStep",
17+
]
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_learning.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)