learning-paths-plugin 0.3.4rc6__tar.gz → 0.3.4rc9__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.4rc6 → learning_paths_plugin-0.3.4rc9}/CHANGELOG.rst +5 -0
  2. {learning_paths_plugin-0.3.4rc6/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.4rc9}/PKG-INFO +7 -1
  3. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/__init__.py +1 -1
  4. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/admin.py +79 -4
  5. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/v1/serializers.py +9 -8
  6. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/v1/views.py +1 -4
  7. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0013_enrollment_audit.py +11 -0
  8. learning_paths_plugin-0.3.4rc9/learning_paths/migrations/0014_remove_learningpath_duration_in_days_and_more.py +59 -0
  9. learning_paths_plugin-0.3.4rc9/learning_paths/migrations/0015_make_skill_level_optional.py +27 -0
  10. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/models.py +45 -33
  11. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9/learning_paths_plugin.egg-info}/PKG-INFO +7 -1
  12. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths_plugin.egg-info/SOURCES.txt +2 -1
  13. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths_plugin.egg-info/requires.txt +1 -0
  14. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/requirements/base.in +1 -0
  15. learning_paths_plugin-0.3.4rc6/learning_paths/migrations/0014_learningpathenrollmentallowed_is_active.py +0 -20
  16. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/LICENSE.txt +0 -0
  17. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/MANIFEST.in +0 -0
  18. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/README.rst +0 -0
  19. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/__init__.py +0 -0
  20. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/urls.py +0 -0
  21. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/v1/__init__.py +0 -0
  22. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/v1/filters.py +0 -0
  23. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/v1/permissions.py +0 -0
  24. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/v1/urls.py +0 -0
  25. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/v1/utils.py +0 -0
  26. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/apps.py +0 -0
  27. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/compat.py +0 -0
  28. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/conftest.py +0 -0
  29. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/keys.py +0 -0
  30. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0001_initial.py +0 -0
  31. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0002_learningpath_uuid.py +0 -0
  32. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0003_learningpath_subtitle.py +0 -0
  33. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0004_auto_20240207_1633.py +0 -0
  34. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py +0 -0
  35. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0006_enrollment_models.py +0 -0
  36. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0007_replace_uuid_with_learningpathkey.py +0 -0
  37. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0008_remove_learningpathstep_relative_due_date_in_days.py +0 -0
  38. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0009_remove_learningpath_slug.py +0 -0
  39. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0010_learningpath_invite_only.py +0 -0
  40. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0011_replace_learningpath_image_url_with_image.py +0 -0
  41. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0012_alter_learningpath_subtitle.py +0 -0
  42. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/__init__.py +0 -0
  43. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/receivers.py +0 -0
  44. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/settings.py +0 -0
  45. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/templates/learning_paths/base.html +0 -0
  46. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/urls.py +0 -0
  47. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths_plugin.egg-info/dependency_links.txt +0 -0
  48. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths_plugin.egg-info/entry_points.txt +0 -0
  49. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths_plugin.egg-info/not-zip-safe +0 -0
  50. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths_plugin.egg-info/top_level.txt +0 -0
  51. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/pyproject.toml +0 -0
  52. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/requirements/constraints.txt +0 -0
  53. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/setup.cfg +0 -0
  54. {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/setup.py +0 -0
@@ -24,11 +24,16 @@ Added
24
24
 
25
25
  * Bulk unenrollment API.
26
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.
27
29
 
28
30
  Changed
29
31
  =======
30
32
 
31
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.
32
37
 
33
38
  0.3.3 - 2025-05-23
34
39
  ******************
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning-paths-plugin
3
- Version: 0.3.4rc6
3
+ Version: 0.3.4rc9
4
4
  Summary: Learning Paths plugin
5
5
  Home-page: https://github.com/open-craft/learning-paths-plugin
6
6
  Author: OpenCraft
@@ -18,6 +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-object-actions
21
22
  Requires-Dist: djangorestframework
22
23
  Requires-Dist: edx-django-utils
23
24
  Requires-Dist: edx-opaque-keys
@@ -128,11 +129,16 @@ Added
128
129
 
129
130
  * Bulk unenrollment API.
130
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.
131
134
 
132
135
  Changed
133
136
  =======
134
137
 
135
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.
136
142
 
137
143
  0.3.3 - 2025-05-23
138
144
  ******************
@@ -2,4 +2,4 @@
2
2
  Learning Paths plugin.
3
3
  """
4
4
 
5
- __version__ = "0.3.4-rc6"
5
+ __version__ = "0.3.4-rc9"
@@ -2,11 +2,17 @@
2
2
  Django Admin for learning_paths.
3
3
  """
4
4
 
5
+ import os
6
+
5
7
  from django import forms
6
- from django.contrib import admin, auth
8
+ from django.contrib import admin, auth, messages
7
9
  from django.core.exceptions import ValidationError
10
+ from django.core.files.base import ContentFile
8
11
  from django.db import transaction
12
+ from django.http import HttpResponseRedirect
13
+ from django.urls import reverse
9
14
  from django.utils.translation import gettext_lazy as _
15
+ from django_object_actions import DjangoObjectActions, action
10
16
 
11
17
  from .compat import get_course_keys_with_outlines
12
18
  from .models import (
@@ -105,6 +111,7 @@ class LearningPathGradingCriteriaInline(admin.TabularInline):
105
111
  """Inline Admin for Learning path grading criteria."""
106
112
 
107
113
  model = LearningPathGradingCriteria
114
+ verbose_name = "Certificate Criteria"
108
115
 
109
116
 
110
117
  class BulkEnrollUsersForm(forms.ModelForm):
@@ -138,7 +145,7 @@ class BulkEnrollUsersForm(forms.ModelForm):
138
145
 
139
146
 
140
147
  @admin.register(LearningPath)
141
- class LearningPathAdmin(admin.ModelAdmin):
148
+ class LearningPathAdmin(DjangoObjectActions, admin.ModelAdmin):
142
149
  """Admin for Learning Path."""
143
150
 
144
151
  model = LearningPath
@@ -152,7 +159,7 @@ class LearningPathAdmin(admin.ModelAdmin):
152
159
  "key",
153
160
  "display_name",
154
161
  "level",
155
- "duration_in_days",
162
+ "duration",
156
163
  "invite_only",
157
164
  )
158
165
  list_filter = ("invite_only",)
@@ -165,6 +172,8 @@ class LearningPathAdmin(admin.ModelAdmin):
165
172
  LearningPathGradingCriteriaInline,
166
173
  ]
167
174
 
175
+ change_actions = ("duplicate_learning_path",)
176
+
168
177
  def get_readonly_fields(self, request, obj=None):
169
178
  """Make key read-only only for existing objects."""
170
179
  if obj: # Editing an existing object.
@@ -178,6 +187,72 @@ class LearningPathAdmin(admin.ModelAdmin):
178
187
  for user in form.cleaned_data["usernames"]:
179
188
  LearningPathEnrollment.objects.get_or_create(user=user, learning_path=form.instance)
180
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(
220
+ original_filename,
221
+ ContentFile(image_content),
222
+ save=False
223
+ )
224
+
225
+ new_learning_path.save()
226
+
227
+ new_learning_path.refresh_from_db()
228
+ new_learning_path.grading_criteria.required_completion = obj.grading_criteria.required_completion
229
+ new_learning_path.grading_criteria.required_grade = obj.grading_criteria.required_grade
230
+ new_learning_path.grading_criteria.save()
231
+
232
+ for step in obj.steps.all():
233
+ step.pk = None
234
+ step.learning_path = new_learning_path
235
+ step.save()
236
+
237
+ for skill in obj.requiredskill_set.all():
238
+ skill.pk = None
239
+ skill.learning_path = new_learning_path
240
+ skill.save()
241
+
242
+ for skill in obj.acquiredskill_set.all():
243
+ skill.pk = None
244
+ skill.learning_path = new_learning_path
245
+ skill.save()
246
+
247
+ messages.success(
248
+ request,
249
+ f'Learning path duplicated successfully. New key: {new_key}'
250
+ )
251
+
252
+ return HttpResponseRedirect(
253
+ reverse('admin:learning_paths_learningpath_change', args=[new_learning_path.pk])
254
+ )
255
+
181
256
 
182
257
  @admin.register(Skill)
183
258
  class SkillAdmin(admin.ModelAdmin):
@@ -249,8 +324,8 @@ class EnrolledUsersAdmin(admin.ModelAdmin):
249
324
  "id",
250
325
  "user",
251
326
  "learning_path",
252
- "enrolled_at",
253
327
  "is_active",
328
+ "created",
254
329
  ]
255
330
 
256
331
  list_filter = [
@@ -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):
@@ -174,7 +174,8 @@ class LearningPathDetailSerializer(LearningPathListSerializer):
174
174
  "subtitle",
175
175
  "description",
176
176
  "level",
177
- "duration_in_days",
177
+ "duration",
178
+ "time_commitment",
178
179
  "required_skills",
179
180
  "acquired_skills",
180
181
  ]
@@ -183,4 +184,4 @@ class LearningPathDetailSerializer(LearningPathListSerializer):
183
184
  class LearningPathEnrollmentSerializer(serializers.ModelSerializer):
184
185
  class Meta:
185
186
  model = LearningPathEnrollment
186
- fields = ("user", "learning_path", "is_active", "enrolled_at")
187
+ fields = ("user", "learning_path", "is_active", "created")
@@ -3,7 +3,6 @@ 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
@@ -242,7 +241,6 @@ class LearningPathEnrollmentView(APIView):
242
241
  return Response({"detail": "Enrollment exists."}, status=status.HTTP_409_CONFLICT)
243
242
 
244
243
  enrollment.is_active = True
245
- enrollment.enrolled_at = datetime.now(timezone.utc)
246
244
  enrollment.save()
247
245
  return Response(LearningPathEnrollmentSerializer(enrollment).data)
248
246
 
@@ -389,7 +387,6 @@ class BulkEnrollView(APIView):
389
387
  if enrollment:
390
388
  if not enrollment.is_active:
391
389
  enrollment.is_active = True
392
- enrollment.enrolled_at = datetime.now(timezone.utc)
393
390
  enrolled_now = True
394
391
  else:
395
392
  audit_data["state_transition"] = LearningPathEnrollmentAudit.ENROLLED_TO_ENROLLED
@@ -519,7 +516,7 @@ class LearningPathCourseEnrollmentView(APIView):
519
516
  :raises: Http404 if the learning path is not found or the user does not have access.
520
517
  """
521
518
  return get_object_or_404(
522
- 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),
523
520
  key=learning_path_key_str,
524
521
  )
525
522
 
@@ -155,4 +155,15 @@ class Migration(migrations.Migration):
155
155
  "abstract": False,
156
156
  },
157
157
  ),
158
+ migrations.AddField(
159
+ model_name="learningpathenrollmentallowed",
160
+ name="is_active",
161
+ field=models.BooleanField(
162
+ db_index=True, default=True, help_text="Indicates if the enrollment allowance is active"
163
+ ),
164
+ ),
165
+ migrations.RemoveField(
166
+ model_name="learningpathenrollment",
167
+ name="enrolled_at",
168
+ ),
158
169
  ]
@@ -0,0 +1,59 @@
1
+ # Generated by Django 4.2.20 on 2025-07-14 13:10
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ def transfer_duration_data(apps, schema_editor):
7
+ """Transfer duration_in_days values to duration field with '{x} days' format."""
8
+ LearningPath = apps.get_model("learning_paths", "LearningPath")
9
+
10
+ for learning_path in LearningPath.objects.filter(duration_in_days__isnull=False):
11
+ learning_path.duration = f"{learning_path.duration_in_days} days"
12
+ learning_path.save()
13
+
14
+
15
+ def reverse_transfer_duration_data(apps, schema_editor):
16
+ """Reverse operation: extract numeric values from duration field back to duration_in_days."""
17
+ LearningPath = apps.get_model("learning_paths", "LearningPath")
18
+
19
+ for learning_path in LearningPath.objects.filter(duration__endswith=" days"):
20
+ try:
21
+ days_str = learning_path.duration.replace(" days", "")
22
+ learning_path.duration_in_days = int(days_str)
23
+ learning_path.save()
24
+ except ValueError:
25
+ # Skip entries that don't match the expected format.
26
+ pass
27
+
28
+
29
+ class Migration(migrations.Migration):
30
+ dependencies = [
31
+ ("learning_paths", "0013_enrollment_audit"),
32
+ ]
33
+
34
+ operations = [
35
+ migrations.AddField(
36
+ model_name="learningpath",
37
+ name="duration",
38
+ field=models.CharField(
39
+ blank=True,
40
+ help_text="Approximate time it should take to complete this Learning Path. Example: '10 Weeks'.",
41
+ max_length=255,
42
+ ),
43
+ ),
44
+ migrations.AddField(
45
+ model_name="learningpath",
46
+ name="time_commitment",
47
+ field=models.CharField(
48
+ blank=True, help_text="Approximate time commitment. Example: '4-6 hours/week'.", max_length=255
49
+ ),
50
+ ),
51
+ migrations.RunPython(
52
+ transfer_duration_data,
53
+ reverse_transfer_duration_data,
54
+ ),
55
+ migrations.RemoveField(
56
+ model_name="learningpath",
57
+ name="duration_in_days",
58
+ ),
59
+ ]
@@ -0,0 +1,27 @@
1
+ # Generated by Django 4.2.23 on 2025-07-22 22:28
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ("learning_paths", "0014_remove_learningpath_duration_in_days_and_more"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AlterField(
14
+ model_name="acquiredskill",
15
+ name="level",
16
+ field=models.PositiveIntegerField(
17
+ blank=True, help_text="The skill level associated with this course.", null=True
18
+ ),
19
+ ),
20
+ migrations.AlterField(
21
+ model_name="requiredskill",
22
+ name="level",
23
+ field=models.PositiveIntegerField(
24
+ blank=True, help_text="The skill level associated with this course.", null=True
25
+ ),
26
+ ),
27
+ ]
@@ -5,14 +5,14 @@ Database models for learning_paths.
5
5
  import logging
6
6
  import os
7
7
  import uuid
8
- from datetime import datetime, timedelta
8
+ from datetime import datetime
9
9
  from uuid import uuid4
10
10
 
11
11
  from django.contrib import auth
12
12
  from django.core.exceptions import ValidationError
13
13
  from django.core.validators import MaxValueValidator, MinValueValidator
14
14
  from django.db import models
15
- from django.db.models import Exists, OuterRef, Q
15
+ from django.db.models import OuterRef, Q
16
16
  from django.utils.translation import gettext_lazy as _
17
17
  from model_utils import FieldTracker
18
18
  from model_utils.models import TimeStampedModel
@@ -38,27 +38,29 @@ class LearningPathManager(models.Manager):
38
38
 
39
39
  def get_paths_visible_to_user(self, user: User) -> models.QuerySet:
40
40
  """
41
- Return only learning paths that should be visible to the given user with enrollment status.
41
+ Return only learning paths that should be visible to the given user with an enrollment date.
42
42
 
43
43
  For staff users: all learning paths.
44
44
  For non-staff: non-invite-only paths or invite-only paths they're enrolled in.
45
45
 
46
- Each learning path in the queryset is annotated with `is_enrolled` indicating
47
- whether the user has an active enrollment in that learning path.
46
+ Each learning path in the queryset is annotated with `enrollment_date` indicating
47
+ the date when the user enrolled in that learning path (None if not enrolled).
48
+ Results are ordered by enrollment date (the most recent first), with non-enrolled paths at the end.
48
49
  """
49
50
  queryset = self.get_queryset()
50
51
 
51
- # Annotate each path with whether the user is enrolled.
52
- enrollment_exists = LearningPathEnrollment.objects.filter(
52
+ # Annotate each path with the enrollment date.
53
+ enrollment_subquery = LearningPathEnrollment.objects.filter(
53
54
  learning_path=OuterRef("pk"), user=user, is_active=True
54
- )
55
- queryset = queryset.annotate(is_enrolled=Exists(enrollment_exists))
55
+ ).values("created")[:1]
56
+ queryset = queryset.annotate(enrollment_date=models.Subquery(enrollment_subquery))
56
57
 
57
58
  # Apply visibility filtering based on the user role.
58
59
  if not user.is_staff:
59
- queryset = queryset.filter(Q(invite_only=False) | Q(is_enrolled=True))
60
+ queryset = queryset.filter(Q(invite_only=False) | Q(enrollment_date__isnull=False))
60
61
 
61
- return queryset
62
+ # Order by enrollment date (the most recent first), with null values at the end.
63
+ return queryset.order_by(models.F("enrollment_date").desc(nulls_last=True))
62
64
 
63
65
 
64
66
  class LearningPath(TimeStampedModel):
@@ -102,18 +104,22 @@ class LearningPath(TimeStampedModel):
102
104
  subtitle = models.TextField(blank=True)
103
105
  description = models.TextField(blank=True)
104
106
  image = models.ImageField(
105
- upload_to=_learning_path_image_upload_path,
107
+ upload_to=_learning_path_image_upload_path, # type: ignore
106
108
  blank=True,
107
109
  null=True,
108
110
  verbose_name=_("Image"),
109
111
  help_text=_("Image representing this Learning Path."),
110
112
  )
111
113
  level = models.CharField(max_length=255, blank=True, choices=LEVEL_CHOICES)
112
- duration_in_days = models.PositiveIntegerField(
114
+ duration = models.CharField(
115
+ max_length=255,
113
116
  blank=True,
114
- null=True,
115
- verbose_name=_("Duration (days)"),
116
- help_text=_("Approximate time (in days) it should take to complete this Learning Path."),
117
+ help_text=_("Approximate time it should take to complete this Learning Path. Example: '10 Weeks'."),
118
+ )
119
+ time_commitment = models.CharField(
120
+ max_length=255,
121
+ blank=True,
122
+ help_text=_("Approximate time commitment. Example: '4-6 hours/week'."),
117
123
  )
118
124
  sequential = models.BooleanField(
119
125
  default=False,
@@ -134,6 +140,11 @@ class LearningPath(TimeStampedModel):
134
140
 
135
141
  objects = LearningPathManager()
136
142
 
143
+ steps: "models.Manager[LearningPathStep]"
144
+ requiredskill_set: "models.Manager[RequiredSkill]"
145
+ acquiredskill_set: "models.Manager[AcquiredSkill]"
146
+ grading_criteria: "LearningPathGradingCriteria"
147
+
137
148
  def __str__(self):
138
149
  """User-friendly string representation of this model."""
139
150
  return str(self.key)
@@ -248,7 +259,11 @@ class LearningPathSkill(TimeStampedModel):
248
259
 
249
260
  learning_path = models.ForeignKey(LearningPath, on_delete=models.CASCADE)
250
261
  skill = models.ForeignKey(Skill, on_delete=models.CASCADE)
251
- level = models.PositiveIntegerField(help_text=_("The skill level associated with this course."))
262
+ level = models.PositiveIntegerField(
263
+ blank=True,
264
+ null=True,
265
+ help_text=_("The skill level associated with this course."),
266
+ )
252
267
 
253
268
  def __str__(self):
254
269
  """User-friendly string representation of this model."""
@@ -289,25 +304,12 @@ class LearningPathEnrollment(TimeStampedModel):
289
304
  default=True,
290
305
  help_text=_("Indicates if the learner is enrolled or not in the Learning Path"),
291
306
  )
292
- enrolled_at = models.DateTimeField(
293
- auto_now_add=True,
294
- help_text=_(
295
- "Timestamp of enrollment or un-enrollment. To be explicitly set when performing a learner enrollment."
296
- ),
297
- )
298
307
  tracker = FieldTracker(fields=["is_active"])
299
308
 
300
309
  def __str__(self):
301
310
  """User-friendly string representation of this model."""
302
311
  return "{}: {}".format(self.user, self.learning_path)
303
312
 
304
- @property
305
- def estimated_end_date(self):
306
- """Estimated end date of the learning path."""
307
- if self.learning_path.duration_in_days is None:
308
- return None
309
- return self.created + timedelta(days=self.learning_path.duration_in_days)
310
-
311
313
 
312
314
  class LearningPathGradingCriteria(models.Model):
313
315
  """
@@ -373,7 +375,9 @@ class LearningPathEnrollmentAllowed(TimeStampedModel):
373
375
  learning_path = models.ForeignKey(LearningPath, on_delete=models.CASCADE)
374
376
  user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True)
375
377
  is_active = models.BooleanField(
376
- default=True, db_index=True, help_text=_("Indicates if the enrollment allowance is active")
378
+ default=True,
379
+ db_index=True,
380
+ help_text=_("Indicates if the enrollment allowance is active"),
377
381
  )
378
382
 
379
383
  def __str__(self):
@@ -410,9 +414,17 @@ class LearningPathEnrollmentAudit(TimeStampedModel):
410
414
  )
411
415
 
412
416
  enrolled_by = models.ForeignKey(User, on_delete=models.CASCADE, null=True, related_name="learning_path_audit")
413
- enrollment = models.ForeignKey(LearningPathEnrollment, on_delete=models.CASCADE, null=True, related_name="audit")
417
+ enrollment = models.ForeignKey(
418
+ LearningPathEnrollment,
419
+ on_delete=models.CASCADE,
420
+ null=True,
421
+ related_name="audit",
422
+ )
414
423
  enrollment_allowed = models.ForeignKey(
415
- LearningPathEnrollmentAllowed, on_delete=models.CASCADE, null=True, related_name="audit"
424
+ LearningPathEnrollmentAllowed,
425
+ on_delete=models.CASCADE,
426
+ null=True,
427
+ related_name="audit",
416
428
  )
417
429
  state_transition = models.CharField(max_length=255, choices=TRANSITION_STATES, default=DEFAULT_TRANSITION_STATE)
418
430
  reason = models.TextField(blank=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning-paths-plugin
3
- Version: 0.3.4rc6
3
+ Version: 0.3.4rc9
4
4
  Summary: Learning Paths plugin
5
5
  Home-page: https://github.com/open-craft/learning-paths-plugin
6
6
  Author: OpenCraft
@@ -18,6 +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-object-actions
21
22
  Requires-Dist: djangorestframework
22
23
  Requires-Dist: edx-django-utils
23
24
  Requires-Dist: edx-opaque-keys
@@ -128,11 +129,16 @@ Added
128
129
 
129
130
  * Bulk unenrollment API.
130
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.
131
134
 
132
135
  Changed
133
136
  =======
134
137
 
135
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.
136
142
 
137
143
  0.3.3 - 2025-05-23
138
144
  ******************
@@ -37,7 +37,8 @@ learning_paths/migrations/0010_learningpath_invite_only.py
37
37
  learning_paths/migrations/0011_replace_learningpath_image_url_with_image.py
38
38
  learning_paths/migrations/0012_alter_learningpath_subtitle.py
39
39
  learning_paths/migrations/0013_enrollment_audit.py
40
- learning_paths/migrations/0014_learningpathenrollmentallowed_is_active.py
40
+ learning_paths/migrations/0014_remove_learningpath_duration_in_days_and_more.py
41
+ learning_paths/migrations/0015_make_skill_level_optional.py
41
42
  learning_paths/migrations/__init__.py
42
43
  learning_paths/templates/learning_paths/base.html
43
44
  learning_paths_plugin.egg-info/PKG-INFO
@@ -1,5 +1,6 @@
1
1
  Django
2
2
  django-model-utils
3
+ django-object-actions
3
4
  djangorestframework
4
5
  edx-django-utils
5
6
  edx-opaque-keys
@@ -4,6 +4,7 @@
4
4
  Django # Web application framework
5
5
 
6
6
  django-model-utils
7
+ django-object-actions # Add action buttons to Django admin
7
8
  djangorestframework
8
9
  edx-django-utils
9
10
  edx-opaque-keys
@@ -1,20 +0,0 @@
1
- # Generated by Django 4.2.20 on 2025-05-30 19:57
2
-
3
- from django.db import migrations, models
4
-
5
-
6
- class Migration(migrations.Migration):
7
-
8
- dependencies = [
9
- ("learning_paths", "0013_enrollment_audit"),
10
- ]
11
-
12
- operations = [
13
- migrations.AddField(
14
- model_name="learningpathenrollmentallowed",
15
- name="is_active",
16
- field=models.BooleanField(
17
- db_index=True, default=True, help_text="Indicates if the enrollment allowance is active"
18
- ),
19
- ),
20
- ]