learning-paths-plugin 0.3.2__tar.gz → 0.3.4__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 (58) hide show
  1. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/CHANGELOG.rst +27 -0
  2. {learning_paths_plugin-0.3.2/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.4}/PKG-INFO +29 -2
  3. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/__init__.py +1 -1
  4. learning_paths_plugin-0.3.4/learning_paths/admin.py +434 -0
  5. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/api/v1/serializers.py +13 -18
  6. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/api/v1/urls.py +1 -3
  7. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/api/v1/utils.py +2 -6
  8. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/api/v1/views.py +157 -54
  9. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/compat.py +5 -9
  10. learning_paths_plugin-0.3.4/learning_paths/conftest.py +51 -0
  11. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/keys.py +3 -9
  12. learning_paths_plugin-0.3.4/learning_paths/migrations/0006_enrollment_models.py +39 -0
  13. learning_paths_plugin-0.3.4/learning_paths/migrations/0013_enrollment_audit.py +169 -0
  14. learning_paths_plugin-0.3.4/learning_paths/migrations/0014_remove_learningpath_duration_in_days_and_more.py +59 -0
  15. learning_paths_plugin-0.3.4/learning_paths/migrations/0015_make_skill_level_optional.py +27 -0
  16. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/models.py +108 -57
  17. learning_paths_plugin-0.3.4/learning_paths/receivers.py +139 -0
  18. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4/learning_paths_plugin.egg-info}/PKG-INFO +29 -2
  19. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths_plugin.egg-info/SOURCES.txt +3 -0
  20. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths_plugin.egg-info/requires.txt +1 -1
  21. learning_paths_plugin-0.3.4/pyproject.toml +16 -0
  22. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/requirements/base.in +1 -1
  23. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/requirements/constraints.txt +1 -6
  24. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/setup.py +8 -26
  25. learning_paths_plugin-0.3.2/learning_paths/admin.py +0 -210
  26. learning_paths_plugin-0.3.2/learning_paths/conftest.py +0 -13
  27. learning_paths_plugin-0.3.2/learning_paths/migrations/0006_enrollment_models.py +0 -66
  28. learning_paths_plugin-0.3.2/learning_paths/receivers.py +0 -52
  29. learning_paths_plugin-0.3.2/pyproject.toml +0 -9
  30. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/LICENSE.txt +0 -0
  31. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/MANIFEST.in +0 -0
  32. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/README.rst +0 -0
  33. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/api/__init__.py +0 -0
  34. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/api/urls.py +0 -0
  35. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/api/v1/__init__.py +0 -0
  36. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/api/v1/filters.py +0 -0
  37. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/api/v1/permissions.py +0 -0
  38. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/apps.py +0 -0
  39. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/0001_initial.py +0 -0
  40. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/0002_learningpath_uuid.py +0 -0
  41. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/0003_learningpath_subtitle.py +0 -0
  42. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/0004_auto_20240207_1633.py +0 -0
  43. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py +0 -0
  44. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/0007_replace_uuid_with_learningpathkey.py +0 -0
  45. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/0008_remove_learningpathstep_relative_due_date_in_days.py +0 -0
  46. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/0009_remove_learningpath_slug.py +0 -0
  47. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/0010_learningpath_invite_only.py +0 -0
  48. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/0011_replace_learningpath_image_url_with_image.py +0 -0
  49. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/0012_alter_learningpath_subtitle.py +0 -0
  50. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/__init__.py +0 -0
  51. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/settings.py +0 -0
  52. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/templates/learning_paths/base.html +0 -0
  53. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/urls.py +0 -0
  54. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths_plugin.egg-info/dependency_links.txt +0 -0
  55. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths_plugin.egg-info/entry_points.txt +0 -0
  56. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths_plugin.egg-info/not-zip-safe +0 -0
  57. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths_plugin.egg-info/top_level.txt +0 -0
  58. {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/setup.cfg +0 -0
@@ -16,6 +16,33 @@ Unreleased
16
16
 
17
17
  *
18
18
 
19
+ 0.3.4 - 2025-08-02
20
+ ******************
21
+
22
+ Added
23
+ =====
24
+
25
+ * Bulk unenrollment API.
26
+ * Enrollment audit model that tracks the enrollment state transitions.
27
+ * Allow specifying time commitment.
28
+ * Allow duplicating Learning Paths in the Django admin interface.
29
+
30
+ Changed
31
+ =======
32
+
33
+ * The Learning Paths API includes start and end dates for its steps.
34
+ * Return enrollment date in the API instead of a boolean.
35
+ * Allow specifying any text for the duration.
36
+ * Make the skill level optional.
37
+
38
+ 0.3.3 - 2025-05-23
39
+ ******************
40
+
41
+ Changed
42
+ =======
43
+
44
+ * Changed line length from 80 to 120 characters.
45
+
19
46
  0.3.2 - 2025-05-02
20
47
  ******************
21
48
 
@@ -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.4
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,7 @@ 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
21
+ Requires-Dist: django-object-actions
22
22
  Requires-Dist: djangorestframework
23
23
  Requires-Dist: edx-django-utils
24
24
  Requires-Dist: edx-opaque-keys
@@ -121,6 +121,33 @@ Unreleased
121
121
 
122
122
  *
123
123
 
124
+ 0.3.4 - 2025-08-02
125
+ ******************
126
+
127
+ Added
128
+ =====
129
+
130
+ * Bulk unenrollment API.
131
+ * Enrollment audit model that tracks the enrollment state transitions.
132
+ * Allow specifying time commitment.
133
+ * Allow duplicating Learning Paths in the Django admin interface.
134
+
135
+ Changed
136
+ =======
137
+
138
+ * The Learning Paths API includes start and end dates for its steps.
139
+ * Return enrollment date in the API instead of a boolean.
140
+ * Allow specifying any text for the duration.
141
+ * Make the skill level optional.
142
+
143
+ 0.3.3 - 2025-05-23
144
+ ******************
145
+
146
+ Changed
147
+ =======
148
+
149
+ * Changed line length from 80 to 120 characters.
150
+
124
151
  0.3.2 - 2025-05-02
125
152
  ******************
126
153
 
@@ -2,4 +2,4 @@
2
2
  Learning Paths plugin.
3
3
  """
4
4
 
5
- __version__ = "0.3.2"
5
+ __version__ = "0.3.4"
@@ -0,0 +1,434 @@
1
+ """
2
+ Django Admin for learning_paths.
3
+ """
4
+
5
+ import os
6
+
7
+ from django import forms
8
+ from django.contrib import admin, auth, messages
9
+ from django.core.exceptions import ValidationError
10
+ from django.core.files.base import ContentFile
11
+ from django.db import transaction
12
+ from django.http import HttpResponseRedirect
13
+ from django.urls import reverse
14
+ from django.utils.translation import gettext_lazy as _
15
+ from django_object_actions import DjangoObjectActions, action
16
+
17
+ from .compat import get_course_keys_with_outlines
18
+ from .models import (
19
+ AcquiredSkill,
20
+ LearningPath,
21
+ LearningPathEnrollment,
22
+ LearningPathEnrollmentAllowed,
23
+ LearningPathEnrollmentAudit,
24
+ LearningPathGradingCriteria,
25
+ LearningPathStep,
26
+ RequiredSkill,
27
+ Skill,
28
+ )
29
+
30
+ User = auth.get_user_model()
31
+
32
+
33
+ def get_course_keys_choices():
34
+ """Get course keys in an adequate format for a choice field."""
35
+ yield None, ""
36
+ for key in get_course_keys_with_outlines():
37
+ yield key, key
38
+
39
+
40
+ class CourseKeyDatalistWidget(forms.TextInput):
41
+ """A widget that provides a datalist for course keys."""
42
+
43
+ def __init__(self, choices=None, attrs=None):
44
+ """Initialize the widget with a datalist and apply styles."""
45
+ attrs = attrs or {}
46
+ attrs.update(
47
+ {
48
+ "style": "width: 30em;",
49
+ "class": "form-control datalist-input",
50
+ "placeholder": _("Type to search courses..."),
51
+ }
52
+ )
53
+ super().__init__(attrs)
54
+ self.choices = choices or []
55
+
56
+ def render(self, name, value, attrs=None, renderer=None):
57
+ """Render the widget with a datalist."""
58
+ final_attrs = attrs or {}
59
+ data_list_id = f"datalist_{name}"
60
+ final_attrs["list"] = data_list_id
61
+
62
+ text_input_html = super().render(name, value, attrs, renderer)
63
+ data_list_id = f"datalist_{name}"
64
+ options = "\n".join(f'<option value="{choice}" />' for choice in self.choices)
65
+ datalist_html = f'<datalist id="{data_list_id}">\n{options}\n</datalist>'
66
+ return f"{text_input_html}\n{datalist_html}"
67
+
68
+
69
+ class LearningPathStepForm(forms.ModelForm):
70
+ """Form for Learning Path step."""
71
+
72
+ def __init__(self, *args, **kwargs):
73
+ """Lazily fetch course keys to avoid calling compat code in all environments."""
74
+ super().__init__(*args, **kwargs)
75
+ self._course_keys = get_course_keys_with_outlines()
76
+ self.fields["course_key"].widget = CourseKeyDatalistWidget(choices=self._course_keys)
77
+
78
+ course_key = forms.CharField(label=_("Course"))
79
+
80
+ def clean_course_key(self):
81
+ """Validate that the course key is on the list of available course keys."""
82
+ course_key = self.cleaned_data.get("course_key")
83
+ valid_keys = {str(key).strip() for key in self._course_keys}
84
+
85
+ if course_key not in valid_keys:
86
+ raise ValidationError(_("Invalid course key. Please select a course from the suggestions."))
87
+
88
+ return course_key
89
+
90
+
91
+ class LearningPathStepInline(admin.TabularInline):
92
+ """Inline Admin for Learning Path step."""
93
+
94
+ model = LearningPathStep
95
+ form = LearningPathStepForm
96
+
97
+
98
+ class AcquiredSkillInline(admin.TabularInline):
99
+ """Inline Admin for Learning Path acquired skill."""
100
+
101
+ model = AcquiredSkill
102
+
103
+
104
+ class RequiredSkillInline(admin.TabularInline):
105
+ """Inline Admin for Learning Path required skill."""
106
+
107
+ model = RequiredSkill
108
+
109
+
110
+ class LearningPathGradingCriteriaInline(admin.TabularInline):
111
+ """Inline Admin for Learning path grading criteria."""
112
+
113
+ model = LearningPathGradingCriteria
114
+ verbose_name = "Certificate Criteria"
115
+
116
+
117
+ class BulkEnrollUsersForm(forms.ModelForm):
118
+ """Form to bulk enroll users in a learning path."""
119
+
120
+ usernames = forms.CharField(
121
+ widget=forms.Textarea,
122
+ help_text="Enter usernames separated by newlines",
123
+ label="Bulk enroll users",
124
+ required=False,
125
+ )
126
+
127
+ class Meta:
128
+ """Form options."""
129
+
130
+ model = LearningPath
131
+ fields = "__all__"
132
+
133
+ def clean_usernames(self):
134
+ """Validate usernames and return a list of users."""
135
+ data = self.cleaned_data["usernames"]
136
+ if not data:
137
+ return []
138
+ usernames = [username.strip() for username in data.split("\n")]
139
+ users = User.objects.filter(username__in=usernames)
140
+ found_usernames = list(users.values_list("username", flat=True))
141
+ invalid_usernames = set(usernames) - set(found_usernames)
142
+ if invalid_usernames:
143
+ raise ValidationError(f"The following usernames are not valid: {', '.join(invalid_usernames)}")
144
+ return users
145
+
146
+
147
+ @admin.register(LearningPath)
148
+ class LearningPathAdmin(DjangoObjectActions, admin.ModelAdmin):
149
+ """Admin for Learning Path."""
150
+
151
+ model = LearningPath
152
+ form = BulkEnrollUsersForm
153
+
154
+ search_fields = [
155
+ "display_name",
156
+ "key",
157
+ ]
158
+ list_display = (
159
+ "key",
160
+ "display_name",
161
+ "level",
162
+ "duration",
163
+ "invite_only",
164
+ )
165
+ list_filter = ("invite_only",)
166
+ readonly_fields = ("key",)
167
+
168
+ inlines = [
169
+ LearningPathStepInline,
170
+ RequiredSkillInline,
171
+ AcquiredSkillInline,
172
+ LearningPathGradingCriteriaInline,
173
+ ]
174
+
175
+ change_actions = ("duplicate_learning_path",)
176
+
177
+ def get_readonly_fields(self, request, obj=None):
178
+ """Make key read-only only for existing objects."""
179
+ if obj: # Editing an existing object.
180
+ return self.readonly_fields
181
+ return () # Allow all fields during creation.
182
+
183
+ def save_related(self, request, form, formsets, change):
184
+ """Save related objects and enroll users in the learning path."""
185
+ super().save_related(request, form, formsets, change)
186
+ with transaction.atomic():
187
+ for user in form.cleaned_data["usernames"]:
188
+ LearningPathEnrollment.objects.get_or_create(user=user, learning_path=form.instance)
189
+
190
+ @action(label="Duplicate Learning Path", description="Create a copy of this Learning Path")
191
+ def duplicate_learning_path(self, request, obj: LearningPath) -> HttpResponseRedirect:
192
+ """Duplicate the learning path with a new unique key."""
193
+ base_new_key = f"{str(obj.key)}_copy"
194
+ new_key = base_new_key
195
+ counter = 1
196
+
197
+ while LearningPath.objects.filter(key=new_key).exists():
198
+ new_key = f"{base_new_key}_{counter}"
199
+ counter += 1
200
+
201
+ with transaction.atomic():
202
+ new_learning_path = LearningPath(
203
+ key=new_key,
204
+ display_name=f"{obj.display_name} (Copy)",
205
+ subtitle=obj.subtitle,
206
+ description=obj.description,
207
+ level=obj.level,
208
+ duration=obj.duration,
209
+ time_commitment=obj.time_commitment,
210
+ sequential=obj.sequential,
211
+ invite_only=obj.invite_only,
212
+ )
213
+
214
+ if obj.image:
215
+ with obj.image.open("rb") as original_file:
216
+ image_content = original_file.read()
217
+
218
+ original_filename = os.path.basename(obj.image.name)
219
+ new_learning_path.image.save(original_filename, ContentFile(image_content), save=False)
220
+
221
+ new_learning_path.save()
222
+
223
+ new_learning_path.refresh_from_db()
224
+ new_learning_path.grading_criteria.required_completion = obj.grading_criteria.required_completion
225
+ new_learning_path.grading_criteria.required_grade = obj.grading_criteria.required_grade
226
+ new_learning_path.grading_criteria.save()
227
+
228
+ for step in obj.steps.all():
229
+ step.pk = None
230
+ step.learning_path = new_learning_path
231
+ step.save()
232
+
233
+ for skill in obj.requiredskill_set.all():
234
+ skill.pk = None
235
+ skill.learning_path = new_learning_path
236
+ skill.save()
237
+
238
+ for skill in obj.acquiredskill_set.all():
239
+ skill.pk = None
240
+ skill.learning_path = new_learning_path
241
+ skill.save()
242
+
243
+ messages.success(request, f"Learning path duplicated successfully. New key: {new_key}")
244
+ return HttpResponseRedirect(reverse("admin:learning_paths_learningpath_change", args=[new_learning_path.pk]))
245
+
246
+
247
+ @admin.register(Skill)
248
+ class SkillAdmin(admin.ModelAdmin):
249
+ """Admin for Learning Path generic skill."""
250
+
251
+ model = Skill
252
+
253
+
254
+ class EnrollmentAuditInline(admin.TabularInline):
255
+ """Inline admin for LearningPathEnrollmentAudit records."""
256
+
257
+ model = LearningPathEnrollmentAudit
258
+ fk_name = "enrollment"
259
+ extra = 0
260
+ exclude = ["enrollment_allowed"]
261
+ readonly_fields = [
262
+ "state_transition",
263
+ "enrolled_by",
264
+ "reason",
265
+ "org",
266
+ "role",
267
+ "created",
268
+ ]
269
+
270
+ def has_add_permission(self, request, obj=None):
271
+ """Disable manual creation of audit records."""
272
+ return False
273
+
274
+ def has_delete_permission(self, request, obj=None):
275
+ """Disable deletion of audit records."""
276
+ return False
277
+
278
+
279
+ class EnrollmentAllowedAuditInline(admin.TabularInline):
280
+ """Inline admin for LearningPathEnrollmentAudit records related to enrollment allowed."""
281
+
282
+ model = LearningPathEnrollmentAudit
283
+ fk_name = "enrollment_allowed"
284
+ extra = 0
285
+ exclude = ["enrollment"]
286
+ readonly_fields = [
287
+ "state_transition",
288
+ "enrolled_by",
289
+ "reason",
290
+ "org",
291
+ "role",
292
+ "created",
293
+ ]
294
+
295
+ def has_add_permission(self, request, obj=None):
296
+ """Disable manual creation of audit records."""
297
+ return False
298
+
299
+ def has_delete_permission(self, request, obj=None):
300
+ """Disable deletion of audit records."""
301
+ return False
302
+
303
+
304
+ @admin.register(LearningPathEnrollment)
305
+ class EnrolledUsersAdmin(admin.ModelAdmin):
306
+ """Admin for Learning Path enrollment."""
307
+
308
+ model = LearningPathEnrollment
309
+ raw_id_fields = ("user",)
310
+ autocomplete_fields = ["learning_path"]
311
+ inlines = [EnrollmentAuditInline]
312
+
313
+ list_display = [
314
+ "id",
315
+ "user",
316
+ "learning_path",
317
+ "is_active",
318
+ "created",
319
+ ]
320
+
321
+ list_filter = [
322
+ "learning_path__key",
323
+ "created",
324
+ "is_active",
325
+ ]
326
+
327
+ search_fields = [
328
+ "id",
329
+ "user__username",
330
+ "learning_path__key",
331
+ "learning_path__display_name",
332
+ ]
333
+
334
+
335
+ @admin.register(LearningPathEnrollmentAllowed)
336
+ class EnrollmentAllowedAdmin(admin.ModelAdmin):
337
+ """Admin configuration for LearningPathEnrollmentAllowed model."""
338
+
339
+ list_display = [
340
+ "id",
341
+ "email",
342
+ "get_user",
343
+ "learning_path",
344
+ "created",
345
+ ]
346
+
347
+ list_filter = [
348
+ "learning_path",
349
+ "created",
350
+ ]
351
+
352
+ search_fields = [
353
+ "email",
354
+ "user__username",
355
+ "user__email",
356
+ "learning_path__key",
357
+ ]
358
+
359
+ readonly_fields = [
360
+ "user",
361
+ "created",
362
+ "modified",
363
+ ]
364
+
365
+ inlines = [EnrollmentAllowedAuditInline]
366
+
367
+ def get_user(self, obj):
368
+ """Get the associated user, if any."""
369
+ return obj.user.username if obj.user else "-"
370
+
371
+ get_user.short_description = "User"
372
+
373
+
374
+ @admin.register(LearningPathEnrollmentAudit)
375
+ class EnrollmentAuditAdmin(admin.ModelAdmin):
376
+ """Admin configuration for LearningPathEnrollmentAudit model."""
377
+
378
+ list_display = [
379
+ "id",
380
+ "state_transition",
381
+ "enrolled_by",
382
+ "get_enrollee",
383
+ "get_learning_path",
384
+ "created",
385
+ "org",
386
+ "role",
387
+ ]
388
+
389
+ list_filter = [
390
+ "state_transition",
391
+ "created",
392
+ "org",
393
+ "role",
394
+ ]
395
+
396
+ search_fields = [
397
+ "enrolled_by__username",
398
+ "enrolled_by__email",
399
+ "enrollment__user__username",
400
+ "enrollment__user__email",
401
+ "enrollment_allowed__email",
402
+ "enrollment__learning_path__key",
403
+ "enrollment_allowed__learning_path__key",
404
+ "reason",
405
+ ]
406
+
407
+ readonly_fields = [
408
+ "enrollment",
409
+ "enrollment_allowed",
410
+ "enrolled_by",
411
+ "state_transition",
412
+ "created",
413
+ "modified",
414
+ ]
415
+
416
+ def get_enrollee(self, obj):
417
+ """Get the enrollee (user or email)."""
418
+ if obj.enrollment:
419
+ return obj.enrollment.user.username
420
+ elif obj.enrollment_allowed:
421
+ return obj.enrollment_allowed.user.username if obj.enrollment_allowed.user else obj.enrollment_allowed.email
422
+ return "-"
423
+
424
+ get_enrollee.short_description = "Enrollee"
425
+
426
+ def get_learning_path(self, obj):
427
+ """Get the learning path title."""
428
+ if obj.enrollment:
429
+ return obj.enrollment.learning_path.key
430
+ elif obj.enrollment_allowed:
431
+ return obj.enrollment_allowed.learning_path.key
432
+ return "-"
433
+
434
+ get_learning_path.short_description = "Learning Path"
@@ -97,17 +97,15 @@ 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):
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
- )
110
- is_enrolled = serializers.SerializerMethodField()
107
+ required_completion = serializers.FloatField(source="grading_criteria.required_completion", read_only=True)
108
+ enrollment_date = serializers.SerializerMethodField()
111
109
  invite_only = serializers.BooleanField()
112
110
  image = serializers.ImageField(read_only=True)
113
111
 
@@ -120,17 +118,17 @@ class LearningPathListSerializer(serializers.ModelSerializer):
120
118
  "sequential",
121
119
  "steps",
122
120
  "required_completion",
123
- "is_enrolled",
121
+ "enrollment_date",
124
122
  "invite_only",
125
123
  ]
126
124
 
127
- def get_is_enrolled(self, obj):
125
+ def get_enrollment_date(self, obj):
128
126
  """
129
127
  Check if the current user is enrolled in this learning path.
130
128
  """
131
- if hasattr(obj, "is_enrolled"):
132
- return obj.is_enrolled
133
- return False
129
+ if hasattr(obj, "enrollment_date"):
130
+ return obj.enrollment_date
131
+ return None
134
132
 
135
133
 
136
134
  class SkillSerializer(serializers.ModelSerializer):
@@ -168,19 +166,16 @@ 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 + [
180
174
  "subtitle",
181
175
  "description",
182
176
  "level",
183
- "duration_in_days",
177
+ "duration",
178
+ "time_commitment",
184
179
  "required_skills",
185
180
  "acquired_skills",
186
181
  ]
@@ -189,4 +184,4 @@ class LearningPathDetailSerializer(LearningPathListSerializer):
189
184
  class LearningPathEnrollmentSerializer(serializers.ModelSerializer):
190
185
  class Meta:
191
186
  model = LearningPathEnrollment
192
- fields = ("user", "learning_path", "is_active", "enrolled_at")
187
+ fields = ("user", "learning_path", "is_active", "created")
@@ -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)