learning-paths-plugin 0.3.2__tar.gz → 0.3.4rc1__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.2 → learning_paths_plugin-0.3.4rc1}/CHANGELOG.rst +16 -0
- {learning_paths_plugin-0.3.2/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.4rc1}/PKG-INFO +17 -2
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/__init__.py +1 -1
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/admin.py +162 -12
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/serializers.py +3 -9
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/urls.py +1 -3
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/utils.py +2 -6
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/views.py +33 -31
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/compat.py +1 -3
- learning_paths_plugin-0.3.4rc1/learning_paths/conftest.py +51 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/keys.py +3 -9
- learning_paths_plugin-0.3.4rc1/learning_paths/migrations/0006_enrollment_models.py +39 -0
- learning_paths_plugin-0.3.4rc1/learning_paths/migrations/0013_enrollment_audit.py +158 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/models.py +65 -29
- learning_paths_plugin-0.3.4rc1/learning_paths/receivers.py +127 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1/learning_paths_plugin.egg-info}/PKG-INFO +17 -2
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/SOURCES.txt +1 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/requires.txt +0 -1
- learning_paths_plugin-0.3.4rc1/pyproject.toml +16 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/requirements/base.in +0 -1
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/requirements/constraints.txt +1 -6
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/setup.py +8 -26
- learning_paths_plugin-0.3.2/learning_paths/conftest.py +0 -13
- learning_paths_plugin-0.3.2/learning_paths/migrations/0006_enrollment_models.py +0 -66
- learning_paths_plugin-0.3.2/learning_paths/receivers.py +0 -52
- learning_paths_plugin-0.3.2/pyproject.toml +0 -9
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/LICENSE.txt +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/MANIFEST.in +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/README.rst +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/__init__.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/urls.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/__init__.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/filters.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/permissions.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/apps.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0001_initial.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0002_learningpath_uuid.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0003_learningpath_subtitle.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0004_auto_20240207_1633.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0007_replace_uuid_with_learningpathkey.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0008_remove_learningpathstep_relative_due_date_in_days.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0009_remove_learningpath_slug.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0010_learningpath_invite_only.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0011_replace_learningpath_image_url_with_image.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0012_alter_learningpath_subtitle.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/__init__.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/settings.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/templates/learning_paths/base.html +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/urls.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/dependency_links.txt +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/entry_points.txt +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/not-zip-safe +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/top_level.txt +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/setup.cfg +0 -0
|
@@ -16,6 +16,22 @@ Unreleased
|
|
|
16
16
|
|
|
17
17
|
*
|
|
18
18
|
|
|
19
|
+
0.3.4 - 2025-05-23
|
|
20
|
+
******************
|
|
21
|
+
|
|
22
|
+
Added
|
|
23
|
+
=====
|
|
24
|
+
|
|
25
|
+
* Enrollment audit model that tracks the enrollment state transitions.
|
|
26
|
+
|
|
27
|
+
0.3.3 - 2025-05-23
|
|
28
|
+
******************
|
|
29
|
+
|
|
30
|
+
Changed
|
|
31
|
+
=======
|
|
32
|
+
|
|
33
|
+
* Changed line length from 80 to 120 characters.
|
|
34
|
+
|
|
19
35
|
0.3.2 - 2025-05-02
|
|
20
36
|
******************
|
|
21
37
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning-paths-plugin
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4rc1
|
|
4
4
|
Summary: Learning Paths plugin
|
|
5
5
|
Home-page: https://github.com/open-craft/learning-paths-plugin
|
|
6
6
|
Author: OpenCraft
|
|
@@ -18,7 +18,6 @@ Requires-Python: >=3.11
|
|
|
18
18
|
License-File: LICENSE.txt
|
|
19
19
|
Requires-Dist: Django
|
|
20
20
|
Requires-Dist: django-model-utils
|
|
21
|
-
Requires-Dist: django-simple-history==3.4.0
|
|
22
21
|
Requires-Dist: djangorestframework
|
|
23
22
|
Requires-Dist: edx-django-utils
|
|
24
23
|
Requires-Dist: edx-opaque-keys
|
|
@@ -121,6 +120,22 @@ Unreleased
|
|
|
121
120
|
|
|
122
121
|
*
|
|
123
122
|
|
|
123
|
+
0.3.4 - 2025-05-23
|
|
124
|
+
******************
|
|
125
|
+
|
|
126
|
+
Added
|
|
127
|
+
=====
|
|
128
|
+
|
|
129
|
+
* Enrollment audit model that tracks the enrollment state transitions.
|
|
130
|
+
|
|
131
|
+
0.3.3 - 2025-05-23
|
|
132
|
+
******************
|
|
133
|
+
|
|
134
|
+
Changed
|
|
135
|
+
=======
|
|
136
|
+
|
|
137
|
+
* Changed line length from 80 to 120 characters.
|
|
138
|
+
|
|
124
139
|
0.3.2 - 2025-05-02
|
|
125
140
|
******************
|
|
126
141
|
|
|
@@ -13,6 +13,8 @@ from .models import (
|
|
|
13
13
|
AcquiredSkill,
|
|
14
14
|
LearningPath,
|
|
15
15
|
LearningPathEnrollment,
|
|
16
|
+
LearningPathEnrollmentAllowed,
|
|
17
|
+
LearningPathEnrollmentAudit,
|
|
16
18
|
LearningPathGradingCriteria,
|
|
17
19
|
LearningPathStep,
|
|
18
20
|
RequiredSkill,
|
|
@@ -65,9 +67,7 @@ class LearningPathStepForm(forms.ModelForm):
|
|
|
65
67
|
"""Lazily fetch course keys to avoid calling compat code in all environments."""
|
|
66
68
|
super().__init__(*args, **kwargs)
|
|
67
69
|
self._course_keys = get_course_keys_with_outlines()
|
|
68
|
-
self.fields["course_key"].widget = CourseKeyDatalistWidget(
|
|
69
|
-
choices=self._course_keys
|
|
70
|
-
)
|
|
70
|
+
self.fields["course_key"].widget = CourseKeyDatalistWidget(choices=self._course_keys)
|
|
71
71
|
|
|
72
72
|
course_key = forms.CharField(label=_("Course"))
|
|
73
73
|
|
|
@@ -77,9 +77,7 @@ class LearningPathStepForm(forms.ModelForm):
|
|
|
77
77
|
valid_keys = {str(key).strip() for key in self._course_keys}
|
|
78
78
|
|
|
79
79
|
if course_key not in valid_keys:
|
|
80
|
-
raise ValidationError(
|
|
81
|
-
_("Invalid course key. Please select a course from the suggestions.")
|
|
82
|
-
)
|
|
80
|
+
raise ValidationError(_("Invalid course key. Please select a course from the suggestions."))
|
|
83
81
|
|
|
84
82
|
return course_key
|
|
85
83
|
|
|
@@ -135,9 +133,7 @@ class BulkEnrollUsersForm(forms.ModelForm):
|
|
|
135
133
|
found_usernames = list(users.values_list("username", flat=True))
|
|
136
134
|
invalid_usernames = set(usernames) - set(found_usernames)
|
|
137
135
|
if invalid_usernames:
|
|
138
|
-
raise ValidationError(
|
|
139
|
-
f"The following usernames are not valid: {', '.join(invalid_usernames)}"
|
|
140
|
-
)
|
|
136
|
+
raise ValidationError(f"The following usernames are not valid: {', '.join(invalid_usernames)}")
|
|
141
137
|
return users
|
|
142
138
|
|
|
143
139
|
|
|
@@ -179,9 +175,7 @@ class LearningPathAdmin(admin.ModelAdmin):
|
|
|
179
175
|
super().save_related(request, form, formsets, change)
|
|
180
176
|
with transaction.atomic():
|
|
181
177
|
for user in form.cleaned_data["usernames"]:
|
|
182
|
-
LearningPathEnrollment.objects.get_or_create(
|
|
183
|
-
user=user, learning_path=form.instance
|
|
184
|
-
)
|
|
178
|
+
LearningPathEnrollment.objects.get_or_create(user=user, learning_path=form.instance)
|
|
185
179
|
|
|
186
180
|
|
|
187
181
|
class SkillAdmin(admin.ModelAdmin):
|
|
@@ -190,12 +184,63 @@ class SkillAdmin(admin.ModelAdmin):
|
|
|
190
184
|
model = Skill
|
|
191
185
|
|
|
192
186
|
|
|
187
|
+
class LearningPathEnrollmentAuditInline(admin.TabularInline):
|
|
188
|
+
"""Inline admin for LearningPathEnrollmentAudit records."""
|
|
189
|
+
|
|
190
|
+
model = LearningPathEnrollmentAudit
|
|
191
|
+
fk_name = "enrollment"
|
|
192
|
+
extra = 0
|
|
193
|
+
exclude = ["enrollment_allowed"]
|
|
194
|
+
readonly_fields = [
|
|
195
|
+
"state_transition",
|
|
196
|
+
"enrolled_by",
|
|
197
|
+
"reason",
|
|
198
|
+
"org",
|
|
199
|
+
"role",
|
|
200
|
+
"created",
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
def has_add_permission(self, request, obj=None):
|
|
204
|
+
"""Disable manual creation of audit records."""
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
def has_delete_permission(self, request, obj=None):
|
|
208
|
+
"""Disable deletion of audit records."""
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class LearningPathEnrollmentAllowedAuditInline(admin.TabularInline):
|
|
213
|
+
"""Inline admin for LearningPathEnrollmentAudit records related to enrollment allowed."""
|
|
214
|
+
|
|
215
|
+
model = LearningPathEnrollmentAudit
|
|
216
|
+
fk_name = "enrollment_allowed"
|
|
217
|
+
extra = 0
|
|
218
|
+
exclude = ["enrollment"]
|
|
219
|
+
readonly_fields = [
|
|
220
|
+
"state_transition",
|
|
221
|
+
"enrolled_by",
|
|
222
|
+
"reason",
|
|
223
|
+
"org",
|
|
224
|
+
"role",
|
|
225
|
+
"created",
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
def has_add_permission(self, request, obj=None):
|
|
229
|
+
"""Disable manual creation of audit records."""
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
def has_delete_permission(self, request, obj=None):
|
|
233
|
+
"""Disable deletion of audit records."""
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
|
|
193
237
|
class EnrolledUsersAdmin(admin.ModelAdmin):
|
|
194
238
|
"""Admin for Learning Path enrollment."""
|
|
195
239
|
|
|
196
240
|
model = LearningPathEnrollment
|
|
197
241
|
raw_id_fields = ("user",)
|
|
198
242
|
autocomplete_fields = ["learning_path"]
|
|
243
|
+
inlines = [LearningPathEnrollmentAuditInline]
|
|
199
244
|
|
|
200
245
|
search_fields = [
|
|
201
246
|
"id",
|
|
@@ -205,6 +250,111 @@ class EnrolledUsersAdmin(admin.ModelAdmin):
|
|
|
205
250
|
]
|
|
206
251
|
|
|
207
252
|
|
|
253
|
+
@admin.register(LearningPathEnrollmentAllowed)
|
|
254
|
+
class LearningPathEnrollmentAllowedAdmin(admin.ModelAdmin):
|
|
255
|
+
"""Admin configuration for LearningPathEnrollmentAllowed model."""
|
|
256
|
+
|
|
257
|
+
list_display = [
|
|
258
|
+
"id",
|
|
259
|
+
"email",
|
|
260
|
+
"get_user",
|
|
261
|
+
"learning_path",
|
|
262
|
+
"created",
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
list_filter = [
|
|
266
|
+
"learning_path",
|
|
267
|
+
"user",
|
|
268
|
+
"created",
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
search_fields = [
|
|
272
|
+
"email",
|
|
273
|
+
"user__username",
|
|
274
|
+
"user__email",
|
|
275
|
+
"learning_path__title",
|
|
276
|
+
"learning_path__key",
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
readonly_fields = [
|
|
280
|
+
"user",
|
|
281
|
+
"created",
|
|
282
|
+
"modified",
|
|
283
|
+
]
|
|
284
|
+
|
|
285
|
+
inlines = [LearningPathEnrollmentAllowedAuditInline]
|
|
286
|
+
|
|
287
|
+
def get_user(self, obj):
|
|
288
|
+
"""Get the associated user, if any."""
|
|
289
|
+
return obj.user.username if obj.user else "-"
|
|
290
|
+
|
|
291
|
+
get_user.short_description = "User"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@admin.register(LearningPathEnrollmentAudit)
|
|
295
|
+
class LearningPathEnrollmentAuditAdmin(admin.ModelAdmin):
|
|
296
|
+
"""Admin configuration for LearningPathEnrollmentAudit model."""
|
|
297
|
+
|
|
298
|
+
list_display = [
|
|
299
|
+
"id",
|
|
300
|
+
"state_transition",
|
|
301
|
+
"enrolled_by",
|
|
302
|
+
"get_enrollee",
|
|
303
|
+
"get_learning_path",
|
|
304
|
+
"created",
|
|
305
|
+
"org",
|
|
306
|
+
"role",
|
|
307
|
+
]
|
|
308
|
+
|
|
309
|
+
list_filter = [
|
|
310
|
+
"state_transition",
|
|
311
|
+
"created",
|
|
312
|
+
"org",
|
|
313
|
+
"role",
|
|
314
|
+
"enrolled_by",
|
|
315
|
+
]
|
|
316
|
+
|
|
317
|
+
search_fields = [
|
|
318
|
+
"enrolled_by__username",
|
|
319
|
+
"enrolled_by__email",
|
|
320
|
+
"enrollment__user__username",
|
|
321
|
+
"enrollment__user__email",
|
|
322
|
+
"enrollment_allowed__email",
|
|
323
|
+
"enrollment__learning_path__title",
|
|
324
|
+
"enrollment_allowed__learning_path__title",
|
|
325
|
+
"reason",
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
readonly_fields = [
|
|
329
|
+
"enrollment",
|
|
330
|
+
"enrollment_allowed",
|
|
331
|
+
"enrolled_by",
|
|
332
|
+
"state_transition",
|
|
333
|
+
"created",
|
|
334
|
+
"modified",
|
|
335
|
+
]
|
|
336
|
+
|
|
337
|
+
def get_enrollee(self, obj):
|
|
338
|
+
"""Get the enrollee (user or email)."""
|
|
339
|
+
if obj.enrollment:
|
|
340
|
+
return obj.enrollment.user.username
|
|
341
|
+
elif obj.enrollment_allowed:
|
|
342
|
+
return obj.enrollment_allowed.user.username if obj.enrollment_allowed.user else obj.enrollment_allowed.email
|
|
343
|
+
return "-"
|
|
344
|
+
|
|
345
|
+
get_enrollee.short_description = "Enrollee"
|
|
346
|
+
|
|
347
|
+
def get_learning_path(self, obj):
|
|
348
|
+
"""Get the learning path title."""
|
|
349
|
+
if obj.enrollment:
|
|
350
|
+
return obj.enrollment.learning_path.key
|
|
351
|
+
elif obj.enrollment_allowed:
|
|
352
|
+
return obj.enrollment_allowed.learning_path.key
|
|
353
|
+
return "-"
|
|
354
|
+
|
|
355
|
+
get_learning_path.short_description = "Learning Path"
|
|
356
|
+
|
|
357
|
+
|
|
208
358
|
admin.site.register(LearningPath, LearningPathAdmin)
|
|
209
359
|
admin.site.register(Skill, SkillAdmin)
|
|
210
360
|
admin.site.register(LearningPathEnrollment, EnrolledUsersAdmin)
|
{learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/serializers.py
RENAMED
|
@@ -104,9 +104,7 @@ class LearningPathListSerializer(serializers.ModelSerializer):
|
|
|
104
104
|
"""Serializer for the learning path list."""
|
|
105
105
|
|
|
106
106
|
steps = LearningPathStepSerializer(many=True, read_only=True)
|
|
107
|
-
required_completion = serializers.FloatField(
|
|
108
|
-
source="grading_criteria.required_completion", read_only=True
|
|
109
|
-
)
|
|
107
|
+
required_completion = serializers.FloatField(source="grading_criteria.required_completion", read_only=True)
|
|
110
108
|
is_enrolled = serializers.SerializerMethodField()
|
|
111
109
|
invite_only = serializers.BooleanField()
|
|
112
110
|
image = serializers.ImageField(read_only=True)
|
|
@@ -168,12 +166,8 @@ class LearningPathDetailSerializer(LearningPathListSerializer):
|
|
|
168
166
|
Serializer for learning path details.
|
|
169
167
|
"""
|
|
170
168
|
|
|
171
|
-
required_skills = RequiredSkillSerializer(
|
|
172
|
-
|
|
173
|
-
)
|
|
174
|
-
acquired_skills = AcquiredSkillSerializer(
|
|
175
|
-
source="acquiredskill_set", many=True, read_only=True
|
|
176
|
-
)
|
|
169
|
+
required_skills = RequiredSkillSerializer(source="requiredskill_set", many=True, read_only=True)
|
|
170
|
+
acquired_skills = AcquiredSkillSerializer(source="acquiredskill_set", many=True, read_only=True)
|
|
177
171
|
|
|
178
172
|
class Meta(LearningPathListSerializer.Meta):
|
|
179
173
|
fields = LearningPathListSerializer.Meta.fields + [
|
{learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/urls.py
RENAMED
|
@@ -16,9 +16,7 @@ from learning_paths.api.v1.views import (
|
|
|
16
16
|
from learning_paths.keys import COURSE_KEY_URL_PATTERN, LEARNING_PATH_URL_PATTERN
|
|
17
17
|
|
|
18
18
|
router = routers.SimpleRouter()
|
|
19
|
-
router.register(
|
|
20
|
-
r"programs", LearningPathAsProgramViewSet, basename="learning-path-as-program"
|
|
21
|
-
)
|
|
19
|
+
router.register(r"programs", LearningPathAsProgramViewSet, basename="learning-path-as-program")
|
|
22
20
|
router.register(r"learning-paths", LearningPathViewSet, basename="learning-path")
|
|
23
21
|
|
|
24
22
|
urlpatterns = router.urls + [
|
{learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/utils.py
RENAMED
|
@@ -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)
|
{learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/views.py
RENAMED
|
@@ -33,6 +33,7 @@ from learning_paths.models import (
|
|
|
33
33
|
LearningPath,
|
|
34
34
|
LearningPathEnrollment,
|
|
35
35
|
LearningPathEnrollmentAllowed,
|
|
36
|
+
LearningPathEnrollmentAudit,
|
|
36
37
|
)
|
|
37
38
|
|
|
38
39
|
from .filters import AdminOrSelfFilterBackend
|
|
@@ -152,9 +153,7 @@ class LearningPathViewSet(viewsets.ReadOnlyModelViewSet):
|
|
|
152
153
|
Get all learning paths and prefetch the related data.
|
|
153
154
|
"""
|
|
154
155
|
user = self.request.user
|
|
155
|
-
queryset = LearningPath.objects.get_paths_visible_to_user(
|
|
156
|
-
user
|
|
157
|
-
).prefetch_related(
|
|
156
|
+
queryset = LearningPath.objects.get_paths_visible_to_user(user).prefetch_related(
|
|
158
157
|
"steps",
|
|
159
158
|
"grading_criteria",
|
|
160
159
|
)
|
|
@@ -203,9 +202,7 @@ class LearningPathEnrollmentView(APIView):
|
|
|
203
202
|
"""
|
|
204
203
|
learning_path = self._get_learning_path(learning_path_key_str)
|
|
205
204
|
|
|
206
|
-
enrollments = LearningPathEnrollment.objects.filter(
|
|
207
|
-
learning_path=learning_path, is_active=True
|
|
208
|
-
)
|
|
205
|
+
enrollments = LearningPathEnrollment.objects.filter(learning_path=learning_path, is_active=True)
|
|
209
206
|
|
|
210
207
|
if request.user.is_staff:
|
|
211
208
|
if username := request.query_params.get("username"):
|
|
@@ -233,18 +230,14 @@ class LearningPathEnrollmentView(APIView):
|
|
|
233
230
|
username = request.data.get("username")
|
|
234
231
|
user = get_object_or_404(User, username=username) if username else request.user
|
|
235
232
|
|
|
236
|
-
enrollment, created = LearningPathEnrollment.objects.get_or_create(
|
|
237
|
-
learning_path=learning_path, user=user
|
|
238
|
-
)
|
|
233
|
+
enrollment, created = LearningPathEnrollment.objects.get_or_create(learning_path=learning_path, user=user)
|
|
239
234
|
if created:
|
|
240
235
|
return Response(
|
|
241
236
|
LearningPathEnrollmentSerializer(enrollment).data,
|
|
242
237
|
status=status.HTTP_201_CREATED,
|
|
243
238
|
)
|
|
244
239
|
if enrollment.is_active:
|
|
245
|
-
return Response(
|
|
246
|
-
{"detail": "Enrollment exists."}, status=status.HTTP_409_CONFLICT
|
|
247
|
-
)
|
|
240
|
+
return Response({"detail": "Enrollment exists."}, status=status.HTTP_409_CONFLICT)
|
|
248
241
|
|
|
249
242
|
enrollment.is_active = True
|
|
250
243
|
enrollment.enrolled_at = datetime.now(timezone.utc)
|
|
@@ -276,10 +269,7 @@ class LearningPathEnrollmentView(APIView):
|
|
|
276
269
|
user=user,
|
|
277
270
|
)
|
|
278
271
|
|
|
279
|
-
if
|
|
280
|
-
not request.user.is_staff
|
|
281
|
-
and not settings.LEARNING_PATHS_ALLOW_SELF_UNENROLLMENT
|
|
282
|
-
):
|
|
272
|
+
if not request.user.is_staff and not settings.LEARNING_PATHS_ALLOW_SELF_UNENROLLMENT:
|
|
283
273
|
raise PermissionDenied
|
|
284
274
|
|
|
285
275
|
enrollment.is_active = False
|
|
@@ -340,6 +330,14 @@ class BulkEnrollView(APIView):
|
|
|
340
330
|
learning_paths_keys = data.get("learning_paths", "").split(",")
|
|
341
331
|
emails = data.get("emails", "").split(",")
|
|
342
332
|
|
|
333
|
+
audit_data = {
|
|
334
|
+
"enrolled_by": request.user,
|
|
335
|
+
"reason": data.get("reason", ""),
|
|
336
|
+
"org": data.get("org", ""),
|
|
337
|
+
"role": data.get("role", ""),
|
|
338
|
+
"state_transition": LearningPathEnrollmentAudit.DEFAULT_TRANSITION_STATE,
|
|
339
|
+
}
|
|
340
|
+
|
|
343
341
|
valid_learning_paths_keys = []
|
|
344
342
|
for key in learning_paths_keys:
|
|
345
343
|
try:
|
|
@@ -360,20 +358,22 @@ class BulkEnrollView(APIView):
|
|
|
360
358
|
|
|
361
359
|
# Create LearningPathEnrollment for existing users
|
|
362
360
|
for user in existing_users:
|
|
363
|
-
enrollment = LearningPathEnrollment.objects.filter(
|
|
364
|
-
user=user, learning_path=learning_path
|
|
365
|
-
).first()
|
|
361
|
+
enrollment = LearningPathEnrollment.objects.filter(user=user, learning_path=learning_path).first()
|
|
366
362
|
enrolled_now = False
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
363
|
+
audit_data["state_transition"] = LearningPathEnrollmentAudit.UNENROLLED_TO_ENROLLED
|
|
364
|
+
if enrollment:
|
|
365
|
+
if not enrollment.is_active:
|
|
366
|
+
enrollment.is_active = True
|
|
367
|
+
enrollment.enrolled_at = datetime.now(timezone.utc)
|
|
368
|
+
enrolled_now = True
|
|
369
|
+
else:
|
|
370
|
+
audit_data["state_transition"] = LearningPathEnrollmentAudit.ENROLLED_TO_ENROLLED
|
|
371
|
+
else:
|
|
372
|
+
enrollment = LearningPathEnrollment(user=user, learning_path=learning_path)
|
|
376
373
|
enrolled_now = True
|
|
374
|
+
|
|
375
|
+
# Set enrollment audit data that will be used by the post_save receiver.
|
|
376
|
+
enrollment._audit = audit_data # pylint: disable=protected-access
|
|
377
377
|
enrollment.save()
|
|
378
378
|
if enrolled_now:
|
|
379
379
|
enrollments_created.append(enrollment)
|
|
@@ -391,6 +391,10 @@ class BulkEnrollView(APIView):
|
|
|
391
391
|
if created:
|
|
392
392
|
enrollment_allowed_created.append(allowed)
|
|
393
393
|
|
|
394
|
+
audit_data["state_transition"] = LearningPathEnrollmentAudit.UNENROLLED_TO_ALLOWEDTOENROLL
|
|
395
|
+
allowed._audit = audit_data # pylint: disable=protected-access
|
|
396
|
+
allowed.save()
|
|
397
|
+
|
|
394
398
|
return Response(
|
|
395
399
|
{
|
|
396
400
|
"enrollments_created": len(enrollments_created),
|
|
@@ -412,9 +416,7 @@ class LearningPathCourseEnrollmentView(APIView):
|
|
|
412
416
|
:raises: Http404 if the learning path is not found or the user does not have access.
|
|
413
417
|
"""
|
|
414
418
|
return get_object_or_404(
|
|
415
|
-
LearningPath.objects.get_paths_visible_to_user(self.request.user).filter(
|
|
416
|
-
is_enrolled=True
|
|
417
|
-
),
|
|
419
|
+
LearningPath.objects.get_paths_visible_to_user(self.request.user).filter(is_enrolled=True),
|
|
418
420
|
key=learning_path_key_str,
|
|
419
421
|
)
|
|
420
422
|
|
|
@@ -72,7 +72,5 @@ def enroll_user_in_course(user: AbstractBaseUser, course_key: CourseKey) -> bool
|
|
|
72
72
|
CourseEnrollment.enroll(user, course_key)
|
|
73
73
|
return True
|
|
74
74
|
except CourseEnrollmentException as exc:
|
|
75
|
-
log.exception(
|
|
76
|
-
"Failed to enroll user %s in course %s: %s", user, course_key, exc
|
|
77
|
-
)
|
|
75
|
+
log.exception("Failed to enroll user %s in course %s: %s", user, course_key, exc)
|
|
78
76
|
return False
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Pytest fixtures."""
|
|
2
|
+
|
|
3
|
+
# pylint: disable=redefined-outer-name
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from django.test import override_settings
|
|
7
|
+
|
|
8
|
+
from learning_paths.tests.factories import (
|
|
9
|
+
LearningPathEnrollmentFactory,
|
|
10
|
+
LearningPathFactory,
|
|
11
|
+
UserFactory,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def user():
|
|
17
|
+
"""Create a single user for testing."""
|
|
18
|
+
return UserFactory()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def learning_path():
|
|
23
|
+
"""Create a single learning path for testing."""
|
|
24
|
+
return LearningPathFactory(invite_only=False)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def learning_path_with_invite_only():
|
|
29
|
+
"""Create a learning path that is invite-only."""
|
|
30
|
+
return LearningPathFactory()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def active_enrollment(user, learning_path):
|
|
35
|
+
"""Create an active enrollment for the user in the learning path."""
|
|
36
|
+
return LearningPathEnrollmentFactory(user=user, learning_path=learning_path, is_active=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture
|
|
40
|
+
def inactive_enrollment(user, learning_path):
|
|
41
|
+
"""Create an inactive enrollment for the user in the learning path."""
|
|
42
|
+
return LearningPathEnrollmentFactory(user=user, learning_path=learning_path, is_active=False)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.fixture
|
|
46
|
+
def temp_media(tmpdir):
|
|
47
|
+
"""Temporarily override MEDIA_ROOT to a pytest tmpdir."""
|
|
48
|
+
temp_dir = str(tmpdir.mkdir("media"))
|
|
49
|
+
|
|
50
|
+
with override_settings(MEDIA_ROOT=temp_dir):
|
|
51
|
+
yield temp_dir
|
|
@@ -12,15 +12,11 @@ from opaque_keys.edx.keys import LearningContextKey
|
|
|
12
12
|
|
|
13
13
|
COURSE_KEY_NAMESPACE = "course-v1"
|
|
14
14
|
COURSE_KEY_PATTERN = r"([^+]+)\+([^+]+)\+([^+]+)"
|
|
15
|
-
COURSE_KEY_URL_PATTERN = (
|
|
16
|
-
rf"(?P<course_key_str>{COURSE_KEY_NAMESPACE}:{COURSE_KEY_PATTERN})"
|
|
17
|
-
)
|
|
15
|
+
COURSE_KEY_URL_PATTERN = rf"(?P<course_key_str>{COURSE_KEY_NAMESPACE}:{COURSE_KEY_PATTERN})"
|
|
18
16
|
|
|
19
17
|
LEARNING_PATH_NAMESPACE = "path-v1"
|
|
20
18
|
LEARNING_PATH_PATTERN = r"([^+]+)\+([^+]+)\+([^+]+)\+([^+]+)"
|
|
21
|
-
LEARNING_PATH_URL_PATTERN = (
|
|
22
|
-
rf"(?P<learning_path_key_str>{LEARNING_PATH_NAMESPACE}:{LEARNING_PATH_PATTERN})"
|
|
23
|
-
)
|
|
19
|
+
LEARNING_PATH_URL_PATTERN = rf"(?P<learning_path_key_str>{LEARNING_PATH_NAMESPACE}:{LEARNING_PATH_PATTERN})"
|
|
24
20
|
|
|
25
21
|
|
|
26
22
|
class LearningPathKey(LearningContextKey):
|
|
@@ -52,9 +48,7 @@ class LearningPathKey(LearningContextKey):
|
|
|
52
48
|
|
|
53
49
|
def _to_string(self) -> str:
|
|
54
50
|
"""Return a string representing this key."""
|
|
55
|
-
return "+".join(
|
|
56
|
-
[self.org, self.number, self.run, self.group] # pylint: disable=no-member
|
|
57
|
-
)
|
|
51
|
+
return "+".join([self.org, self.number, self.run, self.group]) # pylint: disable=no-member
|
|
58
52
|
|
|
59
53
|
|
|
60
54
|
class LearningPathKeyField(LearningContextKeyField):
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Generated by Django 4.2.16 on 2025-03-28 09:54
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
import django.utils.timezone
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Migration(migrations.Migration):
|
|
9
|
+
|
|
10
|
+
dependencies = [
|
|
11
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
12
|
+
('learning_paths', '0005_learningpathstep_weight_learningpathgradingcriteria'),
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
operations = [
|
|
16
|
+
migrations.AddField(
|
|
17
|
+
model_name='learningpathenrollment',
|
|
18
|
+
name='enrolled_at',
|
|
19
|
+
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, help_text='Timestamp of enrollment or un-enrollment. To be explicitly set when performing a learner enrollment.'),
|
|
20
|
+
preserve_default=False,
|
|
21
|
+
),
|
|
22
|
+
migrations.AddField(
|
|
23
|
+
model_name='learningpathenrollment',
|
|
24
|
+
name='is_active',
|
|
25
|
+
field=models.BooleanField(default=True, help_text='Indicates if the learner is enrolled or not in the Learning Path'),
|
|
26
|
+
),
|
|
27
|
+
migrations.CreateModel(
|
|
28
|
+
name='LearningPathEnrollmentAllowed',
|
|
29
|
+
fields=[
|
|
30
|
+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
31
|
+
('email', models.EmailField(max_length=254)),
|
|
32
|
+
('learning_path', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='learning_paths.learningpath')),
|
|
33
|
+
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
|
34
|
+
],
|
|
35
|
+
options={
|
|
36
|
+
'unique_together': {('email', 'learning_path')},
|
|
37
|
+
},
|
|
38
|
+
),
|
|
39
|
+
]
|