learning-paths-plugin 0.3.3__tar.gz → 0.3.4rc8__tar.gz

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