learning-paths-plugin 0.3.4rc1__tar.gz → 0.3.4rc3__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 (51) hide show
  1. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/CHANGELOG.rst +1 -0
  2. {learning_paths_plugin-0.3.4rc1/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.4rc3}/PKG-INFO +2 -1
  3. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/__init__.py +1 -1
  4. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/admin.py +25 -16
  5. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/api/v1/views.py +108 -30
  6. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3/learning_paths_plugin.egg-info}/PKG-INFO +2 -1
  7. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/LICENSE.txt +0 -0
  8. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/MANIFEST.in +0 -0
  9. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/README.rst +0 -0
  10. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/api/__init__.py +0 -0
  11. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/api/urls.py +0 -0
  12. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/api/v1/__init__.py +0 -0
  13. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/api/v1/filters.py +0 -0
  14. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/api/v1/permissions.py +0 -0
  15. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/api/v1/serializers.py +0 -0
  16. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/api/v1/urls.py +0 -0
  17. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/api/v1/utils.py +0 -0
  18. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/apps.py +0 -0
  19. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/compat.py +0 -0
  20. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/conftest.py +0 -0
  21. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/keys.py +0 -0
  22. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/migrations/0001_initial.py +0 -0
  23. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/migrations/0002_learningpath_uuid.py +0 -0
  24. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/migrations/0003_learningpath_subtitle.py +0 -0
  25. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/migrations/0004_auto_20240207_1633.py +0 -0
  26. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py +0 -0
  27. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/migrations/0006_enrollment_models.py +0 -0
  28. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/migrations/0007_replace_uuid_with_learningpathkey.py +0 -0
  29. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/migrations/0008_remove_learningpathstep_relative_due_date_in_days.py +0 -0
  30. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/migrations/0009_remove_learningpath_slug.py +0 -0
  31. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/migrations/0010_learningpath_invite_only.py +0 -0
  32. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/migrations/0011_replace_learningpath_image_url_with_image.py +0 -0
  33. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/migrations/0012_alter_learningpath_subtitle.py +0 -0
  34. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/migrations/0013_enrollment_audit.py +0 -0
  35. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/migrations/__init__.py +0 -0
  36. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/models.py +0 -0
  37. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/receivers.py +0 -0
  38. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/settings.py +0 -0
  39. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/templates/learning_paths/base.html +0 -0
  40. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths/urls.py +0 -0
  41. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths_plugin.egg-info/SOURCES.txt +0 -0
  42. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths_plugin.egg-info/dependency_links.txt +0 -0
  43. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths_plugin.egg-info/entry_points.txt +0 -0
  44. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths_plugin.egg-info/not-zip-safe +0 -0
  45. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths_plugin.egg-info/requires.txt +0 -0
  46. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/learning_paths_plugin.egg-info/top_level.txt +0 -0
  47. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/pyproject.toml +0 -0
  48. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/requirements/base.in +0 -0
  49. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/requirements/constraints.txt +0 -0
  50. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/setup.cfg +0 -0
  51. {learning_paths_plugin-0.3.4rc1 → learning_paths_plugin-0.3.4rc3}/setup.py +0 -0
@@ -22,6 +22,7 @@ Unreleased
22
22
  Added
23
23
  =====
24
24
 
25
+ * Bulk unenrollment API.
25
26
  * Enrollment audit model that tracks the enrollment state transitions.
26
27
 
27
28
  0.3.3 - 2025-05-23
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning-paths-plugin
3
- Version: 0.3.4rc1
3
+ Version: 0.3.4rc3
4
4
  Summary: Learning Paths plugin
5
5
  Home-page: https://github.com/open-craft/learning-paths-plugin
6
6
  Author: OpenCraft
@@ -126,6 +126,7 @@ Unreleased
126
126
  Added
127
127
  =====
128
128
 
129
+ * Bulk unenrollment API.
129
130
  * Enrollment audit model that tracks the enrollment state transitions.
130
131
 
131
132
  0.3.3 - 2025-05-23
@@ -2,4 +2,4 @@
2
2
  Learning Paths plugin.
3
3
  """
4
4
 
5
- __version__ = "0.3.4-rc1"
5
+ __version__ = "0.3.4-rc3"
@@ -137,6 +137,7 @@ class BulkEnrollUsersForm(forms.ModelForm):
137
137
  return users
138
138
 
139
139
 
140
+ @admin.register(LearningPath)
140
141
  class LearningPathAdmin(admin.ModelAdmin):
141
142
  """Admin for Learning Path."""
142
143
 
@@ -178,13 +179,14 @@ class LearningPathAdmin(admin.ModelAdmin):
178
179
  LearningPathEnrollment.objects.get_or_create(user=user, learning_path=form.instance)
179
180
 
180
181
 
182
+ @admin.register(Skill)
181
183
  class SkillAdmin(admin.ModelAdmin):
182
184
  """Admin for Learning Path generic skill."""
183
185
 
184
186
  model = Skill
185
187
 
186
188
 
187
- class LearningPathEnrollmentAuditInline(admin.TabularInline):
189
+ class EnrollmentAuditInline(admin.TabularInline):
188
190
  """Inline admin for LearningPathEnrollmentAudit records."""
189
191
 
190
192
  model = LearningPathEnrollmentAudit
@@ -209,7 +211,7 @@ class LearningPathEnrollmentAuditInline(admin.TabularInline):
209
211
  return False
210
212
 
211
213
 
212
- class LearningPathEnrollmentAllowedAuditInline(admin.TabularInline):
214
+ class EnrollmentAllowedAuditInline(admin.TabularInline):
213
215
  """Inline admin for LearningPathEnrollmentAudit records related to enrollment allowed."""
214
216
 
215
217
  model = LearningPathEnrollmentAudit
@@ -234,13 +236,28 @@ class LearningPathEnrollmentAllowedAuditInline(admin.TabularInline):
234
236
  return False
235
237
 
236
238
 
239
+ @admin.register(LearningPathEnrollment)
237
240
  class EnrolledUsersAdmin(admin.ModelAdmin):
238
241
  """Admin for Learning Path enrollment."""
239
242
 
240
243
  model = LearningPathEnrollment
241
244
  raw_id_fields = ("user",)
242
245
  autocomplete_fields = ["learning_path"]
243
- inlines = [LearningPathEnrollmentAuditInline]
246
+ inlines = [EnrollmentAuditInline]
247
+
248
+ list_display = [
249
+ "id",
250
+ "user",
251
+ "learning_path",
252
+ "enrolled_at",
253
+ "is_active",
254
+ ]
255
+
256
+ list_filter = [
257
+ "learning_path__key",
258
+ "created",
259
+ "is_active",
260
+ ]
244
261
 
245
262
  search_fields = [
246
263
  "id",
@@ -251,7 +268,7 @@ class EnrolledUsersAdmin(admin.ModelAdmin):
251
268
 
252
269
 
253
270
  @admin.register(LearningPathEnrollmentAllowed)
254
- class LearningPathEnrollmentAllowedAdmin(admin.ModelAdmin):
271
+ class EnrollmentAllowedAdmin(admin.ModelAdmin):
255
272
  """Admin configuration for LearningPathEnrollmentAllowed model."""
256
273
 
257
274
  list_display = [
@@ -264,7 +281,6 @@ class LearningPathEnrollmentAllowedAdmin(admin.ModelAdmin):
264
281
 
265
282
  list_filter = [
266
283
  "learning_path",
267
- "user",
268
284
  "created",
269
285
  ]
270
286
 
@@ -272,7 +288,6 @@ class LearningPathEnrollmentAllowedAdmin(admin.ModelAdmin):
272
288
  "email",
273
289
  "user__username",
274
290
  "user__email",
275
- "learning_path__title",
276
291
  "learning_path__key",
277
292
  ]
278
293
 
@@ -282,7 +297,7 @@ class LearningPathEnrollmentAllowedAdmin(admin.ModelAdmin):
282
297
  "modified",
283
298
  ]
284
299
 
285
- inlines = [LearningPathEnrollmentAllowedAuditInline]
300
+ inlines = [EnrollmentAllowedAuditInline]
286
301
 
287
302
  def get_user(self, obj):
288
303
  """Get the associated user, if any."""
@@ -292,7 +307,7 @@ class LearningPathEnrollmentAllowedAdmin(admin.ModelAdmin):
292
307
 
293
308
 
294
309
  @admin.register(LearningPathEnrollmentAudit)
295
- class LearningPathEnrollmentAuditAdmin(admin.ModelAdmin):
310
+ class EnrollmentAuditAdmin(admin.ModelAdmin):
296
311
  """Admin configuration for LearningPathEnrollmentAudit model."""
297
312
 
298
313
  list_display = [
@@ -311,7 +326,6 @@ class LearningPathEnrollmentAuditAdmin(admin.ModelAdmin):
311
326
  "created",
312
327
  "org",
313
328
  "role",
314
- "enrolled_by",
315
329
  ]
316
330
 
317
331
  search_fields = [
@@ -320,8 +334,8 @@ class LearningPathEnrollmentAuditAdmin(admin.ModelAdmin):
320
334
  "enrollment__user__username",
321
335
  "enrollment__user__email",
322
336
  "enrollment_allowed__email",
323
- "enrollment__learning_path__title",
324
- "enrollment_allowed__learning_path__title",
337
+ "enrollment__learning_path__key",
338
+ "enrollment_allowed__learning_path__key",
325
339
  "reason",
326
340
  ]
327
341
 
@@ -353,8 +367,3 @@ class LearningPathEnrollmentAuditAdmin(admin.ModelAdmin):
353
367
  return "-"
354
368
 
355
369
  get_learning_path.short_description = "Learning Path"
356
-
357
-
358
- admin.site.register(LearningPath, LearningPathAdmin)
359
- admin.site.register(Skill, SkillAdmin)
360
- admin.site.register(LearningPathEnrollment, EnrolledUsersAdmin)
@@ -9,6 +9,7 @@ from django.conf import settings
9
9
  from django.contrib.auth import get_user_model
10
10
  from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
11
11
  from django.core.validators import validate_email
12
+ from django.db.models import QuerySet
12
13
  from django.shortcuts import get_object_or_404
13
14
  from opaque_keys import InvalidKeyError
14
15
  from opaque_keys.edx.keys import CourseKey
@@ -16,6 +17,7 @@ from rest_framework import generics, status, viewsets
16
17
  from rest_framework.exceptions import NotFound, ParseError
17
18
  from rest_framework.pagination import PageNumberPagination
18
19
  from rest_framework.permissions import IsAdminUser, IsAuthenticated
20
+ from rest_framework.request import Request
19
21
  from rest_framework.response import Response
20
22
  from rest_framework.views import APIView
21
23
 
@@ -296,13 +298,53 @@ class ListEnrollmentsView(generics.ListAPIView):
296
298
 
297
299
  class BulkEnrollView(APIView):
298
300
  """
299
- Bulk enrollment API for LearningPathEnrollment.
300
-
301
+ Bulk enrollment/unenrollment API for LearningPathEnrollment.
301
302
  """
302
303
 
303
304
  permission_classes = [IsAdminUser]
304
305
 
305
- def post(self, request, *args, **kwargs):
306
+ @staticmethod
307
+ def _process_input_data(request: Request) -> tuple[list[str], list[str]]:
308
+ """Extract and validate input data from request."""
309
+ data = request.data
310
+ learning_paths_keys = data.get("learning_paths", "").split(",")
311
+ emails = data.get("emails", "").split(",")
312
+
313
+ return learning_paths_keys, emails
314
+
315
+ @staticmethod
316
+ def _validate_learning_paths(learning_paths_keys: list[str]) -> QuerySet[LearningPath]:
317
+ """Validate learning path keys and return valid ones."""
318
+ valid_learning_paths_keys = []
319
+ for key in learning_paths_keys:
320
+ try:
321
+ LearningPathKey.from_string(key)
322
+ valid_learning_paths_keys.append(key)
323
+ except InvalidKeyError:
324
+ logger.warning("BulkEnrollView: Invalid learning path key: %s", key)
325
+
326
+ return LearningPath.objects.filter(key__in=valid_learning_paths_keys)
327
+
328
+ @staticmethod
329
+ def _create_audit_data(request: Request, state_transition: str) -> dict[str, str]:
330
+ """Create audit data dictionary."""
331
+ return {
332
+ "enrolled_by": request.user,
333
+ "reason": request.data.get("reason", ""),
334
+ "org": request.data.get("org", ""),
335
+ "role": request.data.get("role", ""),
336
+ "state_transition": state_transition,
337
+ }
338
+
339
+ def _setup_bulk_operation(self, request: Request) -> tuple[QuerySet[LearningPath], QuerySet[User], list[str]]:
340
+ """Common setup for bulk operations."""
341
+ learning_paths_keys, emails = self._process_input_data(request)
342
+ learning_paths = self._validate_learning_paths(learning_paths_keys)
343
+ existing_users = User.objects.filter(email__in=emails)
344
+
345
+ return learning_paths, existing_users, emails
346
+
347
+ def post(self, request: Request, *args, **kwargs) -> Response:
306
348
  """
307
349
  Bulk Enroll learners in Learning Paths.
308
350
 
@@ -313,11 +355,17 @@ class BulkEnrollView(APIView):
313
355
 
314
356
  {
315
357
  "learning_paths": "learning_path_1,learning_path_2",
316
- "emails": "user_1@example.com,user_2@example.com"
358
+ "emails": "user_1@example.com,user_2@example.com",
359
+ "reason": "Bulk enrollment for new cohort",
360
+ "org": "organization_name",
361
+ "role": "student"
317
362
  }
318
363
 
319
364
  `learning_paths` (str): A comma separated list of learning path IDs.
320
365
  `emails` (str): A comma separated list of email addresses.
366
+ `reason` (str, optional): Reason for enrollment, used for audit.
367
+ `org` (str, optional): Organization identifier, used for audit.
368
+ `role` (str, optional): User role, used for audit.
321
369
 
322
370
  * For existing users, it creates a new LearningPathEnrollment record, automatically
323
371
  enrolling them in the learning path. It also creates a LearningPathAllowed record
@@ -326,41 +374,18 @@ class BulkEnrollView(APIView):
326
374
  with just the email address, allowing them to get enrolled when they register.
327
375
 
328
376
  """
329
- data = request.data
330
- learning_paths_keys = data.get("learning_paths", "").split(",")
331
- emails = data.get("emails", "").split(",")
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
-
341
- valid_learning_paths_keys = []
342
- for key in learning_paths_keys:
343
- try:
344
- LearningPathKey.from_string(key)
345
- valid_learning_paths_keys.append(key)
346
- except InvalidKeyError:
347
- logger.warning("BulkEnrollView: Invalid learning path key: %s", key)
348
-
349
- learning_paths = LearningPath.objects.filter(key__in=valid_learning_paths_keys)
350
-
351
- existing_users = User.objects.filter(email__in=emails)
377
+ learning_paths, existing_users, emails = self._setup_bulk_operation(request)
352
378
  non_existing_emails = set(emails) - set(u.email for u in existing_users)
353
379
 
354
380
  enrollments_created = []
355
381
  enrollment_allowed_created = []
356
382
 
357
383
  for learning_path in learning_paths:
358
-
359
384
  # Create LearningPathEnrollment for existing users
360
385
  for user in existing_users:
361
386
  enrollment = LearningPathEnrollment.objects.filter(user=user, learning_path=learning_path).first()
362
387
  enrolled_now = False
363
- audit_data["state_transition"] = LearningPathEnrollmentAudit.UNENROLLED_TO_ENROLLED
388
+ audit_data = self._create_audit_data(request, LearningPathEnrollmentAudit.UNENROLLED_TO_ENROLLED)
364
389
  if enrollment:
365
390
  if not enrollment.is_active:
366
391
  enrollment.is_active = True
@@ -391,7 +416,7 @@ class BulkEnrollView(APIView):
391
416
  if created:
392
417
  enrollment_allowed_created.append(allowed)
393
418
 
394
- audit_data["state_transition"] = LearningPathEnrollmentAudit.UNENROLLED_TO_ALLOWEDTOENROLL
419
+ audit_data = self._create_audit_data(request, LearningPathEnrollmentAudit.UNENROLLED_TO_ALLOWEDTOENROLL)
395
420
  allowed._audit = audit_data # pylint: disable=protected-access
396
421
  allowed.save()
397
422
 
@@ -403,6 +428,59 @@ class BulkEnrollView(APIView):
403
428
  status=status.HTTP_201_CREATED,
404
429
  )
405
430
 
431
+ def delete(self, request, *args, **kwargs) -> Response:
432
+ """
433
+ Bulk Unenroll learners from Learning Paths.
434
+
435
+ The "bulk unenroll" API provides a way for the staff to unenroll multiple learners
436
+ from multiple learning paths at once.
437
+
438
+ Example payload::
439
+
440
+ {
441
+ "learning_paths": "learning_path_1,learning_path_2",
442
+ "emails": "user_1@example.com,user_2@example.com",
443
+ "reason": "End of semester cleanup",
444
+ "org": "organization_name",
445
+ "role": "student"
446
+ }
447
+
448
+ `learning_paths` (str): A comma separated list of learning path IDs.
449
+ `emails` (str): A comma separated list of email addresses.
450
+ `reason` (str, optional): Reason for unenrollment, used for audit.
451
+ `org` (str, optional): Organization identifier, used for audit.
452
+ `role` (str, optional): User role, used for audit.
453
+
454
+ * For existing users, it deactivates their LearningPathEnrollment records.
455
+ * Does not affect LearningPathEnrollmentAllowed records (allowed to enroll records).
456
+
457
+ """
458
+ learning_paths, existing_users, _ = self._setup_bulk_operation(request)
459
+
460
+ enrollments_unenrolled = []
461
+
462
+ for learning_path in learning_paths:
463
+ for user in existing_users:
464
+ enrollment = LearningPathEnrollment.objects.filter(user=user, learning_path=learning_path).first()
465
+
466
+ if enrollment:
467
+ if enrollment.is_active:
468
+ state_transition = LearningPathEnrollmentAudit.ENROLLED_TO_UNENROLLED
469
+ enrollment.is_active = False
470
+ enrollments_unenrolled.append(enrollment)
471
+ else:
472
+ state_transition = LearningPathEnrollmentAudit.UNENROLLED_TO_UNENROLLED
473
+ audit_data = self._create_audit_data(request, state_transition)
474
+ enrollment._audit = audit_data # pylint: disable=protected-access
475
+ enrollment.save()
476
+
477
+ return Response(
478
+ {
479
+ "enrollments_unenrolled": len(enrollments_unenrolled),
480
+ },
481
+ status=status.HTTP_204_NO_CONTENT,
482
+ )
483
+
406
484
 
407
485
  class LearningPathCourseEnrollmentView(APIView):
408
486
  """API View to enroll a user in a course that's part of a learning path."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning-paths-plugin
3
- Version: 0.3.4rc1
3
+ Version: 0.3.4rc3
4
4
  Summary: Learning Paths plugin
5
5
  Home-page: https://github.com/open-craft/learning-paths-plugin
6
6
  Author: OpenCraft
@@ -126,6 +126,7 @@ Unreleased
126
126
  Added
127
127
  =====
128
128
 
129
+ * Bulk unenrollment API.
129
130
  * Enrollment audit model that tracks the enrollment state transitions.
130
131
 
131
132
  0.3.3 - 2025-05-23