learning-paths-plugin 0.3.1__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/CHANGELOG.rst +26 -0
  2. {learning_paths_plugin-0.3.1/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.2}/PKG-INFO +28 -1
  3. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/__init__.py +1 -1
  4. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/admin.py +55 -8
  5. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/serializers.py +20 -20
  6. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/urls.py +8 -2
  7. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/views.py +84 -16
  8. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/compat.py +20 -7
  9. learning_paths_plugin-0.3.2/learning_paths/conftest.py +13 -0
  10. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/keys.py +6 -7
  11. learning_paths_plugin-0.3.2/learning_paths/migrations/0009_remove_learningpath_slug.py +17 -0
  12. learning_paths_plugin-0.3.2/learning_paths/migrations/0010_learningpath_invite_only.py +22 -0
  13. learning_paths_plugin-0.3.2/learning_paths/migrations/0011_replace_learningpath_image_url_with_image.py +29 -0
  14. learning_paths_plugin-0.3.2/learning_paths/migrations/0012_alter_learningpath_subtitle.py +18 -0
  15. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/models.py +104 -14
  16. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2/learning_paths_plugin.egg-info}/PKG-INFO +28 -1
  17. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/SOURCES.txt +6 -4
  18. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/requires.txt +1 -0
  19. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/requirements/base.in +1 -0
  20. learning_paths_plugin-0.3.1/tests/test_keys.py +0 -110
  21. learning_paths_plugin-0.3.1/tests/test_models.py +0 -87
  22. learning_paths_plugin-0.3.1/tests/test_receivers.py +0 -78
  23. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/LICENSE.txt +0 -0
  24. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/MANIFEST.in +0 -0
  25. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/README.rst +0 -0
  26. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/api/__init__.py +0 -0
  27. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/api/urls.py +0 -0
  28. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/__init__.py +0 -0
  29. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/filters.py +0 -0
  30. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/permissions.py +0 -0
  31. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/utils.py +0 -0
  32. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/apps.py +0 -0
  33. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0001_initial.py +0 -0
  34. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0002_learningpath_uuid.py +0 -0
  35. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0003_learningpath_subtitle.py +0 -0
  36. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0004_auto_20240207_1633.py +0 -0
  37. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py +0 -0
  38. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0006_enrollment_models.py +0 -0
  39. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0007_replace_uuid_with_learningpathkey.py +0 -0
  40. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0008_remove_learningpathstep_relative_due_date_in_days.py +0 -0
  41. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/__init__.py +0 -0
  42. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/receivers.py +0 -0
  43. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/settings.py +0 -0
  44. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/templates/learning_paths/base.html +0 -0
  45. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/urls.py +0 -0
  46. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/dependency_links.txt +0 -0
  47. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/entry_points.txt +0 -0
  48. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/not-zip-safe +0 -0
  49. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/top_level.txt +0 -0
  50. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/pyproject.toml +0 -0
  51. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/requirements/constraints.txt +0 -0
  52. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/setup.cfg +0 -0
  53. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/setup.py +0 -0
@@ -16,6 +16,32 @@ Unreleased
16
16
 
17
17
  *
18
18
 
19
+ 0.3.2 - 2025-05-02
20
+ ******************
21
+
22
+ Added
23
+ =====
24
+
25
+ * Course key selection in admin forms.
26
+ * Learning Path selection field in admin forms.
27
+ * Enrollment status to the Learning Path list and retrieve APIs.
28
+ * Invite-only functionality for Learning Paths.
29
+ * Course enrollment API.
30
+
31
+ Changed
32
+ =======
33
+
34
+ * The Learning Path ``subtitle`` to ``TextField`` and made it optional.
35
+ * The image URL field to ``ImageField``.
36
+ * The user field on the admin enrollments page to raw ID, to prevent the page
37
+ from retrieving all users in the system.
38
+
39
+ Removed
40
+ =======
41
+
42
+ * The ``slug`` field from the Learning Path model.
43
+ * The UUID compatibility layer from Learning Path keys.
44
+
19
45
  0.3.1 - 2025-04-14
20
46
  ******************
21
47
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning-paths-plugin
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Learning Paths plugin
5
5
  Home-page: https://github.com/open-craft/learning-paths-plugin
6
6
  Author: OpenCraft
@@ -24,6 +24,7 @@ Requires-Dist: edx-django-utils
24
24
  Requires-Dist: edx-opaque-keys
25
25
  Requires-Dist: openedx-atlas
26
26
  Requires-Dist: openedx-completion-aggregator
27
+ Requires-Dist: pillow
27
28
  Dynamic: author
28
29
  Dynamic: author-email
29
30
  Dynamic: classifier
@@ -120,6 +121,32 @@ Unreleased
120
121
 
121
122
  *
122
123
 
124
+ 0.3.2 - 2025-05-02
125
+ ******************
126
+
127
+ Added
128
+ =====
129
+
130
+ * Course key selection in admin forms.
131
+ * Learning Path selection field in admin forms.
132
+ * Enrollment status to the Learning Path list and retrieve APIs.
133
+ * Invite-only functionality for Learning Paths.
134
+ * Course enrollment API.
135
+
136
+ Changed
137
+ =======
138
+
139
+ * The Learning Path ``subtitle`` to ``TextField`` and made it optional.
140
+ * The image URL field to ``ImageField``.
141
+ * The user field on the admin enrollments page to raw ID, to prevent the page
142
+ from retrieving all users in the system.
143
+
144
+ Removed
145
+ =======
146
+
147
+ * The ``slug`` field from the Learning Path model.
148
+ * The UUID compatibility layer from Learning Path keys.
149
+
123
150
  0.3.1 - 2025-04-14
124
151
  ******************
125
152
 
@@ -2,4 +2,4 @@
2
2
  Learning Paths plugin.
3
3
  """
4
4
 
5
- __version__ = "0.3.1"
5
+ __version__ = "0.3.2"
@@ -29,13 +29,59 @@ def get_course_keys_choices():
29
29
  yield key, key
30
30
 
31
31
 
32
+ class CourseKeyDatalistWidget(forms.TextInput):
33
+ """A widget that provides a datalist for course keys."""
34
+
35
+ def __init__(self, choices=None, attrs=None):
36
+ """Initialize the widget with a datalist and apply styles."""
37
+ attrs = attrs or {}
38
+ attrs.update(
39
+ {
40
+ "style": "width: 30em;",
41
+ "class": "form-control datalist-input",
42
+ "placeholder": _("Type to search courses..."),
43
+ }
44
+ )
45
+ super().__init__(attrs)
46
+ self.choices = choices or []
47
+
48
+ def render(self, name, value, attrs=None, renderer=None):
49
+ """Render the widget with a datalist."""
50
+ final_attrs = attrs or {}
51
+ data_list_id = f"datalist_{name}"
52
+ final_attrs["list"] = data_list_id
53
+
54
+ text_input_html = super().render(name, value, attrs, renderer)
55
+ data_list_id = f"datalist_{name}"
56
+ options = "\n".join(f'<option value="{choice}" />' for choice in self.choices)
57
+ datalist_html = f'<datalist id="{data_list_id}">\n{options}\n</datalist>'
58
+ return f"{text_input_html}\n{datalist_html}"
59
+
60
+
32
61
  class LearningPathStepForm(forms.ModelForm):
33
- """Admin form for Learning Path step."""
62
+ """Form for Learning Path step."""
63
+
64
+ def __init__(self, *args, **kwargs):
65
+ """Lazily fetch course keys to avoid calling compat code in all environments."""
66
+ super().__init__(*args, **kwargs)
67
+ self._course_keys = get_course_keys_with_outlines()
68
+ self.fields["course_key"].widget = CourseKeyDatalistWidget(
69
+ choices=self._course_keys
70
+ )
71
+
72
+ course_key = forms.CharField(label=_("Course"))
73
+
74
+ def clean_course_key(self):
75
+ """Validate that the course key is on the list of available course keys."""
76
+ course_key = self.cleaned_data.get("course_key")
77
+ valid_keys = {str(key).strip() for key in self._course_keys}
78
+
79
+ if course_key not in valid_keys:
80
+ raise ValidationError(
81
+ _("Invalid course key. Please select a course from the suggestions.")
82
+ )
34
83
 
35
- # TODO: Use autocomplete select instead.
36
- # See <https://github.com/open-craft/section-to-course/blob/db6fd6f8f4478e91bb531e6c2fa50143e1c2e012/
37
- # section_to_course/admin.py#L31-L140>
38
- course_key = forms.ChoiceField(choices=get_course_keys_choices, label=_("Course"))
84
+ return course_key
39
85
 
40
86
 
41
87
  class LearningPathStepInline(admin.TabularInline):
@@ -102,17 +148,17 @@ class LearningPathAdmin(admin.ModelAdmin):
102
148
  form = BulkEnrollUsersForm
103
149
 
104
150
  search_fields = [
105
- "slug",
106
151
  "display_name",
107
152
  "key",
108
153
  ]
109
154
  list_display = (
110
155
  "key",
111
- "slug",
112
156
  "display_name",
113
157
  "level",
114
158
  "duration_in_days",
159
+ "invite_only",
115
160
  )
161
+ list_filter = ("invite_only",)
116
162
  readonly_fields = ("key",)
117
163
 
118
164
  inlines = [
@@ -148,12 +194,13 @@ class EnrolledUsersAdmin(admin.ModelAdmin):
148
194
  """Admin for Learning Path enrollment."""
149
195
 
150
196
  model = LearningPathEnrollment
197
+ raw_id_fields = ("user",)
198
+ autocomplete_fields = ["learning_path"]
151
199
 
152
200
  search_fields = [
153
201
  "id",
154
202
  "user__username",
155
203
  "learning_path__key",
156
- "learning_path__slug",
157
204
  "learning_path__display_name",
158
205
  ]
159
206
 
@@ -35,15 +35,15 @@ class LearningPathAsProgramSerializer(serializers.ModelSerializer):
35
35
  course_codes = serializers.SerializerMethodField()
36
36
 
37
37
  def get_marketing_slug(self, obj):
38
- return obj.slug
38
+ return str(obj.key)
39
39
 
40
40
  def get_status(self, obj): # pylint: disable=unused-argument
41
41
  return DEFAULT_STATUS
42
42
 
43
43
  def get_banner_image_urls(self, obj):
44
- if obj.image_url:
44
+ if obj.image:
45
45
  image_key = f"w{IMAGE_WIDTH}h{IMAGE_HEIGHT}"
46
- return {image_key: obj.image_url}
46
+ return {image_key: obj.image.url}
47
47
  return {}
48
48
 
49
49
  def get_organizations(self, obj): # pylint: disable=unused-argument
@@ -107,19 +107,31 @@ class LearningPathListSerializer(serializers.ModelSerializer):
107
107
  required_completion = serializers.FloatField(
108
108
  source="grading_criteria.required_completion", read_only=True
109
109
  )
110
+ is_enrolled = serializers.SerializerMethodField()
111
+ invite_only = serializers.BooleanField()
112
+ image = serializers.ImageField(read_only=True)
110
113
 
111
114
  class Meta:
112
115
  model = LearningPath
113
116
  fields = [
114
117
  "key",
115
- "slug",
116
118
  "display_name",
117
- "image_url",
119
+ "image",
118
120
  "sequential",
119
121
  "steps",
120
122
  "required_completion",
123
+ "is_enrolled",
124
+ "invite_only",
121
125
  ]
122
126
 
127
+ def get_is_enrolled(self, obj):
128
+ """
129
+ Check if the current user is enrolled in this learning path.
130
+ """
131
+ if hasattr(obj, "is_enrolled"):
132
+ return obj.is_enrolled
133
+ return False
134
+
123
135
 
124
136
  class SkillSerializer(serializers.ModelSerializer):
125
137
  class Meta:
@@ -151,38 +163,26 @@ class AcquiredSkillSerializer(serializers.ModelSerializer):
151
163
  fields = ["skill", "level"]
152
164
 
153
165
 
154
- class LearningPathDetailSerializer(serializers.ModelSerializer):
166
+ class LearningPathDetailSerializer(LearningPathListSerializer):
155
167
  """
156
168
  Serializer for learning path details.
157
169
  """
158
170
 
159
- steps = LearningPathStepSerializer(many=True, read_only=True)
160
171
  required_skills = RequiredSkillSerializer(
161
172
  source="requiredskill_set", many=True, read_only=True
162
173
  )
163
174
  acquired_skills = AcquiredSkillSerializer(
164
175
  source="acquiredskill_set", many=True, read_only=True
165
176
  )
166
- required_completion = serializers.FloatField(
167
- source="grading_criteria.required_completion", read_only=True
168
- )
169
177
 
170
- class Meta:
171
- model = LearningPath
172
- fields = [
173
- "key",
174
- "slug",
175
- "display_name",
178
+ class Meta(LearningPathListSerializer.Meta):
179
+ fields = LearningPathListSerializer.Meta.fields + [
176
180
  "subtitle",
177
181
  "description",
178
- "image_url",
179
182
  "level",
180
183
  "duration_in_days",
181
- "sequential",
182
- "steps",
183
184
  "required_skills",
184
185
  "acquired_skills",
185
- "required_completion",
186
186
  ]
187
187
 
188
188
 
@@ -6,13 +6,14 @@ from rest_framework import routers
6
6
  from learning_paths.api.v1.views import (
7
7
  BulkEnrollView,
8
8
  LearningPathAsProgramViewSet,
9
+ LearningPathCourseEnrollmentView,
9
10
  LearningPathEnrollmentView,
10
11
  LearningPathUserGradeView,
11
12
  LearningPathUserProgressView,
12
13
  LearningPathViewSet,
13
14
  ListEnrollmentsView,
14
15
  )
15
- from learning_paths.keys import LEARNING_PATH_URL_PATTERN
16
+ from learning_paths.keys import COURSE_KEY_URL_PATTERN, LEARNING_PATH_URL_PATTERN
16
17
 
17
18
  router = routers.SimpleRouter()
18
19
  router.register(
@@ -32,7 +33,7 @@ urlpatterns = router.urls + [
32
33
  name="learning-path-grade",
33
34
  ),
34
35
  re_path(
35
- rf"{LEARNING_PATH_URL_PATTERN}/enrollments/",
36
+ rf"{LEARNING_PATH_URL_PATTERN}/enrollments/$",
36
37
  LearningPathEnrollmentView.as_view(),
37
38
  name="learning-path-enrollments",
38
39
  ),
@@ -46,4 +47,9 @@ urlpatterns = router.urls + [
46
47
  BulkEnrollView.as_view(),
47
48
  name="bulk-enroll",
48
49
  ),
50
+ re_path(
51
+ rf"{LEARNING_PATH_URL_PATTERN}/enrollments/{COURSE_KEY_URL_PATTERN}/",
52
+ LearningPathCourseEnrollmentView.as_view(),
53
+ name="learning-path-course-enroll",
54
+ ),
49
55
  ]
@@ -11,8 +11,9 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, Validat
11
11
  from django.core.validators import validate_email
12
12
  from django.shortcuts import get_object_or_404
13
13
  from opaque_keys import InvalidKeyError
14
+ from opaque_keys.edx.keys import CourseKey
14
15
  from rest_framework import generics, status, viewsets
15
- from rest_framework.exceptions import NotFound
16
+ from rest_framework.exceptions import NotFound, ParseError
16
17
  from rest_framework.pagination import PageNumberPagination
17
18
  from rest_framework.permissions import IsAdminUser, IsAuthenticated
18
19
  from rest_framework.response import Response
@@ -26,6 +27,7 @@ from learning_paths.api.v1.serializers import (
26
27
  LearningPathListSerializer,
27
28
  LearningPathProgressSerializer,
28
29
  )
30
+ from learning_paths.compat import enroll_user_in_course
29
31
  from learning_paths.keys import LearningPathKey
30
32
  from learning_paths.models import (
31
33
  LearningPath,
@@ -51,11 +53,14 @@ class LearningPathAsProgramViewSet(viewsets.ReadOnlyModelViewSet):
51
53
  https://github.com/openedx/course-discovery/blob/d6a57fd69479b3d5f5afb682d2668b58503a6af6/course_discovery/apps/course_metadata/data_loaders/api.py#L843
52
54
  """
53
55
 
54
- queryset = LearningPath.objects.all()
55
56
  permission_classes = (IsAuthenticated,)
56
57
  serializer_class = LearningPathAsProgramSerializer
57
58
  pagination_class = PageNumberPagination
58
59
 
60
+ def get_queryset(self):
61
+ """Get the learning paths visible to the current user."""
62
+ return LearningPath.objects.get_paths_visible_to_user(self.request.user)
63
+
59
64
 
60
65
  class LearningPathUserProgressView(APIView):
61
66
  """
@@ -68,8 +73,10 @@ class LearningPathUserProgressView(APIView):
68
73
  """
69
74
  Fetch the learning path progress
70
75
  """
71
- learning_path_key = LearningPathKey.from_string(learning_path_key_str)
72
- learning_path = get_object_or_404(LearningPath, key=learning_path_key)
76
+ learning_path = get_object_or_404(
77
+ LearningPath.objects.get_paths_visible_to_user(self.request.user),
78
+ key=learning_path_key_str,
79
+ )
73
80
 
74
81
  progress = get_aggregate_progress(request.user, learning_path)
75
82
  required_completion = None
@@ -80,7 +87,7 @@ class LearningPathUserProgressView(APIView):
80
87
  pass
81
88
 
82
89
  data = {
83
- "learning_path_key": str(learning_path_key),
90
+ "learning_path_key": learning_path_key_str,
84
91
  "progress": progress,
85
92
  "required_completion": required_completion,
86
93
  }
@@ -103,8 +110,10 @@ class LearningPathUserGradeView(APIView):
103
110
  Fetch learning path grade
104
111
  """
105
112
 
106
- learning_path_key = LearningPathKey.from_string(learning_path_key_str)
107
- learning_path = get_object_or_404(LearningPath, key=learning_path_key)
113
+ learning_path = get_object_or_404(
114
+ LearningPath.objects.get_paths_visible_to_user(self.request.user),
115
+ key=learning_path_key_str,
116
+ )
108
117
 
109
118
  try:
110
119
  grading_criteria = learning_path.grading_criteria
@@ -117,7 +126,7 @@ class LearningPathUserGradeView(APIView):
117
126
  grade = grading_criteria.calculate_grade(request.user)
118
127
 
119
128
  data = {
120
- "learning_path_key": str(learning_path_key),
129
+ "learning_path_key": learning_path_key_str,
121
130
  "grade": grade,
122
131
  "required_grade": grading_criteria.required_grade,
123
132
  }
@@ -134,11 +143,23 @@ class LearningPathViewSet(viewsets.ReadOnlyModelViewSet):
134
143
  including steps and associated skills.
135
144
  """
136
145
 
137
- queryset = LearningPath.objects.prefetch_related("steps", "grading_criteria")
138
146
  permission_classes = (IsAuthenticated,)
139
147
  pagination_class = PageNumberPagination
140
148
  lookup_field = "key"
141
149
 
150
+ def get_queryset(self):
151
+ """
152
+ Get all learning paths and prefetch the related data.
153
+ """
154
+ user = self.request.user
155
+ queryset = LearningPath.objects.get_paths_visible_to_user(
156
+ user
157
+ ).prefetch_related(
158
+ "steps",
159
+ "grading_criteria",
160
+ )
161
+ return queryset
162
+
142
163
  def get_serializer_class(self):
143
164
  if self.action == "list":
144
165
  return LearningPathListSerializer
@@ -159,6 +180,17 @@ class LearningPathEnrollmentView(APIView):
159
180
 
160
181
  permission_classes = [IsAuthenticated, IsAdminOrSelf]
161
182
 
183
+ def _get_learning_path(self, learning_path_key_str: str) -> LearningPath:
184
+ """
185
+ Get the learning path and verify user has access to it.
186
+
187
+ :raises: Http404 if the learning path is not found or user does not have access.
188
+ """
189
+ return get_object_or_404(
190
+ LearningPath.objects.get_paths_visible_to_user(self.request.user),
191
+ key=learning_path_key_str,
192
+ )
193
+
162
194
  def get(self, request, learning_path_key_str: str):
163
195
  """Get the learning path of users.
164
196
 
@@ -169,8 +201,7 @@ class LearningPathEnrollmentView(APIView):
169
201
  username (optional): When provided it returns the enrollment for
170
202
  the specified user.
171
203
  """
172
- learning_path_key = LearningPathKey.from_string(learning_path_key_str)
173
- learning_path = get_object_or_404(LearningPath, key=learning_path_key)
204
+ learning_path = self._get_learning_path(learning_path_key_str)
174
205
 
175
206
  enrollments = LearningPathEnrollment.objects.filter(
176
207
  learning_path=learning_path, is_active=True
@@ -189,7 +220,7 @@ class LearningPathEnrollmentView(APIView):
189
220
  """Enroll learners in Learning Paths.
190
221
 
191
222
  Staff/Admin can enroll anyone with the username query param.
192
- Learners can enroll only themselves.
223
+ Learners can enroll only themselves, and only if the learning path is not invite-only.
193
224
 
194
225
  Example payload::
195
226
 
@@ -198,8 +229,7 @@ class LearningPathEnrollmentView(APIView):
198
229
  }
199
230
 
200
231
  """
201
- learning_path_key = LearningPathKey.from_string(learning_path_key_str)
202
- learning_path = get_object_or_404(LearningPath, key=learning_path_key)
232
+ learning_path = self._get_learning_path(learning_path_key_str)
203
233
  username = request.data.get("username")
204
234
  user = get_object_or_404(User, username=username) if username else request.user
205
235
 
@@ -235,8 +265,7 @@ class LearningPathEnrollmentView(APIView):
235
265
  }
236
266
 
237
267
  """
238
- learning_path_key = LearningPathKey.from_string(learning_path_key_str)
239
- learning_path = get_object_or_404(LearningPath, key=learning_path_key)
268
+ learning_path = self._get_learning_path(learning_path_key_str)
240
269
  username = request.data.get("username")
241
270
  user = get_object_or_404(User, username=username) if username else request.user
242
271
 
@@ -369,3 +398,42 @@ class BulkEnrollView(APIView):
369
398
  },
370
399
  status=status.HTTP_201_CREATED,
371
400
  )
401
+
402
+
403
+ class LearningPathCourseEnrollmentView(APIView):
404
+ """API View to enroll a user in a course that's part of a learning path."""
405
+
406
+ permission_classes = [IsAuthenticated, IsAdminOrSelf]
407
+
408
+ def _get_enrolled_learning_path(self, learning_path_key_str: str) -> LearningPath:
409
+ """
410
+ Get the learning path and verify the user has access and is enrolled.
411
+
412
+ :raises: Http404 if the learning path is not found or the user does not have access.
413
+ """
414
+ return get_object_or_404(
415
+ LearningPath.objects.get_paths_visible_to_user(self.request.user).filter(
416
+ is_enrolled=True
417
+ ),
418
+ key=learning_path_key_str,
419
+ )
420
+
421
+ def post(self, request, learning_path_key_str: str, course_key_str: str):
422
+ """
423
+ Enroll a user in a course that's part of a learning path.
424
+
425
+ The user must be enrolled in the learning path, and the course must be a step in the path.
426
+ """
427
+ learning_path = self._get_enrolled_learning_path(learning_path_key_str)
428
+ course_key = CourseKey.from_string(course_key_str)
429
+
430
+ if not learning_path.steps.filter(course_key=course_key).exists():
431
+ raise ParseError("The course is not part of this learning path.")
432
+
433
+ if enroll_user_in_course(request.user, course_key):
434
+ return Response(
435
+ {"detail": "User successfully enrolled in the course."},
436
+ status=status.HTTP_201_CREATED,
437
+ )
438
+ else:
439
+ raise ParseError("Failed to enroll the user in the course.")
@@ -2,11 +2,14 @@
2
2
  Compatibility layer for testing without Open edX.
3
3
  """
4
4
 
5
+ import logging
5
6
  from datetime import datetime
6
7
 
7
8
  from django.contrib.auth.models import AbstractBaseUser
8
9
  from opaque_keys.edx.keys import CourseKey
9
10
 
11
+ log = logging.getLogger(__name__)
12
+
10
13
 
11
14
  def get_user_course_grade(user: AbstractBaseUser, course_key: CourseKey):
12
15
  """
@@ -31,7 +34,7 @@ def get_catalog_api_client(user: AbstractBaseUser):
31
34
  return api_client(user)
32
35
 
33
36
 
34
- def get_course_keys_with_outlines():
37
+ def get_course_keys_with_outlines() -> list[CourseKey]:
35
38
  """
36
39
  Retrieve course keys.
37
40
  """
@@ -57,9 +60,19 @@ def get_course_due_date(course_key: CourseKey) -> datetime | None:
57
60
  return None
58
61
 
59
62
 
60
- __all__ = [
61
- "get_course_keys_with_outlines",
62
- "get_catalog_api_client",
63
- "get_user_course_grade",
64
- "get_course_due_date",
65
- ]
63
+ def enroll_user_in_course(user: AbstractBaseUser, course_key: CourseKey) -> bool:
64
+ """Enroll a user in a course."""
65
+ # pylint: disable=import-outside-toplevel, import-error
66
+ from common.djangoapps.student.api import CourseEnrollment
67
+ from common.djangoapps.student.models.course_enrollment import (
68
+ CourseEnrollmentException,
69
+ )
70
+
71
+ try:
72
+ CourseEnrollment.enroll(user, course_key)
73
+ return True
74
+ except CourseEnrollmentException as exc:
75
+ log.exception(
76
+ "Failed to enroll user %s in course %s: %s", user, course_key, exc
77
+ )
78
+ return False
@@ -0,0 +1,13 @@
1
+ """Pytest fixtures."""
2
+
3
+ import pytest
4
+ from django.test import override_settings
5
+
6
+
7
+ @pytest.fixture
8
+ def temp_media(tmpdir):
9
+ """Temporarily override MEDIA_ROOT to a pytest tmpdir."""
10
+ temp_dir = str(tmpdir.mkdir("media"))
11
+
12
+ with override_settings(MEDIA_ROOT=temp_dir):
13
+ yield temp_dir
@@ -10,6 +10,12 @@ from opaque_keys import InvalidKeyError
10
10
  from opaque_keys.edx.django.models import LearningContextKeyField
11
11
  from opaque_keys.edx.keys import LearningContextKey
12
12
 
13
+ COURSE_KEY_NAMESPACE = "course-v1"
14
+ COURSE_KEY_PATTERN = r"([^+]+)\+([^+]+)\+([^+]+)"
15
+ COURSE_KEY_URL_PATTERN = (
16
+ rf"(?P<course_key_str>{COURSE_KEY_NAMESPACE}:{COURSE_KEY_PATTERN})"
17
+ )
18
+
13
19
  LEARNING_PATH_NAMESPACE = "path-v1"
14
20
  LEARNING_PATH_PATTERN = r"([^+]+)\+([^+]+)\+([^+]+)\+([^+]+)"
15
21
  LEARNING_PATH_URL_PATTERN = (
@@ -62,14 +68,7 @@ class LearningPathKeyField(LearningContextKeyField):
62
68
 
63
69
  def to_python(self, value):
64
70
  """Convert the input value to a LearningPathKey object."""
65
- # TODO: https://github.com/open-craft/learning-paths-plugin/issues/12
66
- if not value:
67
- return None
68
-
69
71
  try:
70
- if not value:
71
- raise InvalidKeyError(self.KEY_CLASS, None)
72
-
73
72
  return super().to_python(value)
74
73
  except InvalidKeyError:
75
74
  raise ValidationError( # pylint: disable=raise-missing-from
@@ -0,0 +1,17 @@
1
+ # Generated by Django 4.2.20 on 2025-04-22 21:15
2
+
3
+ from django.db import migrations
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ("learning_paths", "0008_remove_learningpathstep_relative_due_date_in_days"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.RemoveField(
14
+ model_name="learningpath",
15
+ name="slug",
16
+ ),
17
+ ]
@@ -0,0 +1,22 @@
1
+ # Generated by Django 4.2.20 on 2025-04-23 12:10
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ("learning_paths", "0009_remove_learningpath_slug"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name="learningpath",
15
+ name="invite_only",
16
+ field=models.BooleanField(
17
+ default=True,
18
+ help_text="If enabled, only staff can enroll users and only enrolled users can see the learning path.",
19
+ verbose_name="Invite only",
20
+ ),
21
+ ),
22
+ ]
@@ -0,0 +1,29 @@
1
+ # Generated by Django 4.2.20 on 2025-04-27 20:14
2
+
3
+ from django.db import migrations, models
4
+ import learning_paths.models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ("learning_paths", "0010_learningpath_invite_only"),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.RemoveField(
15
+ model_name="learningpath",
16
+ name="image_url",
17
+ ),
18
+ migrations.AddField(
19
+ model_name="learningpath",
20
+ name="image",
21
+ field=models.ImageField(
22
+ blank=True,
23
+ help_text="Image representing this Learning Path.",
24
+ null=True,
25
+ upload_to=learning_paths.models.LearningPath._learning_path_image_upload_path,
26
+ verbose_name="Image",
27
+ ),
28
+ ),
29
+ ]