learning-paths-plugin 0.3.3__tar.gz → 0.3.4rc8__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.3 → learning_paths_plugin-0.3.4rc8}/CHANGELOG.rst +15 -0
- {learning_paths_plugin-0.3.3/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.4rc8}/PKG-INFO +16 -2
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/__init__.py +1 -1
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/admin.py +170 -3
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/api/v1/serializers.py +8 -8
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/api/v1/views.py +149 -32
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/compat.py +4 -6
- learning_paths_plugin-0.3.4rc8/learning_paths/conftest.py +51 -0
- learning_paths_plugin-0.3.4rc8/learning_paths/migrations/0006_enrollment_models.py +39 -0
- learning_paths_plugin-0.3.4rc8/learning_paths/migrations/0013_enrollment_audit.py +169 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/models.py +113 -31
- learning_paths_plugin-0.3.4rc8/learning_paths/receivers.py +139 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8/learning_paths_plugin.egg-info}/PKG-INFO +16 -2
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths_plugin.egg-info/SOURCES.txt +1 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths_plugin.egg-info/requires.txt +0 -1
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/requirements/base.in +0 -1
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/requirements/constraints.txt +1 -6
- learning_paths_plugin-0.3.3/learning_paths/conftest.py +0 -13
- learning_paths_plugin-0.3.3/learning_paths/migrations/0006_enrollment_models.py +0 -66
- learning_paths_plugin-0.3.3/learning_paths/receivers.py +0 -46
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/LICENSE.txt +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/MANIFEST.in +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/README.rst +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/api/__init__.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/api/urls.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/api/v1/__init__.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/api/v1/filters.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/api/v1/permissions.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/api/v1/urls.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/api/v1/utils.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/apps.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/keys.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/migrations/0001_initial.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/migrations/0002_learningpath_uuid.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/migrations/0003_learningpath_subtitle.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/migrations/0004_auto_20240207_1633.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/migrations/0007_replace_uuid_with_learningpathkey.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/migrations/0008_remove_learningpathstep_relative_due_date_in_days.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/migrations/0009_remove_learningpath_slug.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/migrations/0010_learningpath_invite_only.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/migrations/0011_replace_learningpath_image_url_with_image.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/migrations/0012_alter_learningpath_subtitle.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/migrations/__init__.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/settings.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/templates/learning_paths/base.html +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/urls.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths_plugin.egg-info/dependency_links.txt +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths_plugin.egg-info/entry_points.txt +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths_plugin.egg-info/not-zip-safe +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths_plugin.egg-info/top_level.txt +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/pyproject.toml +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/setup.cfg +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/setup.py +0 -0
|
@@ -16,6 +16,21 @@ Unreleased
|
|
|
16
16
|
|
|
17
17
|
*
|
|
18
18
|
|
|
19
|
+
0.3.4 - 2025-05-23
|
|
20
|
+
******************
|
|
21
|
+
|
|
22
|
+
Added
|
|
23
|
+
=====
|
|
24
|
+
|
|
25
|
+
* Bulk unenrollment API.
|
|
26
|
+
* Enrollment audit model that tracks the enrollment state transitions.
|
|
27
|
+
|
|
28
|
+
Changed
|
|
29
|
+
=======
|
|
30
|
+
|
|
31
|
+
* The Learning Paths API includes start and end dates for its steps.
|
|
32
|
+
* Return enrollment date in the API instead of a boolean.
|
|
33
|
+
|
|
19
34
|
0.3.3 - 2025-05-23
|
|
20
35
|
******************
|
|
21
36
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning-paths-plugin
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4rc8
|
|
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,21 @@ Unreleased
|
|
|
121
120
|
|
|
122
121
|
*
|
|
123
122
|
|
|
123
|
+
0.3.4 - 2025-05-23
|
|
124
|
+
******************
|
|
125
|
+
|
|
126
|
+
Added
|
|
127
|
+
=====
|
|
128
|
+
|
|
129
|
+
* Bulk unenrollment API.
|
|
130
|
+
* Enrollment audit model that tracks the enrollment state transitions.
|
|
131
|
+
|
|
132
|
+
Changed
|
|
133
|
+
=======
|
|
134
|
+
|
|
135
|
+
* The Learning Paths API includes start and end dates for its steps.
|
|
136
|
+
* Return enrollment date in the API instead of a boolean.
|
|
137
|
+
|
|
124
138
|
0.3.3 - 2025-05-23
|
|
125
139
|
******************
|
|
126
140
|
|
|
@@ -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,
|
|
@@ -135,6 +137,7 @@ class BulkEnrollUsersForm(forms.ModelForm):
|
|
|
135
137
|
return users
|
|
136
138
|
|
|
137
139
|
|
|
140
|
+
@admin.register(LearningPath)
|
|
138
141
|
class LearningPathAdmin(admin.ModelAdmin):
|
|
139
142
|
"""Admin for Learning Path."""
|
|
140
143
|
|
|
@@ -176,18 +179,85 @@ class LearningPathAdmin(admin.ModelAdmin):
|
|
|
176
179
|
LearningPathEnrollment.objects.get_or_create(user=user, learning_path=form.instance)
|
|
177
180
|
|
|
178
181
|
|
|
182
|
+
@admin.register(Skill)
|
|
179
183
|
class SkillAdmin(admin.ModelAdmin):
|
|
180
184
|
"""Admin for Learning Path generic skill."""
|
|
181
185
|
|
|
182
186
|
model = Skill
|
|
183
187
|
|
|
184
188
|
|
|
189
|
+
class EnrollmentAuditInline(admin.TabularInline):
|
|
190
|
+
"""Inline admin for LearningPathEnrollmentAudit records."""
|
|
191
|
+
|
|
192
|
+
model = LearningPathEnrollmentAudit
|
|
193
|
+
fk_name = "enrollment"
|
|
194
|
+
extra = 0
|
|
195
|
+
exclude = ["enrollment_allowed"]
|
|
196
|
+
readonly_fields = [
|
|
197
|
+
"state_transition",
|
|
198
|
+
"enrolled_by",
|
|
199
|
+
"reason",
|
|
200
|
+
"org",
|
|
201
|
+
"role",
|
|
202
|
+
"created",
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
def has_add_permission(self, request, obj=None):
|
|
206
|
+
"""Disable manual creation of audit records."""
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
def has_delete_permission(self, request, obj=None):
|
|
210
|
+
"""Disable deletion of audit records."""
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class EnrollmentAllowedAuditInline(admin.TabularInline):
|
|
215
|
+
"""Inline admin for LearningPathEnrollmentAudit records related to enrollment allowed."""
|
|
216
|
+
|
|
217
|
+
model = LearningPathEnrollmentAudit
|
|
218
|
+
fk_name = "enrollment_allowed"
|
|
219
|
+
extra = 0
|
|
220
|
+
exclude = ["enrollment"]
|
|
221
|
+
readonly_fields = [
|
|
222
|
+
"state_transition",
|
|
223
|
+
"enrolled_by",
|
|
224
|
+
"reason",
|
|
225
|
+
"org",
|
|
226
|
+
"role",
|
|
227
|
+
"created",
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
def has_add_permission(self, request, obj=None):
|
|
231
|
+
"""Disable manual creation of audit records."""
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
def has_delete_permission(self, request, obj=None):
|
|
235
|
+
"""Disable deletion of audit records."""
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@admin.register(LearningPathEnrollment)
|
|
185
240
|
class EnrolledUsersAdmin(admin.ModelAdmin):
|
|
186
241
|
"""Admin for Learning Path enrollment."""
|
|
187
242
|
|
|
188
243
|
model = LearningPathEnrollment
|
|
189
244
|
raw_id_fields = ("user",)
|
|
190
245
|
autocomplete_fields = ["learning_path"]
|
|
246
|
+
inlines = [EnrollmentAuditInline]
|
|
247
|
+
|
|
248
|
+
list_display = [
|
|
249
|
+
"id",
|
|
250
|
+
"user",
|
|
251
|
+
"learning_path",
|
|
252
|
+
"is_active",
|
|
253
|
+
"created",
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
list_filter = [
|
|
257
|
+
"learning_path__key",
|
|
258
|
+
"created",
|
|
259
|
+
"is_active",
|
|
260
|
+
]
|
|
191
261
|
|
|
192
262
|
search_fields = [
|
|
193
263
|
"id",
|
|
@@ -197,6 +267,103 @@ class EnrolledUsersAdmin(admin.ModelAdmin):
|
|
|
197
267
|
]
|
|
198
268
|
|
|
199
269
|
|
|
200
|
-
admin.
|
|
201
|
-
admin.
|
|
202
|
-
|
|
270
|
+
@admin.register(LearningPathEnrollmentAllowed)
|
|
271
|
+
class EnrollmentAllowedAdmin(admin.ModelAdmin):
|
|
272
|
+
"""Admin configuration for LearningPathEnrollmentAllowed model."""
|
|
273
|
+
|
|
274
|
+
list_display = [
|
|
275
|
+
"id",
|
|
276
|
+
"email",
|
|
277
|
+
"get_user",
|
|
278
|
+
"learning_path",
|
|
279
|
+
"created",
|
|
280
|
+
]
|
|
281
|
+
|
|
282
|
+
list_filter = [
|
|
283
|
+
"learning_path",
|
|
284
|
+
"created",
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
search_fields = [
|
|
288
|
+
"email",
|
|
289
|
+
"user__username",
|
|
290
|
+
"user__email",
|
|
291
|
+
"learning_path__key",
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
readonly_fields = [
|
|
295
|
+
"user",
|
|
296
|
+
"created",
|
|
297
|
+
"modified",
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
inlines = [EnrollmentAllowedAuditInline]
|
|
301
|
+
|
|
302
|
+
def get_user(self, obj):
|
|
303
|
+
"""Get the associated user, if any."""
|
|
304
|
+
return obj.user.username if obj.user else "-"
|
|
305
|
+
|
|
306
|
+
get_user.short_description = "User"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@admin.register(LearningPathEnrollmentAudit)
|
|
310
|
+
class EnrollmentAuditAdmin(admin.ModelAdmin):
|
|
311
|
+
"""Admin configuration for LearningPathEnrollmentAudit model."""
|
|
312
|
+
|
|
313
|
+
list_display = [
|
|
314
|
+
"id",
|
|
315
|
+
"state_transition",
|
|
316
|
+
"enrolled_by",
|
|
317
|
+
"get_enrollee",
|
|
318
|
+
"get_learning_path",
|
|
319
|
+
"created",
|
|
320
|
+
"org",
|
|
321
|
+
"role",
|
|
322
|
+
]
|
|
323
|
+
|
|
324
|
+
list_filter = [
|
|
325
|
+
"state_transition",
|
|
326
|
+
"created",
|
|
327
|
+
"org",
|
|
328
|
+
"role",
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
search_fields = [
|
|
332
|
+
"enrolled_by__username",
|
|
333
|
+
"enrolled_by__email",
|
|
334
|
+
"enrollment__user__username",
|
|
335
|
+
"enrollment__user__email",
|
|
336
|
+
"enrollment_allowed__email",
|
|
337
|
+
"enrollment__learning_path__key",
|
|
338
|
+
"enrollment_allowed__learning_path__key",
|
|
339
|
+
"reason",
|
|
340
|
+
]
|
|
341
|
+
|
|
342
|
+
readonly_fields = [
|
|
343
|
+
"enrollment",
|
|
344
|
+
"enrollment_allowed",
|
|
345
|
+
"enrolled_by",
|
|
346
|
+
"state_transition",
|
|
347
|
+
"created",
|
|
348
|
+
"modified",
|
|
349
|
+
]
|
|
350
|
+
|
|
351
|
+
def get_enrollee(self, obj):
|
|
352
|
+
"""Get the enrollee (user or email)."""
|
|
353
|
+
if obj.enrollment:
|
|
354
|
+
return obj.enrollment.user.username
|
|
355
|
+
elif obj.enrollment_allowed:
|
|
356
|
+
return obj.enrollment_allowed.user.username if obj.enrollment_allowed.user else obj.enrollment_allowed.email
|
|
357
|
+
return "-"
|
|
358
|
+
|
|
359
|
+
get_enrollee.short_description = "Enrollee"
|
|
360
|
+
|
|
361
|
+
def get_learning_path(self, obj):
|
|
362
|
+
"""Get the learning path title."""
|
|
363
|
+
if obj.enrollment:
|
|
364
|
+
return obj.enrollment.learning_path.key
|
|
365
|
+
elif obj.enrollment_allowed:
|
|
366
|
+
return obj.enrollment_allowed.learning_path.key
|
|
367
|
+
return "-"
|
|
368
|
+
|
|
369
|
+
get_learning_path.short_description = "Learning Path"
|
{learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/api/v1/serializers.py
RENAMED
|
@@ -97,7 +97,7 @@ class LearningPathGradeSerializer(serializers.Serializer):
|
|
|
97
97
|
class LearningPathStepSerializer(serializers.ModelSerializer):
|
|
98
98
|
class Meta:
|
|
99
99
|
model = LearningPathStep
|
|
100
|
-
fields = ["order", "course_key", "
|
|
100
|
+
fields = ["order", "course_key", "course_dates", "weight"]
|
|
101
101
|
|
|
102
102
|
|
|
103
103
|
class LearningPathListSerializer(serializers.ModelSerializer):
|
|
@@ -105,7 +105,7 @@ class LearningPathListSerializer(serializers.ModelSerializer):
|
|
|
105
105
|
|
|
106
106
|
steps = LearningPathStepSerializer(many=True, read_only=True)
|
|
107
107
|
required_completion = serializers.FloatField(source="grading_criteria.required_completion", read_only=True)
|
|
108
|
-
|
|
108
|
+
enrollment_date = serializers.SerializerMethodField()
|
|
109
109
|
invite_only = serializers.BooleanField()
|
|
110
110
|
image = serializers.ImageField(read_only=True)
|
|
111
111
|
|
|
@@ -118,17 +118,17 @@ class LearningPathListSerializer(serializers.ModelSerializer):
|
|
|
118
118
|
"sequential",
|
|
119
119
|
"steps",
|
|
120
120
|
"required_completion",
|
|
121
|
-
"
|
|
121
|
+
"enrollment_date",
|
|
122
122
|
"invite_only",
|
|
123
123
|
]
|
|
124
124
|
|
|
125
|
-
def
|
|
125
|
+
def get_enrollment_date(self, obj):
|
|
126
126
|
"""
|
|
127
127
|
Check if the current user is enrolled in this learning path.
|
|
128
128
|
"""
|
|
129
|
-
if hasattr(obj, "
|
|
130
|
-
return obj.
|
|
131
|
-
return
|
|
129
|
+
if hasattr(obj, "enrollment_date"):
|
|
130
|
+
return obj.enrollment_date
|
|
131
|
+
return None
|
|
132
132
|
|
|
133
133
|
|
|
134
134
|
class SkillSerializer(serializers.ModelSerializer):
|
|
@@ -183,4 +183,4 @@ class LearningPathDetailSerializer(LearningPathListSerializer):
|
|
|
183
183
|
class LearningPathEnrollmentSerializer(serializers.ModelSerializer):
|
|
184
184
|
class Meta:
|
|
185
185
|
model = LearningPathEnrollment
|
|
186
|
-
fields = ("user", "learning_path", "is_active", "
|
|
186
|
+
fields = ("user", "learning_path", "is_active", "created")
|
{learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc8}/learning_paths/api/v1/views.py
RENAMED
|
@@ -3,12 +3,12 @@ Views for LearningPath.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
-
from datetime import datetime, timezone
|
|
7
6
|
|
|
8
7
|
from django.conf import settings
|
|
9
8
|
from django.contrib.auth import get_user_model
|
|
10
9
|
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
|
|
11
10
|
from django.core.validators import validate_email
|
|
11
|
+
from django.db.models import QuerySet
|
|
12
12
|
from django.shortcuts import get_object_or_404
|
|
13
13
|
from opaque_keys import InvalidKeyError
|
|
14
14
|
from opaque_keys.edx.keys import CourseKey
|
|
@@ -16,6 +16,7 @@ from rest_framework import generics, status, viewsets
|
|
|
16
16
|
from rest_framework.exceptions import NotFound, ParseError
|
|
17
17
|
from rest_framework.pagination import PageNumberPagination
|
|
18
18
|
from rest_framework.permissions import IsAdminUser, IsAuthenticated
|
|
19
|
+
from rest_framework.request import Request
|
|
19
20
|
from rest_framework.response import Response
|
|
20
21
|
from rest_framework.views import APIView
|
|
21
22
|
|
|
@@ -33,6 +34,7 @@ from learning_paths.models import (
|
|
|
33
34
|
LearningPath,
|
|
34
35
|
LearningPathEnrollment,
|
|
35
36
|
LearningPathEnrollmentAllowed,
|
|
37
|
+
LearningPathEnrollmentAudit,
|
|
36
38
|
)
|
|
37
39
|
|
|
38
40
|
from .filters import AdminOrSelfFilterBackend
|
|
@@ -239,7 +241,6 @@ class LearningPathEnrollmentView(APIView):
|
|
|
239
241
|
return Response({"detail": "Enrollment exists."}, status=status.HTTP_409_CONFLICT)
|
|
240
242
|
|
|
241
243
|
enrollment.is_active = True
|
|
242
|
-
enrollment.enrolled_at = datetime.now(timezone.utc)
|
|
243
244
|
enrollment.save()
|
|
244
245
|
return Response(LearningPathEnrollmentSerializer(enrollment).data)
|
|
245
246
|
|
|
@@ -295,13 +296,53 @@ class ListEnrollmentsView(generics.ListAPIView):
|
|
|
295
296
|
|
|
296
297
|
class BulkEnrollView(APIView):
|
|
297
298
|
"""
|
|
298
|
-
Bulk enrollment API for LearningPathEnrollment.
|
|
299
|
-
|
|
299
|
+
Bulk enrollment/unenrollment API for LearningPathEnrollment.
|
|
300
300
|
"""
|
|
301
301
|
|
|
302
302
|
permission_classes = [IsAdminUser]
|
|
303
303
|
|
|
304
|
-
|
|
304
|
+
@staticmethod
|
|
305
|
+
def _process_input_data(request: Request) -> tuple[list[str], list[str]]:
|
|
306
|
+
"""Extract and validate input data from request."""
|
|
307
|
+
data = request.data
|
|
308
|
+
learning_paths_keys = data.get("learning_paths", "").split(",")
|
|
309
|
+
emails = data.get("emails", "").split(",")
|
|
310
|
+
|
|
311
|
+
return learning_paths_keys, emails
|
|
312
|
+
|
|
313
|
+
@staticmethod
|
|
314
|
+
def _validate_learning_paths(learning_paths_keys: list[str]) -> QuerySet[LearningPath]:
|
|
315
|
+
"""Validate learning path keys and return valid ones."""
|
|
316
|
+
valid_learning_paths_keys = []
|
|
317
|
+
for key in learning_paths_keys:
|
|
318
|
+
try:
|
|
319
|
+
LearningPathKey.from_string(key)
|
|
320
|
+
valid_learning_paths_keys.append(key)
|
|
321
|
+
except InvalidKeyError:
|
|
322
|
+
logger.warning("BulkEnrollView: Invalid learning path key: %s", key)
|
|
323
|
+
|
|
324
|
+
return LearningPath.objects.filter(key__in=valid_learning_paths_keys)
|
|
325
|
+
|
|
326
|
+
@staticmethod
|
|
327
|
+
def _create_audit_data(request: Request, state_transition: str) -> dict[str, str]:
|
|
328
|
+
"""Create audit data dictionary."""
|
|
329
|
+
return {
|
|
330
|
+
"enrolled_by": request.user,
|
|
331
|
+
"reason": request.data.get("reason", ""),
|
|
332
|
+
"org": request.data.get("org", ""),
|
|
333
|
+
"role": request.data.get("role", ""),
|
|
334
|
+
"state_transition": state_transition,
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
def _setup_bulk_operation(self, request: Request) -> tuple[QuerySet[LearningPath], QuerySet[User], list[str]]:
|
|
338
|
+
"""Common setup for bulk operations."""
|
|
339
|
+
learning_paths_keys, emails = self._process_input_data(request)
|
|
340
|
+
learning_paths = self._validate_learning_paths(learning_paths_keys)
|
|
341
|
+
existing_users = User.objects.filter(email__in=emails)
|
|
342
|
+
|
|
343
|
+
return learning_paths, existing_users, emails
|
|
344
|
+
|
|
345
|
+
def post(self, request: Request, *args, **kwargs) -> Response:
|
|
305
346
|
"""
|
|
306
347
|
Bulk Enroll learners in Learning Paths.
|
|
307
348
|
|
|
@@ -312,11 +353,17 @@ class BulkEnrollView(APIView):
|
|
|
312
353
|
|
|
313
354
|
{
|
|
314
355
|
"learning_paths": "learning_path_1,learning_path_2",
|
|
315
|
-
"emails": "user_1@example.com,user_2@example.com"
|
|
356
|
+
"emails": "user_1@example.com,user_2@example.com",
|
|
357
|
+
"reason": "Bulk enrollment for new cohort",
|
|
358
|
+
"org": "organization_name",
|
|
359
|
+
"role": "student"
|
|
316
360
|
}
|
|
317
361
|
|
|
318
362
|
`learning_paths` (str): A comma separated list of learning path IDs.
|
|
319
363
|
`emails` (str): A comma separated list of email addresses.
|
|
364
|
+
`reason` (str, optional): Reason for enrollment, used for audit.
|
|
365
|
+
`org` (str, optional): Organization identifier, used for audit.
|
|
366
|
+
`role` (str, optional): User role, used for audit.
|
|
320
367
|
|
|
321
368
|
* For existing users, it creates a new LearningPathEnrollment record, automatically
|
|
322
369
|
enrolling them in the learning path. It also creates a LearningPathAllowed record
|
|
@@ -325,42 +372,30 @@ class BulkEnrollView(APIView):
|
|
|
325
372
|
with just the email address, allowing them to get enrolled when they register.
|
|
326
373
|
|
|
327
374
|
"""
|
|
328
|
-
|
|
329
|
-
learning_paths_keys = data.get("learning_paths", "").split(",")
|
|
330
|
-
emails = data.get("emails", "").split(",")
|
|
331
|
-
|
|
332
|
-
valid_learning_paths_keys = []
|
|
333
|
-
for key in learning_paths_keys:
|
|
334
|
-
try:
|
|
335
|
-
LearningPathKey.from_string(key)
|
|
336
|
-
valid_learning_paths_keys.append(key)
|
|
337
|
-
except InvalidKeyError:
|
|
338
|
-
logger.warning("BulkEnrollView: Invalid learning path key: %s", key)
|
|
339
|
-
|
|
340
|
-
learning_paths = LearningPath.objects.filter(key__in=valid_learning_paths_keys)
|
|
341
|
-
|
|
342
|
-
existing_users = User.objects.filter(email__in=emails)
|
|
375
|
+
learning_paths, existing_users, emails = self._setup_bulk_operation(request)
|
|
343
376
|
non_existing_emails = set(emails) - set(u.email for u in existing_users)
|
|
344
377
|
|
|
345
378
|
enrollments_created = []
|
|
346
379
|
enrollment_allowed_created = []
|
|
347
380
|
|
|
348
381
|
for learning_path in learning_paths:
|
|
349
|
-
|
|
350
382
|
# Create LearningPathEnrollment for existing users
|
|
351
383
|
for user in existing_users:
|
|
352
384
|
enrollment = LearningPathEnrollment.objects.filter(user=user, learning_path=learning_path).first()
|
|
353
385
|
enrolled_now = False
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
enrollment
|
|
386
|
+
audit_data = self._create_audit_data(request, LearningPathEnrollmentAudit.UNENROLLED_TO_ENROLLED)
|
|
387
|
+
if enrollment:
|
|
388
|
+
if not enrollment.is_active:
|
|
389
|
+
enrollment.is_active = True
|
|
390
|
+
enrolled_now = True
|
|
391
|
+
else:
|
|
392
|
+
audit_data["state_transition"] = LearningPathEnrollmentAudit.ENROLLED_TO_ENROLLED
|
|
393
|
+
else:
|
|
394
|
+
enrollment = LearningPathEnrollment(user=user, learning_path=learning_path)
|
|
363
395
|
enrolled_now = True
|
|
396
|
+
|
|
397
|
+
# Set enrollment audit data that will be used by the post_save receiver.
|
|
398
|
+
enrollment._audit = audit_data # pylint: disable=protected-access
|
|
364
399
|
enrollment.save()
|
|
365
400
|
if enrolled_now:
|
|
366
401
|
enrollments_created.append(enrollment)
|
|
@@ -378,6 +413,10 @@ class BulkEnrollView(APIView):
|
|
|
378
413
|
if created:
|
|
379
414
|
enrollment_allowed_created.append(allowed)
|
|
380
415
|
|
|
416
|
+
audit_data = self._create_audit_data(request, LearningPathEnrollmentAudit.UNENROLLED_TO_ALLOWEDTOENROLL)
|
|
417
|
+
allowed._audit = audit_data # pylint: disable=protected-access
|
|
418
|
+
allowed.save()
|
|
419
|
+
|
|
381
420
|
return Response(
|
|
382
421
|
{
|
|
383
422
|
"enrollments_created": len(enrollments_created),
|
|
@@ -386,6 +425,84 @@ class BulkEnrollView(APIView):
|
|
|
386
425
|
status=status.HTTP_201_CREATED,
|
|
387
426
|
)
|
|
388
427
|
|
|
428
|
+
def delete(self, request, *args, **kwargs) -> Response:
|
|
429
|
+
"""
|
|
430
|
+
Bulk Unenroll learners from Learning Paths.
|
|
431
|
+
|
|
432
|
+
The "bulk unenroll" API provides a way for the staff to unenroll multiple learners
|
|
433
|
+
from multiple learning paths at once.
|
|
434
|
+
|
|
435
|
+
Example payload::
|
|
436
|
+
|
|
437
|
+
{
|
|
438
|
+
"learning_paths": "learning_path_1,learning_path_2",
|
|
439
|
+
"emails": "user_1@example.com,user_2@example.com",
|
|
440
|
+
"reason": "End of semester cleanup",
|
|
441
|
+
"org": "organization_name",
|
|
442
|
+
"role": "student"
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
`learning_paths` (str): A comma separated list of learning path IDs.
|
|
446
|
+
`emails` (str): A comma separated list of email addresses.
|
|
447
|
+
`reason` (str, optional): Reason for unenrollment, used for audit.
|
|
448
|
+
`org` (str, optional): Organization identifier, used for audit.
|
|
449
|
+
`role` (str, optional): User role, used for audit.
|
|
450
|
+
|
|
451
|
+
* For existing users, it deactivates their LearningPathEnrollment records.
|
|
452
|
+
* For emails with active LearningPathEnrollmentAllowed records, it deactivates those records.
|
|
453
|
+
|
|
454
|
+
"""
|
|
455
|
+
learning_paths, existing_users, emails = self._setup_bulk_operation(request)
|
|
456
|
+
|
|
457
|
+
enrollments_unenrolled = []
|
|
458
|
+
enrollment_allowed_deactivated = []
|
|
459
|
+
|
|
460
|
+
for learning_path in learning_paths:
|
|
461
|
+
for user in existing_users:
|
|
462
|
+
enrollment = LearningPathEnrollment.objects.filter(user=user, learning_path=learning_path).first()
|
|
463
|
+
|
|
464
|
+
if enrollment:
|
|
465
|
+
if enrollment.is_active:
|
|
466
|
+
state_transition = LearningPathEnrollmentAudit.ENROLLED_TO_UNENROLLED
|
|
467
|
+
enrollment.is_active = False
|
|
468
|
+
enrollments_unenrolled.append(enrollment)
|
|
469
|
+
else:
|
|
470
|
+
state_transition = LearningPathEnrollmentAudit.UNENROLLED_TO_UNENROLLED
|
|
471
|
+
audit_data = self._create_audit_data(request, state_transition)
|
|
472
|
+
enrollment._audit = audit_data # pylint: disable=protected-access
|
|
473
|
+
enrollment.save()
|
|
474
|
+
|
|
475
|
+
for email in emails:
|
|
476
|
+
try:
|
|
477
|
+
validate_email(email)
|
|
478
|
+
except ValidationError:
|
|
479
|
+
logger.warning("BulkEnrollView: Invalid email: %s", email)
|
|
480
|
+
continue
|
|
481
|
+
|
|
482
|
+
enrollment_allowed = LearningPathEnrollmentAllowed.objects.filter(
|
|
483
|
+
email=email,
|
|
484
|
+
learning_path=learning_path,
|
|
485
|
+
).first()
|
|
486
|
+
|
|
487
|
+
if enrollment_allowed:
|
|
488
|
+
if enrollment_allowed.is_active:
|
|
489
|
+
state_transition = LearningPathEnrollmentAudit.ALLOWEDTOENROLL_TO_UNENROLLED
|
|
490
|
+
enrollment_allowed.is_active = False
|
|
491
|
+
enrollment_allowed_deactivated.append(enrollment_allowed)
|
|
492
|
+
else:
|
|
493
|
+
state_transition = LearningPathEnrollmentAudit.UNENROLLED_TO_UNENROLLED
|
|
494
|
+
audit_data = self._create_audit_data(request, state_transition)
|
|
495
|
+
enrollment_allowed._audit = audit_data # pylint: disable=protected-access
|
|
496
|
+
enrollment_allowed.save()
|
|
497
|
+
|
|
498
|
+
return Response(
|
|
499
|
+
{
|
|
500
|
+
"enrollments_unenrolled": len(enrollments_unenrolled),
|
|
501
|
+
"enrollment_allowed_deactivated": len(enrollment_allowed_deactivated),
|
|
502
|
+
},
|
|
503
|
+
status=status.HTTP_204_NO_CONTENT,
|
|
504
|
+
)
|
|
505
|
+
|
|
389
506
|
|
|
390
507
|
class LearningPathCourseEnrollmentView(APIView):
|
|
391
508
|
"""API View to enroll a user in a course that's part of a learning path."""
|
|
@@ -399,7 +516,7 @@ class LearningPathCourseEnrollmentView(APIView):
|
|
|
399
516
|
:raises: Http404 if the learning path is not found or the user does not have access.
|
|
400
517
|
"""
|
|
401
518
|
return get_object_or_404(
|
|
402
|
-
LearningPath.objects.get_paths_visible_to_user(self.request.user).filter(
|
|
519
|
+
LearningPath.objects.get_paths_visible_to_user(self.request.user).filter(enrollment_date__isnull=False),
|
|
403
520
|
key=learning_path_key_str,
|
|
404
521
|
)
|
|
405
522
|
|
|
@@ -46,18 +46,16 @@ def get_course_keys_with_outlines() -> list[CourseKey]:
|
|
|
46
46
|
return course_keys_with_outlines()
|
|
47
47
|
|
|
48
48
|
|
|
49
|
-
def
|
|
50
|
-
"""
|
|
51
|
-
Retrieve course end date.
|
|
52
|
-
"""
|
|
49
|
+
def get_course_dates(course_key: CourseKey) -> tuple[datetime | None, datetime | None]:
|
|
50
|
+
"""Retrieve course start and end dates."""
|
|
53
51
|
# pylint: disable=import-outside-toplevel, import-error
|
|
54
52
|
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
|
55
53
|
|
|
56
54
|
try:
|
|
57
55
|
overview = CourseOverview.objects.get(id=course_key)
|
|
58
|
-
return overview.end
|
|
56
|
+
return overview.start, overview.end
|
|
59
57
|
except CourseOverview.DoesNotExist:
|
|
60
|
-
return None
|
|
58
|
+
return None, None
|
|
61
59
|
|
|
62
60
|
|
|
63
61
|
def enroll_user_in_course(user: AbstractBaseUser, course_key: CourseKey) -> bool:
|
|
@@ -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
|