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.
Files changed (54) hide show
  1. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/CHANGELOG.rst +8 -0
  2. {learning_paths_plugin-0.3.3/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.4rc1}/PKG-INFO +9 -2
  3. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/__init__.py +1 -1
  4. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/admin.py +158 -0
  5. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/views.py +26 -9
  6. learning_paths_plugin-0.3.4rc1/learning_paths/conftest.py +51 -0
  7. learning_paths_plugin-0.3.4rc1/learning_paths/migrations/0006_enrollment_models.py +39 -0
  8. learning_paths_plugin-0.3.4rc1/learning_paths/migrations/0013_enrollment_audit.py +158 -0
  9. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/models.py +57 -6
  10. learning_paths_plugin-0.3.4rc1/learning_paths/receivers.py +127 -0
  11. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1/learning_paths_plugin.egg-info}/PKG-INFO +9 -2
  12. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/SOURCES.txt +1 -0
  13. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/requires.txt +0 -1
  14. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/requirements/base.in +0 -1
  15. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/requirements/constraints.txt +1 -6
  16. learning_paths_plugin-0.3.3/learning_paths/conftest.py +0 -13
  17. learning_paths_plugin-0.3.3/learning_paths/migrations/0006_enrollment_models.py +0 -66
  18. learning_paths_plugin-0.3.3/learning_paths/receivers.py +0 -46
  19. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/LICENSE.txt +0 -0
  20. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/MANIFEST.in +0 -0
  21. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/README.rst +0 -0
  22. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/__init__.py +0 -0
  23. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/urls.py +0 -0
  24. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/__init__.py +0 -0
  25. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/filters.py +0 -0
  26. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/permissions.py +0 -0
  27. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/serializers.py +0 -0
  28. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/urls.py +0 -0
  29. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/utils.py +0 -0
  30. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/apps.py +0 -0
  31. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/compat.py +0 -0
  32. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/keys.py +0 -0
  33. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0001_initial.py +0 -0
  34. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0002_learningpath_uuid.py +0 -0
  35. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0003_learningpath_subtitle.py +0 -0
  36. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0004_auto_20240207_1633.py +0 -0
  37. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py +0 -0
  38. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0007_replace_uuid_with_learningpathkey.py +0 -0
  39. {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
  40. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0009_remove_learningpath_slug.py +0 -0
  41. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0010_learningpath_invite_only.py +0 -0
  42. {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
  43. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0012_alter_learningpath_subtitle.py +0 -0
  44. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/__init__.py +0 -0
  45. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/settings.py +0 -0
  46. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/templates/learning_paths/base.html +0 -0
  47. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths/urls.py +0 -0
  48. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/dependency_links.txt +0 -0
  49. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/entry_points.txt +0 -0
  50. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/not-zip-safe +0 -0
  51. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/top_level.txt +0 -0
  52. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/pyproject.toml +0 -0
  53. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/setup.cfg +0 -0
  54. {learning_paths_plugin-0.3.3 → learning_paths_plugin-0.3.4rc1}/setup.py +0 -0
@@ -16,6 +16,14 @@ 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
+
19
27
  0.3.3 - 2025-05-23
20
28
  ******************
21
29
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning-paths-plugin
3
- Version: 0.3.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
 
@@ -2,4 +2,4 @@
2
2
  Learning Paths plugin.
3
3
  """
4
4
 
5
- __version__ = "0.3.3"
5
+ __version__ = "0.3.4-rc1"
@@ -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)
@@ -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
- if not enrollment:
355
- enrollment = LearningPathEnrollment(
356
- user=user,
357
- learning_path=learning_path,
358
- )
359
- enrolled_now = True
360
- if not enrollment.is_active:
361
- enrollment.is_active = True
362
- enrollment.enrolled_at = datetime.now(timezone.utc)
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(models.Model):
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.user.username} in {self.learning_path.display_name}"
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
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
@@ -1,6 +1,5 @@
1
1
  Django
2
2
  django-model-utils
3
- django-simple-history==3.4.0
4
3
  djangorestframework
5
4
  edx-django-utils
6
5
  edx-opaque-keys
@@ -9,5 +9,4 @@ edx-django-utils
9
9
  edx-opaque-keys
10
10
  openedx-atlas
11
11
  openedx-completion-aggregator # Required for fetching course completion
12
- django-simple-history
13
12
  pillow # Required for the ImageField
@@ -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
- )