learning-paths-plugin 0.3.1__tar.gz → 0.3.3__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 (54) hide show
  1. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/CHANGELOG.rst +34 -0
  2. {learning_paths_plugin-0.3.1/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.3}/PKG-INFO +36 -1
  3. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/__init__.py +1 -1
  4. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/admin.py +53 -14
  5. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/api/v1/serializers.py +23 -29
  6. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/api/v1/urls.py +9 -5
  7. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/api/v1/utils.py +2 -6
  8. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/api/v1/views.py +85 -32
  9. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/compat.py +18 -7
  10. learning_paths_plugin-0.3.3/learning_paths/conftest.py +13 -0
  11. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/keys.py +6 -13
  12. learning_paths_plugin-0.3.3/learning_paths/migrations/0009_remove_learningpath_slug.py +17 -0
  13. learning_paths_plugin-0.3.3/learning_paths/migrations/0010_learningpath_invite_only.py +22 -0
  14. learning_paths_plugin-0.3.3/learning_paths/migrations/0011_replace_learningpath_image_url_with_image.py +29 -0
  15. learning_paths_plugin-0.3.3/learning_paths/migrations/0012_alter_learningpath_subtitle.py +18 -0
  16. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/models.py +109 -34
  17. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/receivers.py +3 -9
  18. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3/learning_paths_plugin.egg-info}/PKG-INFO +36 -1
  19. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths_plugin.egg-info/SOURCES.txt +6 -4
  20. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths_plugin.egg-info/requires.txt +1 -0
  21. learning_paths_plugin-0.3.3/pyproject.toml +16 -0
  22. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/requirements/base.in +1 -0
  23. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/setup.py +8 -26
  24. learning_paths_plugin-0.3.1/pyproject.toml +0 -9
  25. learning_paths_plugin-0.3.1/tests/test_keys.py +0 -110
  26. learning_paths_plugin-0.3.1/tests/test_models.py +0 -87
  27. learning_paths_plugin-0.3.1/tests/test_receivers.py +0 -78
  28. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/LICENSE.txt +0 -0
  29. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/MANIFEST.in +0 -0
  30. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/README.rst +0 -0
  31. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/api/__init__.py +0 -0
  32. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/api/urls.py +0 -0
  33. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/api/v1/__init__.py +0 -0
  34. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/api/v1/filters.py +0 -0
  35. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/api/v1/permissions.py +0 -0
  36. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/apps.py +0 -0
  37. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/migrations/0001_initial.py +0 -0
  38. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/migrations/0002_learningpath_uuid.py +0 -0
  39. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/migrations/0003_learningpath_subtitle.py +0 -0
  40. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/migrations/0004_auto_20240207_1633.py +0 -0
  41. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py +0 -0
  42. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/migrations/0006_enrollment_models.py +0 -0
  43. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/migrations/0007_replace_uuid_with_learningpathkey.py +0 -0
  44. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/migrations/0008_remove_learningpathstep_relative_due_date_in_days.py +0 -0
  45. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/migrations/__init__.py +0 -0
  46. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/settings.py +0 -0
  47. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/templates/learning_paths/base.html +0 -0
  48. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/urls.py +0 -0
  49. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths_plugin.egg-info/dependency_links.txt +0 -0
  50. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths_plugin.egg-info/entry_points.txt +0 -0
  51. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths_plugin.egg-info/not-zip-safe +0 -0
  52. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths_plugin.egg-info/top_level.txt +0 -0
  53. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/requirements/constraints.txt +0 -0
  54. {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/setup.cfg +0 -0
@@ -16,6 +16,40 @@ Unreleased
16
16
 
17
17
  *
18
18
 
19
+ 0.3.3 - 2025-05-23
20
+ ******************
21
+
22
+ Changed
23
+ =======
24
+
25
+ * Changed line length from 80 to 120 characters.
26
+
27
+ 0.3.2 - 2025-05-02
28
+ ******************
29
+
30
+ Added
31
+ =====
32
+
33
+ * Course key selection in admin forms.
34
+ * Learning Path selection field in admin forms.
35
+ * Enrollment status to the Learning Path list and retrieve APIs.
36
+ * Invite-only functionality for Learning Paths.
37
+ * Course enrollment API.
38
+
39
+ Changed
40
+ =======
41
+
42
+ * The Learning Path ``subtitle`` to ``TextField`` and made it optional.
43
+ * The image URL field to ``ImageField``.
44
+ * The user field on the admin enrollments page to raw ID, to prevent the page
45
+ from retrieving all users in the system.
46
+
47
+ Removed
48
+ =======
49
+
50
+ * The ``slug`` field from the Learning Path model.
51
+ * The UUID compatibility layer from Learning Path keys.
52
+
19
53
  0.3.1 - 2025-04-14
20
54
  ******************
21
55
 
@@ -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.3
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,40 @@ Unreleased
120
121
 
121
122
  *
122
123
 
124
+ 0.3.3 - 2025-05-23
125
+ ******************
126
+
127
+ Changed
128
+ =======
129
+
130
+ * Changed line length from 80 to 120 characters.
131
+
132
+ 0.3.2 - 2025-05-02
133
+ ******************
134
+
135
+ Added
136
+ =====
137
+
138
+ * Course key selection in admin forms.
139
+ * Learning Path selection field in admin forms.
140
+ * Enrollment status to the Learning Path list and retrieve APIs.
141
+ * Invite-only functionality for Learning Paths.
142
+ * Course enrollment API.
143
+
144
+ Changed
145
+ =======
146
+
147
+ * The Learning Path ``subtitle`` to ``TextField`` and made it optional.
148
+ * The image URL field to ``ImageField``.
149
+ * The user field on the admin enrollments page to raw ID, to prevent the page
150
+ from retrieving all users in the system.
151
+
152
+ Removed
153
+ =======
154
+
155
+ * The ``slug`` field from the Learning Path model.
156
+ * The UUID compatibility layer from Learning Path keys.
157
+
123
158
  0.3.1 - 2025-04-14
124
159
  ******************
125
160
 
@@ -2,4 +2,4 @@
2
2
  Learning Paths plugin.
3
3
  """
4
4
 
5
- __version__ = "0.3.1"
5
+ __version__ = "0.3.3"
@@ -29,13 +29,55 @@ 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(choices=self._course_keys)
69
+
70
+ course_key = forms.CharField(label=_("Course"))
71
+
72
+ def clean_course_key(self):
73
+ """Validate that the course key is on the list of available course keys."""
74
+ course_key = self.cleaned_data.get("course_key")
75
+ valid_keys = {str(key).strip() for key in self._course_keys}
76
+
77
+ if course_key not in valid_keys:
78
+ raise ValidationError(_("Invalid course key. Please select a course from the suggestions."))
34
79
 
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"))
80
+ return course_key
39
81
 
40
82
 
41
83
  class LearningPathStepInline(admin.TabularInline):
@@ -89,9 +131,7 @@ class BulkEnrollUsersForm(forms.ModelForm):
89
131
  found_usernames = list(users.values_list("username", flat=True))
90
132
  invalid_usernames = set(usernames) - set(found_usernames)
91
133
  if invalid_usernames:
92
- raise ValidationError(
93
- f"The following usernames are not valid: {', '.join(invalid_usernames)}"
94
- )
134
+ raise ValidationError(f"The following usernames are not valid: {', '.join(invalid_usernames)}")
95
135
  return users
96
136
 
97
137
 
@@ -102,17 +142,17 @@ class LearningPathAdmin(admin.ModelAdmin):
102
142
  form = BulkEnrollUsersForm
103
143
 
104
144
  search_fields = [
105
- "slug",
106
145
  "display_name",
107
146
  "key",
108
147
  ]
109
148
  list_display = (
110
149
  "key",
111
- "slug",
112
150
  "display_name",
113
151
  "level",
114
152
  "duration_in_days",
153
+ "invite_only",
115
154
  )
155
+ list_filter = ("invite_only",)
116
156
  readonly_fields = ("key",)
117
157
 
118
158
  inlines = [
@@ -133,9 +173,7 @@ class LearningPathAdmin(admin.ModelAdmin):
133
173
  super().save_related(request, form, formsets, change)
134
174
  with transaction.atomic():
135
175
  for user in form.cleaned_data["usernames"]:
136
- LearningPathEnrollment.objects.get_or_create(
137
- user=user, learning_path=form.instance
138
- )
176
+ LearningPathEnrollment.objects.get_or_create(user=user, learning_path=form.instance)
139
177
 
140
178
 
141
179
  class SkillAdmin(admin.ModelAdmin):
@@ -148,12 +186,13 @@ class EnrolledUsersAdmin(admin.ModelAdmin):
148
186
  """Admin for Learning Path enrollment."""
149
187
 
150
188
  model = LearningPathEnrollment
189
+ raw_id_fields = ("user",)
190
+ autocomplete_fields = ["learning_path"]
151
191
 
152
192
  search_fields = [
153
193
  "id",
154
194
  "user__username",
155
195
  "learning_path__key",
156
- "learning_path__slug",
157
196
  "learning_path__display_name",
158
197
  ]
159
198
 
@@ -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
@@ -104,22 +104,32 @@ class LearningPathListSerializer(serializers.ModelSerializer):
104
104
  """Serializer for the learning path list."""
105
105
 
106
106
  steps = LearningPathStepSerializer(many=True, read_only=True)
107
- required_completion = serializers.FloatField(
108
- source="grading_criteria.required_completion", read_only=True
109
- )
107
+ required_completion = serializers.FloatField(source="grading_criteria.required_completion", read_only=True)
108
+ is_enrolled = serializers.SerializerMethodField()
109
+ invite_only = serializers.BooleanField()
110
+ image = serializers.ImageField(read_only=True)
110
111
 
111
112
  class Meta:
112
113
  model = LearningPath
113
114
  fields = [
114
115
  "key",
115
- "slug",
116
116
  "display_name",
117
- "image_url",
117
+ "image",
118
118
  "sequential",
119
119
  "steps",
120
120
  "required_completion",
121
+ "is_enrolled",
122
+ "invite_only",
121
123
  ]
122
124
 
125
+ def get_is_enrolled(self, obj):
126
+ """
127
+ Check if the current user is enrolled in this learning path.
128
+ """
129
+ if hasattr(obj, "is_enrolled"):
130
+ return obj.is_enrolled
131
+ return False
132
+
123
133
 
124
134
  class SkillSerializer(serializers.ModelSerializer):
125
135
  class Meta:
@@ -151,38 +161,22 @@ class AcquiredSkillSerializer(serializers.ModelSerializer):
151
161
  fields = ["skill", "level"]
152
162
 
153
163
 
154
- class LearningPathDetailSerializer(serializers.ModelSerializer):
164
+ class LearningPathDetailSerializer(LearningPathListSerializer):
155
165
  """
156
166
  Serializer for learning path details.
157
167
  """
158
168
 
159
- steps = LearningPathStepSerializer(many=True, read_only=True)
160
- required_skills = RequiredSkillSerializer(
161
- source="requiredskill_set", many=True, read_only=True
162
- )
163
- acquired_skills = AcquiredSkillSerializer(
164
- source="acquiredskill_set", many=True, read_only=True
165
- )
166
- required_completion = serializers.FloatField(
167
- source="grading_criteria.required_completion", read_only=True
168
- )
169
+ required_skills = RequiredSkillSerializer(source="requiredskill_set", many=True, read_only=True)
170
+ acquired_skills = AcquiredSkillSerializer(source="acquiredskill_set", many=True, read_only=True)
169
171
 
170
- class Meta:
171
- model = LearningPath
172
- fields = [
173
- "key",
174
- "slug",
175
- "display_name",
172
+ class Meta(LearningPathListSerializer.Meta):
173
+ fields = LearningPathListSerializer.Meta.fields + [
176
174
  "subtitle",
177
175
  "description",
178
- "image_url",
179
176
  "level",
180
177
  "duration_in_days",
181
- "sequential",
182
- "steps",
183
178
  "required_skills",
184
179
  "acquired_skills",
185
- "required_completion",
186
180
  ]
187
181
 
188
182
 
@@ -6,18 +6,17 @@ 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
- router.register(
19
- r"programs", LearningPathAsProgramViewSet, basename="learning-path-as-program"
20
- )
19
+ router.register(r"programs", LearningPathAsProgramViewSet, basename="learning-path-as-program")
21
20
  router.register(r"learning-paths", LearningPathViewSet, basename="learning-path")
22
21
 
23
22
  urlpatterns = router.urls + [
@@ -32,7 +31,7 @@ urlpatterns = router.urls + [
32
31
  name="learning-path-grade",
33
32
  ),
34
33
  re_path(
35
- rf"{LEARNING_PATH_URL_PATTERN}/enrollments/",
34
+ rf"{LEARNING_PATH_URL_PATTERN}/enrollments/$",
36
35
  LearningPathEnrollmentView.as_view(),
37
36
  name="learning-path-enrollments",
38
37
  ),
@@ -46,4 +45,9 @@ urlpatterns = router.urls + [
46
45
  BulkEnrollView.as_view(),
47
46
  name="bulk-enroll",
48
47
  ),
48
+ re_path(
49
+ rf"{LEARNING_PATH_URL_PATTERN}/enrollments/{COURSE_KEY_URL_PATTERN}/",
50
+ LearningPathCourseEnrollmentView.as_view(),
51
+ name="learning-path-course-enroll",
52
+ ),
49
53
  ]
@@ -29,9 +29,7 @@ def get_course_completion(username: str, course_key: CourseKey, client: Any) ->
29
29
  if err.response.status_code == 404:
30
30
  return 0.0
31
31
  else:
32
- raise APIException(
33
- f"Error fetching completion for course {course_id}: {err}"
34
- ) from err
32
+ raise APIException(f"Error fetching completion for course {course_id}: {err}") from err
35
33
 
36
34
  if data and data.get("results"):
37
35
  return data["results"][0]["completion"]["percent"]
@@ -51,9 +49,7 @@ def get_aggregate_progress(user, learning_path):
51
49
  total_completion = 0.0
52
50
 
53
51
  for step in steps:
54
- course_completion = get_course_completion(
55
- user.username, step.course_key, client
56
- )
52
+ course_completion = get_course_completion(user.username, step.course_key, client)
57
53
  total_completion += course_completion
58
54
 
59
55
  total_courses = len(steps)
@@ -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,21 @@ 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(user).prefetch_related(
156
+ "steps",
157
+ "grading_criteria",
158
+ )
159
+ return queryset
160
+
142
161
  def get_serializer_class(self):
143
162
  if self.action == "list":
144
163
  return LearningPathListSerializer
@@ -159,6 +178,17 @@ class LearningPathEnrollmentView(APIView):
159
178
 
160
179
  permission_classes = [IsAuthenticated, IsAdminOrSelf]
161
180
 
181
+ def _get_learning_path(self, learning_path_key_str: str) -> LearningPath:
182
+ """
183
+ Get the learning path and verify user has access to it.
184
+
185
+ :raises: Http404 if the learning path is not found or user does not have access.
186
+ """
187
+ return get_object_or_404(
188
+ LearningPath.objects.get_paths_visible_to_user(self.request.user),
189
+ key=learning_path_key_str,
190
+ )
191
+
162
192
  def get(self, request, learning_path_key_str: str):
163
193
  """Get the learning path of users.
164
194
 
@@ -169,12 +199,9 @@ class LearningPathEnrollmentView(APIView):
169
199
  username (optional): When provided it returns the enrollment for
170
200
  the specified user.
171
201
  """
172
- learning_path_key = LearningPathKey.from_string(learning_path_key_str)
173
- learning_path = get_object_or_404(LearningPath, key=learning_path_key)
202
+ learning_path = self._get_learning_path(learning_path_key_str)
174
203
 
175
- enrollments = LearningPathEnrollment.objects.filter(
176
- learning_path=learning_path, is_active=True
177
- )
204
+ enrollments = LearningPathEnrollment.objects.filter(learning_path=learning_path, is_active=True)
178
205
 
179
206
  if request.user.is_staff:
180
207
  if username := request.query_params.get("username"):
@@ -189,7 +216,7 @@ class LearningPathEnrollmentView(APIView):
189
216
  """Enroll learners in Learning Paths.
190
217
 
191
218
  Staff/Admin can enroll anyone with the username query param.
192
- Learners can enroll only themselves.
219
+ Learners can enroll only themselves, and only if the learning path is not invite-only.
193
220
 
194
221
  Example payload::
195
222
 
@@ -198,23 +225,18 @@ class LearningPathEnrollmentView(APIView):
198
225
  }
199
226
 
200
227
  """
201
- learning_path_key = LearningPathKey.from_string(learning_path_key_str)
202
- learning_path = get_object_or_404(LearningPath, key=learning_path_key)
228
+ learning_path = self._get_learning_path(learning_path_key_str)
203
229
  username = request.data.get("username")
204
230
  user = get_object_or_404(User, username=username) if username else request.user
205
231
 
206
- enrollment, created = LearningPathEnrollment.objects.get_or_create(
207
- learning_path=learning_path, user=user
208
- )
232
+ enrollment, created = LearningPathEnrollment.objects.get_or_create(learning_path=learning_path, user=user)
209
233
  if created:
210
234
  return Response(
211
235
  LearningPathEnrollmentSerializer(enrollment).data,
212
236
  status=status.HTTP_201_CREATED,
213
237
  )
214
238
  if enrollment.is_active:
215
- return Response(
216
- {"detail": "Enrollment exists."}, status=status.HTTP_409_CONFLICT
217
- )
239
+ return Response({"detail": "Enrollment exists."}, status=status.HTTP_409_CONFLICT)
218
240
 
219
241
  enrollment.is_active = True
220
242
  enrollment.enrolled_at = datetime.now(timezone.utc)
@@ -235,8 +257,7 @@ class LearningPathEnrollmentView(APIView):
235
257
  }
236
258
 
237
259
  """
238
- learning_path_key = LearningPathKey.from_string(learning_path_key_str)
239
- learning_path = get_object_or_404(LearningPath, key=learning_path_key)
260
+ learning_path = self._get_learning_path(learning_path_key_str)
240
261
  username = request.data.get("username")
241
262
  user = get_object_or_404(User, username=username) if username else request.user
242
263
 
@@ -247,10 +268,7 @@ class LearningPathEnrollmentView(APIView):
247
268
  user=user,
248
269
  )
249
270
 
250
- if (
251
- not request.user.is_staff
252
- and not settings.LEARNING_PATHS_ALLOW_SELF_UNENROLLMENT
253
- ):
271
+ if not request.user.is_staff and not settings.LEARNING_PATHS_ALLOW_SELF_UNENROLLMENT:
254
272
  raise PermissionDenied
255
273
 
256
274
  enrollment.is_active = False
@@ -331,9 +349,7 @@ class BulkEnrollView(APIView):
331
349
 
332
350
  # Create LearningPathEnrollment for existing users
333
351
  for user in existing_users:
334
- enrollment = LearningPathEnrollment.objects.filter(
335
- user=user, learning_path=learning_path
336
- ).first()
352
+ enrollment = LearningPathEnrollment.objects.filter(user=user, learning_path=learning_path).first()
337
353
  enrolled_now = False
338
354
  if not enrollment:
339
355
  enrollment = LearningPathEnrollment(
@@ -369,3 +385,40 @@ class BulkEnrollView(APIView):
369
385
  },
370
386
  status=status.HTTP_201_CREATED,
371
387
  )
388
+
389
+
390
+ class LearningPathCourseEnrollmentView(APIView):
391
+ """API View to enroll a user in a course that's part of a learning path."""
392
+
393
+ permission_classes = [IsAuthenticated, IsAdminOrSelf]
394
+
395
+ def _get_enrolled_learning_path(self, learning_path_key_str: str) -> LearningPath:
396
+ """
397
+ Get the learning path and verify the user has access and is enrolled.
398
+
399
+ :raises: Http404 if the learning path is not found or the user does not have access.
400
+ """
401
+ return get_object_or_404(
402
+ LearningPath.objects.get_paths_visible_to_user(self.request.user).filter(is_enrolled=True),
403
+ key=learning_path_key_str,
404
+ )
405
+
406
+ def post(self, request, learning_path_key_str: str, course_key_str: str):
407
+ """
408
+ Enroll a user in a course that's part of a learning path.
409
+
410
+ The user must be enrolled in the learning path, and the course must be a step in the path.
411
+ """
412
+ learning_path = self._get_enrolled_learning_path(learning_path_key_str)
413
+ course_key = CourseKey.from_string(course_key_str)
414
+
415
+ if not learning_path.steps.filter(course_key=course_key).exists():
416
+ raise ParseError("The course is not part of this learning path.")
417
+
418
+ if enroll_user_in_course(request.user, course_key):
419
+ return Response(
420
+ {"detail": "User successfully enrolled in the course."},
421
+ status=status.HTTP_201_CREATED,
422
+ )
423
+ else:
424
+ 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,17 @@ 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("Failed to enroll user %s in course %s: %s", user, course_key, exc)
76
+ return False