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.
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/CHANGELOG.rst +26 -0
- {learning_paths_plugin-0.3.1/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.2}/PKG-INFO +28 -1
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/__init__.py +1 -1
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/admin.py +55 -8
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/serializers.py +20 -20
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/urls.py +8 -2
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/views.py +84 -16
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/compat.py +20 -7
- learning_paths_plugin-0.3.2/learning_paths/conftest.py +13 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/keys.py +6 -7
- learning_paths_plugin-0.3.2/learning_paths/migrations/0009_remove_learningpath_slug.py +17 -0
- learning_paths_plugin-0.3.2/learning_paths/migrations/0010_learningpath_invite_only.py +22 -0
- learning_paths_plugin-0.3.2/learning_paths/migrations/0011_replace_learningpath_image_url_with_image.py +29 -0
- learning_paths_plugin-0.3.2/learning_paths/migrations/0012_alter_learningpath_subtitle.py +18 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/models.py +104 -14
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2/learning_paths_plugin.egg-info}/PKG-INFO +28 -1
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/SOURCES.txt +6 -4
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/requires.txt +1 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/requirements/base.in +1 -0
- learning_paths_plugin-0.3.1/tests/test_keys.py +0 -110
- learning_paths_plugin-0.3.1/tests/test_models.py +0 -87
- learning_paths_plugin-0.3.1/tests/test_receivers.py +0 -78
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/LICENSE.txt +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/MANIFEST.in +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/README.rst +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/api/__init__.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/api/urls.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/__init__.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/filters.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/permissions.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/utils.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/apps.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0001_initial.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0002_learningpath_uuid.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0003_learningpath_subtitle.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0004_auto_20240207_1633.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0006_enrollment_models.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0007_replace_uuid_with_learningpathkey.py +0 -0
- {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
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/__init__.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/receivers.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/settings.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/templates/learning_paths/base.html +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/urls.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/dependency_links.txt +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/entry_points.txt +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/not-zip-safe +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/top_level.txt +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/pyproject.toml +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/requirements/constraints.txt +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/setup.cfg +0 -0
- {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
|
|
{learning_paths_plugin-0.3.1/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.2}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning-paths-plugin
|
|
3
|
-
Version: 0.3.
|
|
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
|
|
|
@@ -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
|
-
"""
|
|
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
|
-
|
|
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
|
|
{learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/serializers.py
RENAMED
|
@@ -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.
|
|
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.
|
|
44
|
+
if obj.image:
|
|
45
45
|
image_key = f"w{IMAGE_WIDTH}h{IMAGE_HEIGHT}"
|
|
46
|
-
return {image_key: obj.
|
|
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
|
-
"
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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":
|
|
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
|
-
|
|
107
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
]
|