learning-paths-plugin 0.3.0rc1__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 (55) hide show
  1. learning_paths_plugin-0.3.2/CHANGELOG.rst +102 -0
  2. {learning_paths_plugin-0.3.0rc1/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.2}/PKG-INFO +47 -2
  3. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/__init__.py +1 -1
  4. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/admin.py +55 -8
  5. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/serializers.py +103 -4
  6. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/urls.py +10 -2
  7. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/views.py +109 -14
  8. learning_paths_plugin-0.3.2/learning_paths/compat.py +78 -0
  9. learning_paths_plugin-0.3.2/learning_paths/conftest.py +13 -0
  10. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/keys.py +8 -9
  11. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0007_replace_uuid_with_learningpathkey.py +1 -1
  12. learning_paths_plugin-0.3.2/learning_paths/migrations/0008_remove_learningpathstep_relative_due_date_in_days.py +17 -0
  13. learning_paths_plugin-0.3.2/learning_paths/migrations/0009_remove_learningpath_slug.py +17 -0
  14. learning_paths_plugin-0.3.2/learning_paths/migrations/0010_learningpath_invite_only.py +22 -0
  15. learning_paths_plugin-0.3.2/learning_paths/migrations/0011_replace_learningpath_image_url_with_image.py +29 -0
  16. learning_paths_plugin-0.3.2/learning_paths/migrations/0012_alter_learningpath_subtitle.py +18 -0
  17. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/models.py +118 -23
  18. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2/learning_paths_plugin.egg-info}/PKG-INFO +47 -2
  19. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/SOURCES.txt +7 -4
  20. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/requires.txt +1 -0
  21. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/requirements/base.in +1 -0
  22. learning_paths_plugin-0.3.0rc1/CHANGELOG.rst +0 -58
  23. learning_paths_plugin-0.3.0rc1/learning_paths/compat.py +0 -48
  24. learning_paths_plugin-0.3.0rc1/tests/test_keys.py +0 -110
  25. learning_paths_plugin-0.3.0rc1/tests/test_models.py +0 -79
  26. learning_paths_plugin-0.3.0rc1/tests/test_receivers.py +0 -78
  27. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/LICENSE.txt +0 -0
  28. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/MANIFEST.in +0 -0
  29. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/README.rst +0 -0
  30. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/api/__init__.py +0 -0
  31. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/api/urls.py +0 -0
  32. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/__init__.py +0 -0
  33. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/filters.py +0 -0
  34. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/permissions.py +0 -0
  35. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/utils.py +0 -0
  36. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/apps.py +0 -0
  37. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0001_initial.py +0 -0
  38. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0002_learningpath_uuid.py +0 -0
  39. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0003_learningpath_subtitle.py +0 -0
  40. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0004_auto_20240207_1633.py +0 -0
  41. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py +0 -0
  42. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0006_enrollment_models.py +0 -0
  43. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/__init__.py +0 -0
  44. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/receivers.py +0 -0
  45. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/settings.py +0 -0
  46. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/templates/learning_paths/base.html +0 -0
  47. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/urls.py +0 -0
  48. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/dependency_links.txt +0 -0
  49. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/entry_points.txt +0 -0
  50. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/not-zip-safe +0 -0
  51. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/top_level.txt +0 -0
  52. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/pyproject.toml +0 -0
  53. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/requirements/constraints.txt +0 -0
  54. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/setup.cfg +0 -0
  55. {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/setup.py +0 -0
@@ -0,0 +1,102 @@
1
+ Change Log
2
+ ##########
3
+
4
+ ..
5
+ All enhancements and patches to learning_paths will be documented
6
+ in this file. It adheres to the structure of https://keepachangelog.com/ ,
7
+ but in reStructuredText instead of Markdown (for ease of incorporation into
8
+ Sphinx documentation and the PyPI description).
9
+
10
+ This project adheres to Semantic Versioning (https://semver.org/).
11
+
12
+ .. There should always be an "Unreleased" section for changes pending release.
13
+
14
+ Unreleased
15
+ **********
16
+
17
+ *
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
+
45
+ 0.3.1 - 2025-04-14
46
+ ******************
47
+
48
+ Added
49
+ =====
50
+
51
+ * API for listing and retrieving Learning Paths.
52
+
53
+ Fixed
54
+ =====
55
+
56
+ * Automatically create grading criteria for Learning Paths.
57
+
58
+ Changed
59
+ =======
60
+
61
+ * Replaced relative due dates with actual due dates from course runs.
62
+
63
+ 0.3.0 - 2025-04-03
64
+ ******************
65
+
66
+ Changed
67
+ =======
68
+
69
+ * Replaced Learning Path UUID with LearningPathKey.
70
+
71
+ 0.2.3 - 2025-03-31
72
+ ******************
73
+
74
+ Added
75
+ =====
76
+
77
+ * Enrollment API.
78
+
79
+ 0.2.2 - 2024-12-05
80
+ ******************
81
+
82
+ Added
83
+ =====
84
+
85
+ * User grade API
86
+
87
+ 0.2.1 - 2024-10-28
88
+ ******************
89
+
90
+ Added
91
+ =====
92
+
93
+ * Progress API
94
+
95
+ 0.2.0 - 2024-01-23
96
+ ******************
97
+
98
+ Added
99
+ =====
100
+
101
+ * Database models
102
+ * Django Admin interface
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning-paths-plugin
3
- Version: 0.3.0rc1
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,7 +121,51 @@ Unreleased
120
121
 
121
122
  *
122
123
 
123
- 0.3.0 - 2025-03-31
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
+
150
+ 0.3.1 - 2025-04-14
151
+ ******************
152
+
153
+ Added
154
+ =====
155
+
156
+ * API for listing and retrieving Learning Paths.
157
+
158
+ Fixed
159
+ =====
160
+
161
+ * Automatically create grading criteria for Learning Paths.
162
+
163
+ Changed
164
+ =======
165
+
166
+ * Replaced relative due dates with actual due dates from course runs.
167
+
168
+ 0.3.0 - 2025-04-03
124
169
  ******************
125
170
 
126
171
  Changed
@@ -2,4 +2,4 @@
2
2
  Learning Paths plugin.
3
3
  """
4
4
 
5
- __version__ = "0.3.0rc1"
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
 
@@ -4,7 +4,14 @@ Serializer for LearningPath.
4
4
 
5
5
  from rest_framework import serializers
6
6
 
7
- from learning_paths.models import LearningPath, LearningPathEnrollment
7
+ from learning_paths.models import (
8
+ AcquiredSkill,
9
+ LearningPath,
10
+ LearningPathEnrollment,
11
+ LearningPathStep,
12
+ RequiredSkill,
13
+ Skill,
14
+ )
8
15
 
9
16
  DEFAULT_STATUS = "active"
10
17
  IMAGE_WIDTH = 1440
@@ -28,15 +35,15 @@ class LearningPathAsProgramSerializer(serializers.ModelSerializer):
28
35
  course_codes = serializers.SerializerMethodField()
29
36
 
30
37
  def get_marketing_slug(self, obj):
31
- return obj.slug
38
+ return str(obj.key)
32
39
 
33
40
  def get_status(self, obj): # pylint: disable=unused-argument
34
41
  return DEFAULT_STATUS
35
42
 
36
43
  def get_banner_image_urls(self, obj):
37
- if obj.image_url:
44
+ if obj.image:
38
45
  image_key = f"w{IMAGE_WIDTH}h{IMAGE_HEIGHT}"
39
- return {image_key: obj.image_url}
46
+ return {image_key: obj.image.url}
40
47
  return {}
41
48
 
42
49
  def get_organizations(self, obj): # pylint: disable=unused-argument
@@ -87,6 +94,98 @@ class LearningPathGradeSerializer(serializers.Serializer):
87
94
  required_grade = serializers.FloatField()
88
95
 
89
96
 
97
+ class LearningPathStepSerializer(serializers.ModelSerializer):
98
+ class Meta:
99
+ model = LearningPathStep
100
+ fields = ["order", "course_key", "due_date", "weight"]
101
+
102
+
103
+ class LearningPathListSerializer(serializers.ModelSerializer):
104
+ """Serializer for the learning path list."""
105
+
106
+ steps = LearningPathStepSerializer(many=True, read_only=True)
107
+ required_completion = serializers.FloatField(
108
+ source="grading_criteria.required_completion", read_only=True
109
+ )
110
+ is_enrolled = serializers.SerializerMethodField()
111
+ invite_only = serializers.BooleanField()
112
+ image = serializers.ImageField(read_only=True)
113
+
114
+ class Meta:
115
+ model = LearningPath
116
+ fields = [
117
+ "key",
118
+ "display_name",
119
+ "image",
120
+ "sequential",
121
+ "steps",
122
+ "required_completion",
123
+ "is_enrolled",
124
+ "invite_only",
125
+ ]
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
+
135
+
136
+ class SkillSerializer(serializers.ModelSerializer):
137
+ class Meta:
138
+ model = Skill
139
+ fields = ["id", "display_name"]
140
+
141
+
142
+ class RequiredSkillSerializer(serializers.ModelSerializer):
143
+ """
144
+ Serializer for required skill.
145
+ """
146
+
147
+ skill = SkillSerializer()
148
+
149
+ class Meta:
150
+ model = RequiredSkill
151
+ fields = ["skill", "level"]
152
+
153
+
154
+ class AcquiredSkillSerializer(serializers.ModelSerializer):
155
+ """
156
+ Serializer for acquired skill.
157
+ """
158
+
159
+ skill = SkillSerializer()
160
+
161
+ class Meta:
162
+ model = AcquiredSkill
163
+ fields = ["skill", "level"]
164
+
165
+
166
+ class LearningPathDetailSerializer(LearningPathListSerializer):
167
+ """
168
+ Serializer for learning path details.
169
+ """
170
+
171
+ required_skills = RequiredSkillSerializer(
172
+ source="requiredskill_set", many=True, read_only=True
173
+ )
174
+ acquired_skills = AcquiredSkillSerializer(
175
+ source="acquiredskill_set", many=True, read_only=True
176
+ )
177
+
178
+ class Meta(LearningPathListSerializer.Meta):
179
+ fields = LearningPathListSerializer.Meta.fields + [
180
+ "subtitle",
181
+ "description",
182
+ "level",
183
+ "duration_in_days",
184
+ "required_skills",
185
+ "acquired_skills",
186
+ ]
187
+
188
+
90
189
  class LearningPathEnrollmentSerializer(serializers.ModelSerializer):
91
190
  class Meta:
92
191
  model = LearningPathEnrollment
@@ -6,17 +6,20 @@ 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,
13
+ LearningPathViewSet,
12
14
  ListEnrollmentsView,
13
15
  )
14
- from learning_paths.keys import LEARNING_PATH_URL_PATTERN
16
+ from learning_paths.keys import COURSE_KEY_URL_PATTERN, LEARNING_PATH_URL_PATTERN
15
17
 
16
18
  router = routers.SimpleRouter()
17
19
  router.register(
18
20
  r"programs", LearningPathAsProgramViewSet, basename="learning-path-as-program"
19
21
  )
22
+ router.register(r"learning-paths", LearningPathViewSet, basename="learning-path")
20
23
 
21
24
  urlpatterns = router.urls + [
22
25
  re_path(
@@ -30,7 +33,7 @@ urlpatterns = router.urls + [
30
33
  name="learning-path-grade",
31
34
  ),
32
35
  re_path(
33
- rf"{LEARNING_PATH_URL_PATTERN}/enrollments/",
36
+ rf"{LEARNING_PATH_URL_PATTERN}/enrollments/$",
34
37
  LearningPathEnrollmentView.as_view(),
35
38
  name="learning-path-enrollments",
36
39
  ),
@@ -44,4 +47,9 @@ urlpatterns = router.urls + [
44
47
  BulkEnrollView.as_view(),
45
48
  name="bulk-enroll",
46
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
+ ),
47
55
  ]
@@ -11,7 +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
16
+ from rest_framework.exceptions import NotFound, ParseError
15
17
  from rest_framework.pagination import PageNumberPagination
16
18
  from rest_framework.permissions import IsAdminUser, IsAuthenticated
17
19
  from rest_framework.response import Response
@@ -19,10 +21,13 @@ from rest_framework.views import APIView
19
21
 
20
22
  from learning_paths.api.v1.serializers import (
21
23
  LearningPathAsProgramSerializer,
24
+ LearningPathDetailSerializer,
22
25
  LearningPathEnrollmentSerializer,
23
26
  LearningPathGradeSerializer,
27
+ LearningPathListSerializer,
24
28
  LearningPathProgressSerializer,
25
29
  )
30
+ from learning_paths.compat import enroll_user_in_course
26
31
  from learning_paths.keys import LearningPathKey
27
32
  from learning_paths.models import (
28
33
  LearningPath,
@@ -48,11 +53,14 @@ class LearningPathAsProgramViewSet(viewsets.ReadOnlyModelViewSet):
48
53
  https://github.com/openedx/course-discovery/blob/d6a57fd69479b3d5f5afb682d2668b58503a6af6/course_discovery/apps/course_metadata/data_loaders/api.py#L843
49
54
  """
50
55
 
51
- queryset = LearningPath.objects.all()
52
56
  permission_classes = (IsAuthenticated,)
53
57
  serializer_class = LearningPathAsProgramSerializer
54
58
  pagination_class = PageNumberPagination
55
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
+
56
64
 
57
65
  class LearningPathUserProgressView(APIView):
58
66
  """
@@ -65,8 +73,10 @@ class LearningPathUserProgressView(APIView):
65
73
  """
66
74
  Fetch the learning path progress
67
75
  """
68
- learning_path_key = LearningPathKey.from_string(learning_path_key_str)
69
- 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
+ )
70
80
 
71
81
  progress = get_aggregate_progress(request.user, learning_path)
72
82
  required_completion = None
@@ -77,7 +87,7 @@ class LearningPathUserProgressView(APIView):
77
87
  pass
78
88
 
79
89
  data = {
80
- "learning_path_key": str(learning_path_key),
90
+ "learning_path_key": learning_path_key_str,
81
91
  "progress": progress,
82
92
  "required_completion": required_completion,
83
93
  }
@@ -100,8 +110,10 @@ class LearningPathUserGradeView(APIView):
100
110
  Fetch learning path grade
101
111
  """
102
112
 
103
- learning_path_key = LearningPathKey.from_string(learning_path_key_str)
104
- 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
+ )
105
117
 
106
118
  try:
107
119
  grading_criteria = learning_path.grading_criteria
@@ -114,7 +126,7 @@ class LearningPathUserGradeView(APIView):
114
126
  grade = grading_criteria.calculate_grade(request.user)
115
127
 
116
128
  data = {
117
- "learning_path_key": str(learning_path_key),
129
+ "learning_path_key": learning_path_key_str,
118
130
  "grade": grade,
119
131
  "required_grade": grading_criteria.required_grade,
120
132
  }
@@ -125,6 +137,42 @@ class LearningPathUserGradeView(APIView):
125
137
  return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
126
138
 
127
139
 
140
+ class LearningPathViewSet(viewsets.ReadOnlyModelViewSet):
141
+ """
142
+ ViewSet for listing all learning paths and retrieving a specific learning path's details,
143
+ including steps and associated skills.
144
+ """
145
+
146
+ permission_classes = (IsAuthenticated,)
147
+ pagination_class = PageNumberPagination
148
+ lookup_field = "key"
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
+
163
+ def get_serializer_class(self):
164
+ if self.action == "list":
165
+ return LearningPathListSerializer
166
+ return LearningPathDetailSerializer
167
+
168
+ def get_object(self):
169
+ """Gracefully handle an invalid learning path key format."""
170
+ try:
171
+ return super().get_object()
172
+ except InvalidKeyError as exc:
173
+ raise NotFound("Invalid learning path key format.") from exc
174
+
175
+
128
176
  class LearningPathEnrollmentView(APIView):
129
177
  """
130
178
  API View to handle changes to LearningPathEnrollment model
@@ -132,6 +180,17 @@ class LearningPathEnrollmentView(APIView):
132
180
 
133
181
  permission_classes = [IsAuthenticated, IsAdminOrSelf]
134
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
+
135
194
  def get(self, request, learning_path_key_str: str):
136
195
  """Get the learning path of users.
137
196
 
@@ -142,8 +201,7 @@ class LearningPathEnrollmentView(APIView):
142
201
  username (optional): When provided it returns the enrollment for
143
202
  the specified user.
144
203
  """
145
- learning_path_key = LearningPathKey.from_string(learning_path_key_str)
146
- learning_path = get_object_or_404(LearningPath, key=learning_path_key)
204
+ learning_path = self._get_learning_path(learning_path_key_str)
147
205
 
148
206
  enrollments = LearningPathEnrollment.objects.filter(
149
207
  learning_path=learning_path, is_active=True
@@ -162,7 +220,7 @@ class LearningPathEnrollmentView(APIView):
162
220
  """Enroll learners in Learning Paths.
163
221
 
164
222
  Staff/Admin can enroll anyone with the username query param.
165
- Learners can enroll only themselves.
223
+ Learners can enroll only themselves, and only if the learning path is not invite-only.
166
224
 
167
225
  Example payload::
168
226
 
@@ -171,8 +229,7 @@ class LearningPathEnrollmentView(APIView):
171
229
  }
172
230
 
173
231
  """
174
- learning_path_key = LearningPathKey.from_string(learning_path_key_str)
175
- learning_path = get_object_or_404(LearningPath, key=learning_path_key)
232
+ learning_path = self._get_learning_path(learning_path_key_str)
176
233
  username = request.data.get("username")
177
234
  user = get_object_or_404(User, username=username) if username else request.user
178
235
 
@@ -208,8 +265,7 @@ class LearningPathEnrollmentView(APIView):
208
265
  }
209
266
 
210
267
  """
211
- learning_path_key = LearningPathKey.from_string(learning_path_key_str)
212
- learning_path = get_object_or_404(LearningPath, key=learning_path_key)
268
+ learning_path = self._get_learning_path(learning_path_key_str)
213
269
  username = request.data.get("username")
214
270
  user = get_object_or_404(User, username=username) if username else request.user
215
271
 
@@ -342,3 +398,42 @@ class BulkEnrollView(APIView):
342
398
  },
343
399
  status=status.HTTP_201_CREATED,
344
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.")