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.
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/CHANGELOG.rst +34 -0
- {learning_paths_plugin-0.3.1/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.3}/PKG-INFO +36 -1
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/__init__.py +1 -1
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/admin.py +53 -14
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/api/v1/serializers.py +23 -29
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/api/v1/urls.py +9 -5
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/api/v1/utils.py +2 -6
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/api/v1/views.py +85 -32
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/compat.py +18 -7
- learning_paths_plugin-0.3.3/learning_paths/conftest.py +13 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/keys.py +6 -13
- learning_paths_plugin-0.3.3/learning_paths/migrations/0009_remove_learningpath_slug.py +17 -0
- learning_paths_plugin-0.3.3/learning_paths/migrations/0010_learningpath_invite_only.py +22 -0
- learning_paths_plugin-0.3.3/learning_paths/migrations/0011_replace_learningpath_image_url_with_image.py +29 -0
- learning_paths_plugin-0.3.3/learning_paths/migrations/0012_alter_learningpath_subtitle.py +18 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/models.py +109 -34
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/receivers.py +3 -9
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3/learning_paths_plugin.egg-info}/PKG-INFO +36 -1
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths_plugin.egg-info/SOURCES.txt +6 -4
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths_plugin.egg-info/requires.txt +1 -0
- learning_paths_plugin-0.3.3/pyproject.toml +16 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/requirements/base.in +1 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/setup.py +8 -26
- learning_paths_plugin-0.3.1/pyproject.toml +0 -9
- 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.3}/LICENSE.txt +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/MANIFEST.in +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/README.rst +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/api/__init__.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/api/urls.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/api/v1/__init__.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/api/v1/filters.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/api/v1/permissions.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/apps.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/migrations/0001_initial.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/migrations/0002_learningpath_uuid.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/migrations/0003_learningpath_subtitle.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/migrations/0004_auto_20240207_1633.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/migrations/0006_enrollment_models.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/migrations/0007_replace_uuid_with_learningpathkey.py +0 -0
- {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
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/migrations/__init__.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/settings.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/templates/learning_paths/base.html +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths/urls.py +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths_plugin.egg-info/dependency_links.txt +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths_plugin.egg-info/entry_points.txt +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths_plugin.egg-info/not-zip-safe +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/learning_paths_plugin.egg-info/top_level.txt +0 -0
- {learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/requirements/constraints.txt +0 -0
- {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
|
|
{learning_paths_plugin-0.3.1/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.3}/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.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
|
|
|
@@ -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
|
-
"""
|
|
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
|
-
|
|
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
|
|
{learning_paths_plugin-0.3.1 → learning_paths_plugin-0.3.3}/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
|
|
@@ -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
|
-
|
|
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
|
-
"
|
|
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(
|
|
164
|
+
class LearningPathDetailSerializer(LearningPathListSerializer):
|
|
155
165
|
"""
|
|
156
166
|
Serializer for learning path details.
|
|
157
167
|
"""
|
|
158
168
|
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("Failed to enroll user %s in course %s: %s", user, course_key, exc)
|
|
76
|
+
return False
|