learning-paths-plugin 0.3.4rc2__tar.gz → 0.3.4rc4__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.4rc2 → learning_paths_plugin-0.3.4rc4}/CHANGELOG.rst +1 -0
  2. {learning_paths_plugin-0.3.4rc2/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.4rc4}/PKG-INFO +2 -1
  3. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/__init__.py +1 -1
  4. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/api/v1/views.py +108 -30
  5. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/receivers.py +12 -3
  6. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4/learning_paths_plugin.egg-info}/PKG-INFO +2 -1
  7. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/LICENSE.txt +0 -0
  8. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/MANIFEST.in +0 -0
  9. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/README.rst +0 -0
  10. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/admin.py +0 -0
  11. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/api/__init__.py +0 -0
  12. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/api/urls.py +0 -0
  13. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/api/v1/__init__.py +0 -0
  14. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/api/v1/filters.py +0 -0
  15. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/api/v1/permissions.py +0 -0
  16. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/api/v1/serializers.py +0 -0
  17. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/api/v1/urls.py +0 -0
  18. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/api/v1/utils.py +0 -0
  19. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/apps.py +0 -0
  20. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/compat.py +0 -0
  21. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/conftest.py +0 -0
  22. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/keys.py +0 -0
  23. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/migrations/0001_initial.py +0 -0
  24. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/migrations/0002_learningpath_uuid.py +0 -0
  25. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/migrations/0003_learningpath_subtitle.py +0 -0
  26. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/migrations/0004_auto_20240207_1633.py +0 -0
  27. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py +0 -0
  28. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/migrations/0006_enrollment_models.py +0 -0
  29. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/migrations/0007_replace_uuid_with_learningpathkey.py +0 -0
  30. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/migrations/0008_remove_learningpathstep_relative_due_date_in_days.py +0 -0
  31. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/migrations/0009_remove_learningpath_slug.py +0 -0
  32. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/migrations/0010_learningpath_invite_only.py +0 -0
  33. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/migrations/0011_replace_learningpath_image_url_with_image.py +0 -0
  34. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/migrations/0012_alter_learningpath_subtitle.py +0 -0
  35. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/migrations/0013_enrollment_audit.py +0 -0
  36. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/migrations/__init__.py +0 -0
  37. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/models.py +0 -0
  38. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/settings.py +0 -0
  39. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/templates/learning_paths/base.html +0 -0
  40. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths/urls.py +0 -0
  41. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths_plugin.egg-info/SOURCES.txt +0 -0
  42. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths_plugin.egg-info/dependency_links.txt +0 -0
  43. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths_plugin.egg-info/entry_points.txt +0 -0
  44. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths_plugin.egg-info/not-zip-safe +0 -0
  45. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths_plugin.egg-info/requires.txt +0 -0
  46. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/learning_paths_plugin.egg-info/top_level.txt +0 -0
  47. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/pyproject.toml +0 -0
  48. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/requirements/base.in +0 -0
  49. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/requirements/constraints.txt +0 -0
  50. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/setup.cfg +0 -0
  51. {learning_paths_plugin-0.3.4rc2 → learning_paths_plugin-0.3.4rc4}/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.4rc2
3
+ Version: 0.3.4rc4
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-rc2"
5
+ __version__ = "0.3.4-rc4"
@@ -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."""
@@ -44,16 +44,25 @@ def process_pending_enrollments(sender, instance, created, **kwargs):
44
44
 
45
45
  for entry in pending_enrollments:
46
46
  try:
47
- enrollment = LearningPathEnrollment(learning_path=entry.learning_path, user=instance)
48
- enrollment._audit = { # pylint: disable=protected-access
47
+ audit_data = {
49
48
  "enrolled_by": instance,
50
49
  "state_transition": LearningPathEnrollmentAudit.ALLOWEDTOENROLL_TO_ENROLLED,
51
50
  }
51
+ if last_allowed_audit := entry.audit.order_by("-created").first():
52
+ for field in ["reason", "org", "role"]:
53
+ audit_data[field] = getattr(last_allowed_audit, field, "")
54
+
55
+ enrollment = LearningPathEnrollment(learning_path=entry.learning_path, user=instance)
56
+ enrollment._audit = audit_data # pylint: disable=protected-access
52
57
  enrollment.save()
53
58
  enrollments_created += 1
59
+
60
+ # Link existing audits from the "allowed to enroll" entry to the new enrollment.
61
+ entry.audit.update(enrollment=enrollment)
62
+
54
63
  except IntegrityError: # pragma: no cover
55
64
  logger.info(
56
- "[LearningPaths] Enrollment already exists for user %s in learning path %s",
65
+ "[LearningPaths] Enrollment already exists for user %s in the learning path %s",
57
66
  instance,
58
67
  entry.learning_path.key,
59
68
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning-paths-plugin
3
- Version: 0.3.4rc2
3
+ Version: 0.3.4rc4
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