learning-paths-plugin 0.3.2__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 (55) hide show
  1. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/CHANGELOG.rst +16 -0
  2. {learning_paths_plugin-0.3.2/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.4rc1}/PKG-INFO +17 -2
  3. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/__init__.py +1 -1
  4. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/admin.py +162 -12
  5. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/serializers.py +3 -9
  6. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/urls.py +1 -3
  7. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/utils.py +2 -6
  8. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/views.py +33 -31
  9. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/compat.py +1 -3
  10. learning_paths_plugin-0.3.4rc1/learning_paths/conftest.py +51 -0
  11. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/keys.py +3 -9
  12. learning_paths_plugin-0.3.4rc1/learning_paths/migrations/0006_enrollment_models.py +39 -0
  13. learning_paths_plugin-0.3.4rc1/learning_paths/migrations/0013_enrollment_audit.py +158 -0
  14. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/models.py +65 -29
  15. learning_paths_plugin-0.3.4rc1/learning_paths/receivers.py +127 -0
  16. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1/learning_paths_plugin.egg-info}/PKG-INFO +17 -2
  17. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/SOURCES.txt +1 -0
  18. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/requires.txt +0 -1
  19. learning_paths_plugin-0.3.4rc1/pyproject.toml +16 -0
  20. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/requirements/base.in +0 -1
  21. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/requirements/constraints.txt +1 -6
  22. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/setup.py +8 -26
  23. learning_paths_plugin-0.3.2/learning_paths/conftest.py +0 -13
  24. learning_paths_plugin-0.3.2/learning_paths/migrations/0006_enrollment_models.py +0 -66
  25. learning_paths_plugin-0.3.2/learning_paths/receivers.py +0 -52
  26. learning_paths_plugin-0.3.2/pyproject.toml +0 -9
  27. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/LICENSE.txt +0 -0
  28. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/MANIFEST.in +0 -0
  29. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/README.rst +0 -0
  30. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/__init__.py +0 -0
  31. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/urls.py +0 -0
  32. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/__init__.py +0 -0
  33. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/filters.py +0 -0
  34. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/api/v1/permissions.py +0 -0
  35. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/apps.py +0 -0
  36. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0001_initial.py +0 -0
  37. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0002_learningpath_uuid.py +0 -0
  38. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0003_learningpath_subtitle.py +0 -0
  39. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0004_auto_20240207_1633.py +0 -0
  40. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py +0 -0
  41. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0007_replace_uuid_with_learningpathkey.py +0 -0
  42. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0008_remove_learningpathstep_relative_due_date_in_days.py +0 -0
  43. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0009_remove_learningpath_slug.py +0 -0
  44. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0010_learningpath_invite_only.py +0 -0
  45. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0011_replace_learningpath_image_url_with_image.py +0 -0
  46. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/0012_alter_learningpath_subtitle.py +0 -0
  47. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/migrations/__init__.py +0 -0
  48. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/settings.py +0 -0
  49. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/templates/learning_paths/base.html +0 -0
  50. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths/urls.py +0 -0
  51. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/dependency_links.txt +0 -0
  52. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/entry_points.txt +0 -0
  53. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/not-zip-safe +0 -0
  54. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/learning_paths_plugin.egg-info/top_level.txt +0 -0
  55. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4rc1}/setup.cfg +0 -0
@@ -16,6 +16,22 @@ 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
+
27
+ 0.3.3 - 2025-05-23
28
+ ******************
29
+
30
+ Changed
31
+ =======
32
+
33
+ * Changed line length from 80 to 120 characters.
34
+
19
35
  0.3.2 - 2025-05-02
20
36
  ******************
21
37
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning-paths-plugin
3
- Version: 0.3.2
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,22 @@ 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
+
131
+ 0.3.3 - 2025-05-23
132
+ ******************
133
+
134
+ Changed
135
+ =======
136
+
137
+ * Changed line length from 80 to 120 characters.
138
+
124
139
  0.3.2 - 2025-05-02
125
140
  ******************
126
141
 
@@ -2,4 +2,4 @@
2
2
  Learning Paths plugin.
3
3
  """
4
4
 
5
- __version__ = "0.3.2"
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,
@@ -65,9 +67,7 @@ class LearningPathStepForm(forms.ModelForm):
65
67
  """Lazily fetch course keys to avoid calling compat code in all environments."""
66
68
  super().__init__(*args, **kwargs)
67
69
  self._course_keys = get_course_keys_with_outlines()
68
- self.fields["course_key"].widget = CourseKeyDatalistWidget(
69
- choices=self._course_keys
70
- )
70
+ self.fields["course_key"].widget = CourseKeyDatalistWidget(choices=self._course_keys)
71
71
 
72
72
  course_key = forms.CharField(label=_("Course"))
73
73
 
@@ -77,9 +77,7 @@ class LearningPathStepForm(forms.ModelForm):
77
77
  valid_keys = {str(key).strip() for key in self._course_keys}
78
78
 
79
79
  if course_key not in valid_keys:
80
- raise ValidationError(
81
- _("Invalid course key. Please select a course from the suggestions.")
82
- )
80
+ raise ValidationError(_("Invalid course key. Please select a course from the suggestions."))
83
81
 
84
82
  return course_key
85
83
 
@@ -135,9 +133,7 @@ class BulkEnrollUsersForm(forms.ModelForm):
135
133
  found_usernames = list(users.values_list("username", flat=True))
136
134
  invalid_usernames = set(usernames) - set(found_usernames)
137
135
  if invalid_usernames:
138
- raise ValidationError(
139
- f"The following usernames are not valid: {', '.join(invalid_usernames)}"
140
- )
136
+ raise ValidationError(f"The following usernames are not valid: {', '.join(invalid_usernames)}")
141
137
  return users
142
138
 
143
139
 
@@ -179,9 +175,7 @@ class LearningPathAdmin(admin.ModelAdmin):
179
175
  super().save_related(request, form, formsets, change)
180
176
  with transaction.atomic():
181
177
  for user in form.cleaned_data["usernames"]:
182
- LearningPathEnrollment.objects.get_or_create(
183
- user=user, learning_path=form.instance
184
- )
178
+ LearningPathEnrollment.objects.get_or_create(user=user, learning_path=form.instance)
185
179
 
186
180
 
187
181
  class SkillAdmin(admin.ModelAdmin):
@@ -190,12 +184,63 @@ class SkillAdmin(admin.ModelAdmin):
190
184
  model = Skill
191
185
 
192
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
+
193
237
  class EnrolledUsersAdmin(admin.ModelAdmin):
194
238
  """Admin for Learning Path enrollment."""
195
239
 
196
240
  model = LearningPathEnrollment
197
241
  raw_id_fields = ("user",)
198
242
  autocomplete_fields = ["learning_path"]
243
+ inlines = [LearningPathEnrollmentAuditInline]
199
244
 
200
245
  search_fields = [
201
246
  "id",
@@ -205,6 +250,111 @@ class EnrolledUsersAdmin(admin.ModelAdmin):
205
250
  ]
206
251
 
207
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
+
208
358
  admin.site.register(LearningPath, LearningPathAdmin)
209
359
  admin.site.register(Skill, SkillAdmin)
210
360
  admin.site.register(LearningPathEnrollment, EnrolledUsersAdmin)
@@ -104,9 +104,7 @@ class LearningPathListSerializer(serializers.ModelSerializer):
104
104
  """Serializer for the learning path list."""
105
105
 
106
106
  steps = LearningPathStepSerializer(many=True, read_only=True)
107
- required_completion = serializers.FloatField(
108
- source="grading_criteria.required_completion", read_only=True
109
- )
107
+ required_completion = serializers.FloatField(source="grading_criteria.required_completion", read_only=True)
110
108
  is_enrolled = serializers.SerializerMethodField()
111
109
  invite_only = serializers.BooleanField()
112
110
  image = serializers.ImageField(read_only=True)
@@ -168,12 +166,8 @@ class LearningPathDetailSerializer(LearningPathListSerializer):
168
166
  Serializer for learning path details.
169
167
  """
170
168
 
171
- required_skills = RequiredSkillSerializer(
172
- source="requiredskill_set", many=True, read_only=True
173
- )
174
- acquired_skills = AcquiredSkillSerializer(
175
- source="acquiredskill_set", many=True, read_only=True
176
- )
169
+ required_skills = RequiredSkillSerializer(source="requiredskill_set", many=True, read_only=True)
170
+ acquired_skills = AcquiredSkillSerializer(source="acquiredskill_set", many=True, read_only=True)
177
171
 
178
172
  class Meta(LearningPathListSerializer.Meta):
179
173
  fields = LearningPathListSerializer.Meta.fields + [
@@ -16,9 +16,7 @@ from learning_paths.api.v1.views import (
16
16
  from learning_paths.keys import COURSE_KEY_URL_PATTERN, LEARNING_PATH_URL_PATTERN
17
17
 
18
18
  router = routers.SimpleRouter()
19
- router.register(
20
- r"programs", LearningPathAsProgramViewSet, basename="learning-path-as-program"
21
- )
19
+ router.register(r"programs", LearningPathAsProgramViewSet, basename="learning-path-as-program")
22
20
  router.register(r"learning-paths", LearningPathViewSet, basename="learning-path")
23
21
 
24
22
  urlpatterns = router.urls + [
@@ -29,9 +29,7 @@ def get_course_completion(username: str, course_key: CourseKey, client: Any) ->
29
29
  if err.response.status_code == 404:
30
30
  return 0.0
31
31
  else:
32
- raise APIException(
33
- f"Error fetching completion for course {course_id}: {err}"
34
- ) from err
32
+ raise APIException(f"Error fetching completion for course {course_id}: {err}") from err
35
33
 
36
34
  if data and data.get("results"):
37
35
  return data["results"][0]["completion"]["percent"]
@@ -51,9 +49,7 @@ def get_aggregate_progress(user, learning_path):
51
49
  total_completion = 0.0
52
50
 
53
51
  for step in steps:
54
- course_completion = get_course_completion(
55
- user.username, step.course_key, client
56
- )
52
+ course_completion = get_course_completion(user.username, step.course_key, client)
57
53
  total_completion += course_completion
58
54
 
59
55
  total_courses = len(steps)
@@ -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
@@ -152,9 +153,7 @@ class LearningPathViewSet(viewsets.ReadOnlyModelViewSet):
152
153
  Get all learning paths and prefetch the related data.
153
154
  """
154
155
  user = self.request.user
155
- queryset = LearningPath.objects.get_paths_visible_to_user(
156
- user
157
- ).prefetch_related(
156
+ queryset = LearningPath.objects.get_paths_visible_to_user(user).prefetch_related(
158
157
  "steps",
159
158
  "grading_criteria",
160
159
  )
@@ -203,9 +202,7 @@ class LearningPathEnrollmentView(APIView):
203
202
  """
204
203
  learning_path = self._get_learning_path(learning_path_key_str)
205
204
 
206
- enrollments = LearningPathEnrollment.objects.filter(
207
- learning_path=learning_path, is_active=True
208
- )
205
+ enrollments = LearningPathEnrollment.objects.filter(learning_path=learning_path, is_active=True)
209
206
 
210
207
  if request.user.is_staff:
211
208
  if username := request.query_params.get("username"):
@@ -233,18 +230,14 @@ class LearningPathEnrollmentView(APIView):
233
230
  username = request.data.get("username")
234
231
  user = get_object_or_404(User, username=username) if username else request.user
235
232
 
236
- enrollment, created = LearningPathEnrollment.objects.get_or_create(
237
- learning_path=learning_path, user=user
238
- )
233
+ enrollment, created = LearningPathEnrollment.objects.get_or_create(learning_path=learning_path, user=user)
239
234
  if created:
240
235
  return Response(
241
236
  LearningPathEnrollmentSerializer(enrollment).data,
242
237
  status=status.HTTP_201_CREATED,
243
238
  )
244
239
  if enrollment.is_active:
245
- return Response(
246
- {"detail": "Enrollment exists."}, status=status.HTTP_409_CONFLICT
247
- )
240
+ return Response({"detail": "Enrollment exists."}, status=status.HTTP_409_CONFLICT)
248
241
 
249
242
  enrollment.is_active = True
250
243
  enrollment.enrolled_at = datetime.now(timezone.utc)
@@ -276,10 +269,7 @@ class LearningPathEnrollmentView(APIView):
276
269
  user=user,
277
270
  )
278
271
 
279
- if (
280
- not request.user.is_staff
281
- and not settings.LEARNING_PATHS_ALLOW_SELF_UNENROLLMENT
282
- ):
272
+ if not request.user.is_staff and not settings.LEARNING_PATHS_ALLOW_SELF_UNENROLLMENT:
283
273
  raise PermissionDenied
284
274
 
285
275
  enrollment.is_active = False
@@ -340,6 +330,14 @@ class BulkEnrollView(APIView):
340
330
  learning_paths_keys = data.get("learning_paths", "").split(",")
341
331
  emails = data.get("emails", "").split(",")
342
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
+
343
341
  valid_learning_paths_keys = []
344
342
  for key in learning_paths_keys:
345
343
  try:
@@ -360,20 +358,22 @@ class BulkEnrollView(APIView):
360
358
 
361
359
  # Create LearningPathEnrollment for existing users
362
360
  for user in existing_users:
363
- enrollment = LearningPathEnrollment.objects.filter(
364
- user=user, learning_path=learning_path
365
- ).first()
361
+ enrollment = LearningPathEnrollment.objects.filter(user=user, learning_path=learning_path).first()
366
362
  enrolled_now = False
367
- if not enrollment:
368
- enrollment = LearningPathEnrollment(
369
- user=user,
370
- learning_path=learning_path,
371
- )
372
- enrolled_now = True
373
- if not enrollment.is_active:
374
- enrollment.is_active = True
375
- 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)
376
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
377
377
  enrollment.save()
378
378
  if enrolled_now:
379
379
  enrollments_created.append(enrollment)
@@ -391,6 +391,10 @@ class BulkEnrollView(APIView):
391
391
  if created:
392
392
  enrollment_allowed_created.append(allowed)
393
393
 
394
+ audit_data["state_transition"] = LearningPathEnrollmentAudit.UNENROLLED_TO_ALLOWEDTOENROLL
395
+ allowed._audit = audit_data # pylint: disable=protected-access
396
+ allowed.save()
397
+
394
398
  return Response(
395
399
  {
396
400
  "enrollments_created": len(enrollments_created),
@@ -412,9 +416,7 @@ class LearningPathCourseEnrollmentView(APIView):
412
416
  :raises: Http404 if the learning path is not found or the user does not have access.
413
417
  """
414
418
  return get_object_or_404(
415
- LearningPath.objects.get_paths_visible_to_user(self.request.user).filter(
416
- is_enrolled=True
417
- ),
419
+ LearningPath.objects.get_paths_visible_to_user(self.request.user).filter(is_enrolled=True),
418
420
  key=learning_path_key_str,
419
421
  )
420
422
 
@@ -72,7 +72,5 @@ def enroll_user_in_course(user: AbstractBaseUser, course_key: CourseKey) -> bool
72
72
  CourseEnrollment.enroll(user, course_key)
73
73
  return True
74
74
  except CourseEnrollmentException as exc:
75
- log.exception(
76
- "Failed to enroll user %s in course %s: %s", user, course_key, exc
77
- )
75
+ log.exception("Failed to enroll user %s in course %s: %s", user, course_key, exc)
78
76
  return False
@@ -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
@@ -12,15 +12,11 @@ from opaque_keys.edx.keys import LearningContextKey
12
12
 
13
13
  COURSE_KEY_NAMESPACE = "course-v1"
14
14
  COURSE_KEY_PATTERN = r"([^+]+)\+([^+]+)\+([^+]+)"
15
- COURSE_KEY_URL_PATTERN = (
16
- rf"(?P<course_key_str>{COURSE_KEY_NAMESPACE}:{COURSE_KEY_PATTERN})"
17
- )
15
+ COURSE_KEY_URL_PATTERN = rf"(?P<course_key_str>{COURSE_KEY_NAMESPACE}:{COURSE_KEY_PATTERN})"
18
16
 
19
17
  LEARNING_PATH_NAMESPACE = "path-v1"
20
18
  LEARNING_PATH_PATTERN = r"([^+]+)\+([^+]+)\+([^+]+)\+([^+]+)"
21
- LEARNING_PATH_URL_PATTERN = (
22
- rf"(?P<learning_path_key_str>{LEARNING_PATH_NAMESPACE}:{LEARNING_PATH_PATTERN})"
23
- )
19
+ LEARNING_PATH_URL_PATTERN = rf"(?P<learning_path_key_str>{LEARNING_PATH_NAMESPACE}:{LEARNING_PATH_PATTERN})"
24
20
 
25
21
 
26
22
  class LearningPathKey(LearningContextKey):
@@ -52,9 +48,7 @@ class LearningPathKey(LearningContextKey):
52
48
 
53
49
  def _to_string(self) -> str:
54
50
  """Return a string representing this key."""
55
- return "+".join(
56
- [self.org, self.number, self.run, self.group] # pylint: disable=no-member
57
- )
51
+ return "+".join([self.org, self.number, self.run, self.group]) # pylint: disable=no-member
58
52
 
59
53
 
60
54
  class LearningPathKeyField(LearningContextKeyField):
@@ -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
+ ]