learning-paths-plugin 0.3.3__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.3 → learning_paths_plugin-0.3.4rc1}/CHANGELOG.rst +8 -0
- {learning_paths_plugin-0.3.3/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.4rc1}/PKG-INFO +9 -2
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/__init__.py +1 -1
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/admin.py +158 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/views.py +26 -9
- learning_paths_plugin-0.3.4rc1/learning_paths/conftest.py +51 -0
- 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.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/models.py +57 -6
- learning_paths_plugin-0.3.4rc1/learning_paths/receivers.py +127 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1/learning_paths_plugin.egg-info}/PKG-INFO +9 -2
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/SOURCES.txt +1 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/requires.txt +0 -1
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/requirements/base.in +0 -1
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/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.4rc1}/LICENSE.txt +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/MANIFEST.in +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/README.rst +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/__init__.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/urls.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/__init__.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/filters.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/permissions.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/serializers.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/urls.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/utils.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/apps.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/compat.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/keys.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0001_initial.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0002_learningpath_uuid.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0003_learningpath_subtitle.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0004_auto_20240207_1633.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0007_replace_uuid_with_learningpathkey.py +0 -0
- {learning_paths_plugin-0.3.3 → 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.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0009_remove_learningpath_slug.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0010_learningpath_invite_only.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0011_replace_learningpath_image_url_with_image.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0012_alter_learningpath_subtitle.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/__init__.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/settings.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/templates/learning_paths/base.html +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/urls.py +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/dependency_links.txt +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/entry_points.txt +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/not-zip-safe +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/top_level.txt +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/pyproject.toml +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/setup.cfg +0 -0
- {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/setup.py +0 -0
|
@@ -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,14 @@ 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
|
+
|
|
124
131
|
0.3.3 - 2025-05-23
|
|
125
132
|
******************
|
|
126
133
|
|
|
@@ -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,
|
|
@@ -182,12 +184,63 @@ class SkillAdmin(admin.ModelAdmin):
|
|
|
182
184
|
model = Skill
|
|
183
185
|
|
|
184
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
|
+
|
|
185
237
|
class EnrolledUsersAdmin(admin.ModelAdmin):
|
|
186
238
|
"""Admin for Learning Path enrollment."""
|
|
187
239
|
|
|
188
240
|
model = LearningPathEnrollment
|
|
189
241
|
raw_id_fields = ("user",)
|
|
190
242
|
autocomplete_fields = ["learning_path"]
|
|
243
|
+
inlines = [LearningPathEnrollmentAuditInline]
|
|
191
244
|
|
|
192
245
|
search_fields = [
|
|
193
246
|
"id",
|
|
@@ -197,6 +250,111 @@ class EnrolledUsersAdmin(admin.ModelAdmin):
|
|
|
197
250
|
]
|
|
198
251
|
|
|
199
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
|
+
|
|
200
358
|
admin.site.register(LearningPath, LearningPathAdmin)
|
|
201
359
|
admin.site.register(Skill, SkillAdmin)
|
|
202
360
|
admin.site.register(LearningPathEnrollment, EnrolledUsersAdmin)
|
{learning_paths_plugin-0.3.3 → 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
|
|
@@ -329,6 +330,14 @@ class BulkEnrollView(APIView):
|
|
|
329
330
|
learning_paths_keys = data.get("learning_paths", "").split(",")
|
|
330
331
|
emails = data.get("emails", "").split(",")
|
|
331
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
|
+
|
|
332
341
|
valid_learning_paths_keys = []
|
|
333
342
|
for key in learning_paths_keys:
|
|
334
343
|
try:
|
|
@@ -351,16 +360,20 @@ class BulkEnrollView(APIView):
|
|
|
351
360
|
for user in existing_users:
|
|
352
361
|
enrollment = LearningPathEnrollment.objects.filter(user=user, learning_path=learning_path).first()
|
|
353
362
|
enrolled_now = False
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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)
|
|
363
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
|
|
364
377
|
enrollment.save()
|
|
365
378
|
if enrolled_now:
|
|
366
379
|
enrollments_created.append(enrollment)
|
|
@@ -378,6 +391,10 @@ class BulkEnrollView(APIView):
|
|
|
378
391
|
if created:
|
|
379
392
|
enrollment_allowed_created.append(allowed)
|
|
380
393
|
|
|
394
|
+
audit_data["state_transition"] = LearningPathEnrollmentAudit.UNENROLLED_TO_ALLOWEDTOENROLL
|
|
395
|
+
allowed._audit = audit_data # pylint: disable=protected-access
|
|
396
|
+
allowed.save()
|
|
397
|
+
|
|
381
398
|
return Response(
|
|
382
399
|
{
|
|
383
400
|
"enrollments_created": len(enrollments_created),
|
|
@@ -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
|
|
@@ -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
|
+
]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Generated by Django 4.2.20 on 2025-05-26 16:31
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
import django.utils.timezone
|
|
6
|
+
import model_utils.fields
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def delete_historical_model_if_exists(apps, schema_editor):
|
|
10
|
+
"""Delete HistoricalLearningPathEnrollment model if it exists."""
|
|
11
|
+
try:
|
|
12
|
+
HistoricalLearningPathEnrollment = apps.get_model(
|
|
13
|
+
"learning_paths", "HistoricalLearningPathEnrollment"
|
|
14
|
+
)
|
|
15
|
+
schema_editor.delete_model(HistoricalLearningPathEnrollment)
|
|
16
|
+
except LookupError:
|
|
17
|
+
# Model doesn't exist in Django's registry, skip deletion
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def reverse_delete_historical_model(apps, schema_editor):
|
|
22
|
+
"""Reverse operation - this is a no-op since we cannot recreate the model."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Migration(migrations.Migration):
|
|
27
|
+
|
|
28
|
+
dependencies = [
|
|
29
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
30
|
+
("learning_paths", "0012_alter_learningpath_subtitle"),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
operations = [
|
|
34
|
+
migrations.RunPython(
|
|
35
|
+
delete_historical_model_if_exists, reverse_delete_historical_model
|
|
36
|
+
),
|
|
37
|
+
migrations.AddField(
|
|
38
|
+
model_name="learningpathenrollmentallowed",
|
|
39
|
+
name="created",
|
|
40
|
+
field=model_utils.fields.AutoCreatedField(
|
|
41
|
+
default=django.utils.timezone.now,
|
|
42
|
+
editable=False,
|
|
43
|
+
verbose_name="created",
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
migrations.AddField(
|
|
47
|
+
model_name="learningpathenrollmentallowed",
|
|
48
|
+
name="modified",
|
|
49
|
+
field=model_utils.fields.AutoLastModifiedField(
|
|
50
|
+
default=django.utils.timezone.now,
|
|
51
|
+
editable=False,
|
|
52
|
+
verbose_name="modified",
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
migrations.AlterField(
|
|
56
|
+
model_name="learningpathenrollmentallowed",
|
|
57
|
+
name="email",
|
|
58
|
+
field=models.EmailField(db_index=True, max_length=254),
|
|
59
|
+
),
|
|
60
|
+
migrations.CreateModel(
|
|
61
|
+
name="LearningPathEnrollmentAudit",
|
|
62
|
+
fields=[
|
|
63
|
+
(
|
|
64
|
+
"id",
|
|
65
|
+
models.AutoField(
|
|
66
|
+
auto_created=True,
|
|
67
|
+
primary_key=True,
|
|
68
|
+
serialize=False,
|
|
69
|
+
verbose_name="ID",
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
(
|
|
73
|
+
"created",
|
|
74
|
+
model_utils.fields.AutoCreatedField(
|
|
75
|
+
default=django.utils.timezone.now,
|
|
76
|
+
editable=False,
|
|
77
|
+
verbose_name="created",
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
(
|
|
81
|
+
"modified",
|
|
82
|
+
model_utils.fields.AutoLastModifiedField(
|
|
83
|
+
default=django.utils.timezone.now,
|
|
84
|
+
editable=False,
|
|
85
|
+
verbose_name="modified",
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
(
|
|
89
|
+
"state_transition",
|
|
90
|
+
models.CharField(
|
|
91
|
+
choices=[
|
|
92
|
+
(
|
|
93
|
+
"from unenrolled to allowed to enroll",
|
|
94
|
+
"from unenrolled to allowed to enroll",
|
|
95
|
+
),
|
|
96
|
+
(
|
|
97
|
+
"from allowed to enroll to enrolled",
|
|
98
|
+
"from allowed to enroll to enrolled",
|
|
99
|
+
),
|
|
100
|
+
("from enrolled to enrolled", "from enrolled to enrolled"),
|
|
101
|
+
(
|
|
102
|
+
"from enrolled to unenrolled",
|
|
103
|
+
"from enrolled to unenrolled",
|
|
104
|
+
),
|
|
105
|
+
(
|
|
106
|
+
"from unenrolled to enrolled",
|
|
107
|
+
"from unenrolled to enrolled",
|
|
108
|
+
),
|
|
109
|
+
(
|
|
110
|
+
"from allowed to enroll to unenrolled",
|
|
111
|
+
"from allowed to enroll to unenrolled",
|
|
112
|
+
),
|
|
113
|
+
(
|
|
114
|
+
"from unenrolled to unenrolled",
|
|
115
|
+
"from unenrolled to unenrolled",
|
|
116
|
+
),
|
|
117
|
+
("N/A", "N/A"),
|
|
118
|
+
],
|
|
119
|
+
default="N/A",
|
|
120
|
+
max_length=255,
|
|
121
|
+
),
|
|
122
|
+
),
|
|
123
|
+
("reason", models.TextField(blank=True)),
|
|
124
|
+
("org", models.CharField(blank=True, db_index=True, max_length=255)),
|
|
125
|
+
("role", models.CharField(blank=True, max_length=255)),
|
|
126
|
+
(
|
|
127
|
+
"enrolled_by",
|
|
128
|
+
models.ForeignKey(
|
|
129
|
+
null=True,
|
|
130
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
131
|
+
related_name="learning_path_audit",
|
|
132
|
+
to=settings.AUTH_USER_MODEL,
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
(
|
|
136
|
+
"enrollment",
|
|
137
|
+
models.ForeignKey(
|
|
138
|
+
null=True,
|
|
139
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
140
|
+
related_name="audit",
|
|
141
|
+
to="learning_paths.learningpathenrollment",
|
|
142
|
+
),
|
|
143
|
+
),
|
|
144
|
+
(
|
|
145
|
+
"enrollment_allowed",
|
|
146
|
+
models.ForeignKey(
|
|
147
|
+
null=True,
|
|
148
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
149
|
+
related_name="audit",
|
|
150
|
+
to="learning_paths.learningpathenrollmentallowed",
|
|
151
|
+
),
|
|
152
|
+
),
|
|
153
|
+
],
|
|
154
|
+
options={
|
|
155
|
+
"abstract": False,
|
|
156
|
+
},
|
|
157
|
+
),
|
|
158
|
+
]
|
|
@@ -17,7 +17,6 @@ from django.utils.translation import gettext_lazy as _
|
|
|
17
17
|
from model_utils import FieldTracker
|
|
18
18
|
from model_utils.models import TimeStampedModel
|
|
19
19
|
from opaque_keys.edx.django.models import CourseKeyField
|
|
20
|
-
from simple_history.models import HistoricalRecords
|
|
21
20
|
from slugify import slugify
|
|
22
21
|
|
|
23
22
|
from .compat import get_course_due_date, get_user_course_grade
|
|
@@ -296,8 +295,7 @@ class LearningPathEnrollment(TimeStampedModel):
|
|
|
296
295
|
"Timestamp of enrollment or un-enrollment. To be explicitly set when performing a learner enrollment."
|
|
297
296
|
),
|
|
298
297
|
)
|
|
299
|
-
|
|
300
|
-
history = HistoricalRecords()
|
|
298
|
+
tracker = FieldTracker(fields=["is_active"])
|
|
301
299
|
|
|
302
300
|
def __str__(self):
|
|
303
301
|
"""User-friendly string representation of this model."""
|
|
@@ -354,7 +352,7 @@ class LearningPathGradingCriteria(models.Model):
|
|
|
354
352
|
return weighted_sum / total_weight if total_weight > 0 else 0.0
|
|
355
353
|
|
|
356
354
|
|
|
357
|
-
class LearningPathEnrollmentAllowed(
|
|
355
|
+
class LearningPathEnrollmentAllowed(TimeStampedModel):
|
|
358
356
|
"""
|
|
359
357
|
Represents an allowed enrollment in a learning path for a user email.
|
|
360
358
|
|
|
@@ -371,10 +369,63 @@ class LearningPathEnrollmentAllowed(models.Model):
|
|
|
371
369
|
|
|
372
370
|
unique_together = ("email", "learning_path")
|
|
373
371
|
|
|
374
|
-
email = models.EmailField()
|
|
372
|
+
email = models.EmailField(db_index=True)
|
|
375
373
|
learning_path = models.ForeignKey(LearningPath, on_delete=models.CASCADE)
|
|
376
374
|
user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True)
|
|
377
375
|
|
|
378
376
|
def __str__(self):
|
|
379
377
|
"""User-friendly string representation of this model."""
|
|
380
|
-
return f"LearningPathEnrollmentAllowed for {self.
|
|
378
|
+
return f"LearningPathEnrollmentAllowed for {self.email} in {self.learning_path.key}"
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class LearningPathEnrollmentAudit(TimeStampedModel):
|
|
382
|
+
"""
|
|
383
|
+
Audit model for tracking changes to learning path enrollments.
|
|
384
|
+
|
|
385
|
+
.. no_pii:
|
|
386
|
+
"""
|
|
387
|
+
|
|
388
|
+
# State transition constants (copied from edx-platform to maintain consistency)
|
|
389
|
+
UNENROLLED_TO_ALLOWEDTOENROLL = "from unenrolled to allowed to enroll"
|
|
390
|
+
ALLOWEDTOENROLL_TO_ENROLLED = "from allowed to enroll to enrolled"
|
|
391
|
+
ENROLLED_TO_ENROLLED = "from enrolled to enrolled"
|
|
392
|
+
ENROLLED_TO_UNENROLLED = "from enrolled to unenrolled"
|
|
393
|
+
UNENROLLED_TO_ENROLLED = "from unenrolled to enrolled"
|
|
394
|
+
ALLOWEDTOENROLL_TO_UNENROLLED = "from allowed to enroll to unenrolled"
|
|
395
|
+
UNENROLLED_TO_UNENROLLED = "from unenrolled to unenrolled"
|
|
396
|
+
DEFAULT_TRANSITION_STATE = "N/A"
|
|
397
|
+
|
|
398
|
+
TRANSITION_STATES = (
|
|
399
|
+
(UNENROLLED_TO_ALLOWEDTOENROLL, UNENROLLED_TO_ALLOWEDTOENROLL),
|
|
400
|
+
(ALLOWEDTOENROLL_TO_ENROLLED, ALLOWEDTOENROLL_TO_ENROLLED),
|
|
401
|
+
(ENROLLED_TO_ENROLLED, ENROLLED_TO_ENROLLED),
|
|
402
|
+
(ENROLLED_TO_UNENROLLED, ENROLLED_TO_UNENROLLED),
|
|
403
|
+
(UNENROLLED_TO_ENROLLED, UNENROLLED_TO_ENROLLED),
|
|
404
|
+
(ALLOWEDTOENROLL_TO_UNENROLLED, ALLOWEDTOENROLL_TO_UNENROLLED),
|
|
405
|
+
(UNENROLLED_TO_UNENROLLED, UNENROLLED_TO_UNENROLLED),
|
|
406
|
+
(DEFAULT_TRANSITION_STATE, DEFAULT_TRANSITION_STATE),
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
enrolled_by = models.ForeignKey(User, on_delete=models.CASCADE, null=True, related_name="learning_path_audit")
|
|
410
|
+
enrollment = models.ForeignKey(LearningPathEnrollment, on_delete=models.CASCADE, null=True, related_name="audit")
|
|
411
|
+
enrollment_allowed = models.ForeignKey(
|
|
412
|
+
LearningPathEnrollmentAllowed, on_delete=models.CASCADE, null=True, related_name="audit"
|
|
413
|
+
)
|
|
414
|
+
state_transition = models.CharField(max_length=255, choices=TRANSITION_STATES, default=DEFAULT_TRANSITION_STATE)
|
|
415
|
+
reason = models.TextField(blank=True)
|
|
416
|
+
org = models.CharField(max_length=255, blank=True, db_index=True)
|
|
417
|
+
role = models.CharField(max_length=255, blank=True)
|
|
418
|
+
|
|
419
|
+
def __str__(self):
|
|
420
|
+
"""User-friendly string representation of this model."""
|
|
421
|
+
enrollee = "unknown"
|
|
422
|
+
learning_path = "unknown"
|
|
423
|
+
|
|
424
|
+
if self.enrollment:
|
|
425
|
+
enrollee = self.enrollment.user
|
|
426
|
+
learning_path = self.enrollment.learning_path.key
|
|
427
|
+
elif self.enrollment_allowed:
|
|
428
|
+
enrollee = self.enrollment_allowed.user or self.enrollment_allowed.email
|
|
429
|
+
learning_path = self.enrollment_allowed.learning_path.key
|
|
430
|
+
|
|
431
|
+
return f"{self.state_transition} for {enrollee} in {learning_path}"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Django signal handler for learning paths plugin."""
|
|
2
|
+
|
|
3
|
+
# pylint: disable=unused-argument
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from django.db import IntegrityError
|
|
8
|
+
from django.db.models.signals import post_save
|
|
9
|
+
from django.dispatch import receiver
|
|
10
|
+
|
|
11
|
+
from learning_paths.models import (
|
|
12
|
+
LearningPathEnrollment,
|
|
13
|
+
LearningPathEnrollmentAllowed,
|
|
14
|
+
LearningPathEnrollmentAudit,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def process_pending_enrollments(sender, instance, created, **kwargs):
|
|
21
|
+
"""
|
|
22
|
+
Process pending enrollments after a user instance has been created.
|
|
23
|
+
|
|
24
|
+
Bulk enrollment API allows enrolling users with just the email. So learners who
|
|
25
|
+
do not have an account yet would also be enrolled. This information is stored
|
|
26
|
+
in the LearningPathEnrollmentAllowed model. This signal handler processes such
|
|
27
|
+
instances and created the corresponding LearningPathEnrollment objects.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
sender: User model class.
|
|
31
|
+
instance: The actual instance being saved.
|
|
32
|
+
created: A boolean indicating whether this is a creation and not an update.
|
|
33
|
+
"""
|
|
34
|
+
if not created:
|
|
35
|
+
logger.debug(
|
|
36
|
+
"[LearningPaths] Skipping processing of pending enrollments for user %s.",
|
|
37
|
+
instance,
|
|
38
|
+
)
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
logger.info("[LearningPaths] Processing pending enrollments for user %s", instance)
|
|
42
|
+
pending_enrollments = LearningPathEnrollmentAllowed.objects.filter(email=instance.email)
|
|
43
|
+
enrollments_created = 0
|
|
44
|
+
|
|
45
|
+
for entry in pending_enrollments:
|
|
46
|
+
try:
|
|
47
|
+
enrollment = LearningPathEnrollment(learning_path=entry.learning_path, user=instance)
|
|
48
|
+
enrollment._audit = { # pylint: disable=protected-access
|
|
49
|
+
"enrolled_by": instance,
|
|
50
|
+
"state_transition": LearningPathEnrollmentAudit.ALLOWEDTOENROLL_TO_ENROLLED,
|
|
51
|
+
}
|
|
52
|
+
enrollment.save()
|
|
53
|
+
enrollments_created += 1
|
|
54
|
+
except IntegrityError: # pragma: no cover
|
|
55
|
+
logger.info(
|
|
56
|
+
"[LearningPaths] Enrollment already exists for user %s in learning path %s",
|
|
57
|
+
instance,
|
|
58
|
+
entry.learning_path.key,
|
|
59
|
+
)
|
|
60
|
+
finally:
|
|
61
|
+
entry.user = instance
|
|
62
|
+
entry.save()
|
|
63
|
+
|
|
64
|
+
logger.info(
|
|
65
|
+
"[LearningPaths] Processed %d pending Learning Path enrollments for user %s.",
|
|
66
|
+
enrollments_created,
|
|
67
|
+
instance,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _create_enrollment_audit(instance: LearningPathEnrollment | LearningPathEnrollmentAllowed, audit_data: dict):
|
|
72
|
+
"""Create an audit record for the given instance with the provided audit data."""
|
|
73
|
+
# If a previous audit exists, copy over missing fields
|
|
74
|
+
previous_audit = instance.audit.order_by("-created").first()
|
|
75
|
+
if previous_audit:
|
|
76
|
+
for field in ["reason", "org", "role"]:
|
|
77
|
+
if not audit_data.get(field):
|
|
78
|
+
audit_data[field] = getattr(previous_audit, field)
|
|
79
|
+
|
|
80
|
+
instance.audit.create(
|
|
81
|
+
state_transition=audit_data.get("state_transition"),
|
|
82
|
+
enrolled_by=audit_data.get("enrolled_by"),
|
|
83
|
+
reason=audit_data.get("reason", ""),
|
|
84
|
+
org=audit_data.get("org", ""),
|
|
85
|
+
role=audit_data.get("role", ""),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@receiver(post_save, sender=LearningPathEnrollment)
|
|
90
|
+
def create_enrollment_audit(sender, instance, created, **kwargs):
|
|
91
|
+
"""Create audit records when LearningPathEnrollment is saved."""
|
|
92
|
+
audit_data = getattr(instance, "_audit", {})
|
|
93
|
+
|
|
94
|
+
# Determine state transition if not provided
|
|
95
|
+
if "state_transition" not in audit_data:
|
|
96
|
+
if created:
|
|
97
|
+
audit_data["state_transition"] = LearningPathEnrollmentAudit.UNENROLLED_TO_ENROLLED
|
|
98
|
+
elif instance.is_active and not instance.tracker.previous("is_active"):
|
|
99
|
+
audit_data["state_transition"] = LearningPathEnrollmentAudit.UNENROLLED_TO_ENROLLED
|
|
100
|
+
elif not instance.is_active and instance.tracker.previous("is_active"):
|
|
101
|
+
audit_data["state_transition"] = LearningPathEnrollmentAudit.ENROLLED_TO_UNENROLLED
|
|
102
|
+
elif instance.is_active and instance.tracker.previous("is_active"):
|
|
103
|
+
audit_data["state_transition"] = LearningPathEnrollmentAudit.ENROLLED_TO_ENROLLED
|
|
104
|
+
elif not instance.is_active and not instance.tracker.previous("is_active"):
|
|
105
|
+
audit_data["state_transition"] = LearningPathEnrollmentAudit.UNENROLLED_TO_UNENROLLED
|
|
106
|
+
else: # pragma: no cover
|
|
107
|
+
# No relevant state change. This should not happen.
|
|
108
|
+
audit_data["state_transition"] = LearningPathEnrollmentAudit.DEFAULT_TRANSITION_STATE
|
|
109
|
+
|
|
110
|
+
_create_enrollment_audit(instance, audit_data)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@receiver(post_save, sender=LearningPathEnrollmentAllowed)
|
|
114
|
+
def create_enrollment_allowed_audit(sender, instance, created, **kwargs):
|
|
115
|
+
"""Create audit records when LearningPathEnrollmentAllowed is saved."""
|
|
116
|
+
# The audit data can be missing in the following scenarios:
|
|
117
|
+
# 1. The instance is created with `get_or_create`, so we want to provide this data later.
|
|
118
|
+
# 2. The instance is updated when the user creates an account. In this case, the audit record is already created for
|
|
119
|
+
# the enrollment record, so we do not need to create it here.
|
|
120
|
+
if not (audit_data := getattr(instance, "_audit", {})):
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
audit_data["state_transition"] = audit_data.get(
|
|
124
|
+
"state_transition", LearningPathEnrollmentAudit.UNENROLLED_TO_ALLOWEDTOENROLL
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
_create_enrollment_audit(instance, audit_data)
|
|
@@ -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,14 @@ 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
|
+
|
|
124
131
|
0.3.3 - 2025-05-23
|
|
125
132
|
******************
|
|
126
133
|
|
|
@@ -36,6 +36,7 @@ learning_paths/migrations/0009_remove_learningpath_slug.py
|
|
|
36
36
|
learning_paths/migrations/0010_learningpath_invite_only.py
|
|
37
37
|
learning_paths/migrations/0011_replace_learningpath_image_url_with_image.py
|
|
38
38
|
learning_paths/migrations/0012_alter_learningpath_subtitle.py
|
|
39
|
+
learning_paths/migrations/0013_enrollment_audit.py
|
|
39
40
|
learning_paths/migrations/__init__.py
|
|
40
41
|
learning_paths/templates/learning_paths/base.html
|
|
41
42
|
learning_paths_plugin.egg-info/PKG-INFO
|
|
@@ -9,9 +9,4 @@
|
|
|
9
9
|
# linking to it here is good.
|
|
10
10
|
|
|
11
11
|
# Common constraints for edx repos
|
|
12
|
-
-c common_constraints.txt
|
|
13
|
-
|
|
14
|
-
# django-simple-history has been pinned on the edx-platform to version 3.4.0
|
|
15
|
-
# since the platform was updated to Django 4.2
|
|
16
|
-
# Ref: https://github.com/openedx/edx-platform/commit/e40a01c7ccfcc853e5be8cc25bdaa0d14248a270#diff-86d5fe588ff2fc7dccb1f4cdd8019d4473146536e88d7a9ede946ea962a91acb
|
|
17
|
-
django-simple-history==3.4.0
|
|
12
|
+
-c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
"""Pytest fixtures."""
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
from django.test import override_settings
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
@pytest.fixture
|
|
8
|
-
def temp_media(tmpdir):
|
|
9
|
-
"""Temporarily override MEDIA_ROOT to a pytest tmpdir."""
|
|
10
|
-
temp_dir = str(tmpdir.mkdir("media"))
|
|
11
|
-
|
|
12
|
-
with override_settings(MEDIA_ROOT=temp_dir):
|
|
13
|
-
yield temp_dir
|
|
@@ -1,66 +0,0 @@
|
|
|
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.db.models.deletion
|
|
6
|
-
import django.utils.timezone
|
|
7
|
-
import model_utils.fields
|
|
8
|
-
import simple_history.models
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class Migration(migrations.Migration):
|
|
12
|
-
|
|
13
|
-
dependencies = [
|
|
14
|
-
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
15
|
-
('learning_paths', '0005_learningpathstep_weight_learningpathgradingcriteria'),
|
|
16
|
-
]
|
|
17
|
-
|
|
18
|
-
operations = [
|
|
19
|
-
migrations.AddField(
|
|
20
|
-
model_name='learningpathenrollment',
|
|
21
|
-
name='enrolled_at',
|
|
22
|
-
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.'),
|
|
23
|
-
preserve_default=False,
|
|
24
|
-
),
|
|
25
|
-
migrations.AddField(
|
|
26
|
-
model_name='learningpathenrollment',
|
|
27
|
-
name='is_active',
|
|
28
|
-
field=models.BooleanField(default=True, help_text='Indicates if the learner is enrolled or not in the Learning Path'),
|
|
29
|
-
),
|
|
30
|
-
migrations.CreateModel(
|
|
31
|
-
name='HistoricalLearningPathEnrollment',
|
|
32
|
-
fields=[
|
|
33
|
-
('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
|
|
34
|
-
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
|
|
35
|
-
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
|
|
36
|
-
('is_active', models.BooleanField(default=True, help_text='Indicates if the learner is enrolled or not in the Learning Path')),
|
|
37
|
-
('enrolled_at', models.DateTimeField(blank=True, editable=False, help_text='Timestamp of enrollment or un-enrollment. To be explicitly set when performing a learner enrollment.')),
|
|
38
|
-
('history_id', models.AutoField(primary_key=True, serialize=False)),
|
|
39
|
-
('history_date', models.DateTimeField(db_index=True)),
|
|
40
|
-
('history_change_reason', models.CharField(max_length=100, null=True)),
|
|
41
|
-
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
|
|
42
|
-
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
|
43
|
-
('learning_path', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='learning_paths.learningpath')),
|
|
44
|
-
('user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)),
|
|
45
|
-
],
|
|
46
|
-
options={
|
|
47
|
-
'verbose_name': 'historical learning path enrollment',
|
|
48
|
-
'verbose_name_plural': 'historical learning path enrollments',
|
|
49
|
-
'ordering': ('-history_date', '-history_id'),
|
|
50
|
-
'get_latest_by': ('history_date', 'history_id'),
|
|
51
|
-
},
|
|
52
|
-
bases=(simple_history.models.HistoricalChanges, models.Model),
|
|
53
|
-
),
|
|
54
|
-
migrations.CreateModel(
|
|
55
|
-
name='LearningPathEnrollmentAllowed',
|
|
56
|
-
fields=[
|
|
57
|
-
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
58
|
-
('email', models.EmailField(max_length=254)),
|
|
59
|
-
('learning_path', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='learning_paths.learningpath')),
|
|
60
|
-
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
|
61
|
-
],
|
|
62
|
-
options={
|
|
63
|
-
'unique_together': {('email', 'learning_path')},
|
|
64
|
-
},
|
|
65
|
-
),
|
|
66
|
-
]
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
"""Django signal handler for learning paths plugin."""
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
|
|
5
|
-
from learning_paths.models import LearningPathEnrollment, LearningPathEnrollmentAllowed
|
|
6
|
-
|
|
7
|
-
logger = logging.getLogger(__name__)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def process_pending_enrollments(sender, instance, created, **kwargs): # pylint: disable=unused-argument
|
|
11
|
-
"""
|
|
12
|
-
Process pending enrollments after a user instance has been created.
|
|
13
|
-
|
|
14
|
-
Bulk enrollment API allows enrolling users with just the email. So learners who
|
|
15
|
-
do not have an account yet would also be enrolled. This information is stored
|
|
16
|
-
in the LearningPathEnrollmentAllowed model. This signal handler processes such
|
|
17
|
-
instances and created the corresponding LearningPathEnrollment objects.
|
|
18
|
-
|
|
19
|
-
Args:
|
|
20
|
-
sender: User model class.
|
|
21
|
-
instance: The actual instance being saved.
|
|
22
|
-
created: A boolean indicating whether this is a creation and not an update.
|
|
23
|
-
"""
|
|
24
|
-
if not created:
|
|
25
|
-
logger.debug(
|
|
26
|
-
"[LearningPaths] Skipping processing of pending enrollments for user %s.",
|
|
27
|
-
instance,
|
|
28
|
-
)
|
|
29
|
-
return
|
|
30
|
-
|
|
31
|
-
logger.info("[LearningPaths] Processing pending enrollments for user %s", instance)
|
|
32
|
-
pending_enrollments = LearningPathEnrollmentAllowed.objects.filter(email=instance.email).all()
|
|
33
|
-
|
|
34
|
-
enrollments = []
|
|
35
|
-
|
|
36
|
-
for entry in pending_enrollments:
|
|
37
|
-
entry.user = instance
|
|
38
|
-
entry.save()
|
|
39
|
-
|
|
40
|
-
enrollments.append(LearningPathEnrollment(learning_path=entry.learning_path, user=instance))
|
|
41
|
-
new_enrollments = LearningPathEnrollment.objects.bulk_create(enrollments)
|
|
42
|
-
logger.info(
|
|
43
|
-
"[LearningPaths] Processed %d pending Learning Path enrollments for user %s.",
|
|
44
|
-
instance,
|
|
45
|
-
len(new_enrollments),
|
|
46
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/__init__.py
RENAMED
|
File without changes
|
{learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/filters.py
RENAMED
|
File without changes
|
{learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/permissions.py
RENAMED
|
File without changes
|
{learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/serializers.py
RENAMED
|
File without changes
|
{learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/urls.py
RENAMED
|
File without changes
|
{learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|