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.
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/CHANGELOG.rst +5 -0
- {learning_paths_plugin-0.3.4rc6/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.4rc9}/PKG-INFO +7 -1
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/__init__.py +1 -1
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/admin.py +79 -4
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/v1/serializers.py +9 -8
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/v1/views.py +1 -4
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0013_enrollment_audit.py +11 -0
- learning_paths_plugin-0.3.4rc9/learning_paths/migrations/0014_remove_learningpath_duration_in_days_and_more.py +59 -0
- learning_paths_plugin-0.3.4rc9/learning_paths/migrations/0015_make_skill_level_optional.py +27 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/models.py +45 -33
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9/learning_paths_plugin.egg-info}/PKG-INFO +7 -1
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths_plugin.egg-info/SOURCES.txt +2 -1
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths_plugin.egg-info/requires.txt +1 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/requirements/base.in +1 -0
- learning_paths_plugin-0.3.4rc6/learning_paths/migrations/0014_learningpathenrollmentallowed_is_active.py +0 -20
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/LICENSE.txt +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/MANIFEST.in +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/README.rst +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/__init__.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/urls.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/v1/__init__.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/v1/filters.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/v1/permissions.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/v1/urls.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/v1/utils.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/apps.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/compat.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/conftest.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/keys.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0001_initial.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0002_learningpath_uuid.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0003_learningpath_subtitle.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0004_auto_20240207_1633.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0006_enrollment_models.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0007_replace_uuid_with_learningpathkey.py +0 -0
- {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
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0009_remove_learningpath_slug.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0010_learningpath_invite_only.py +0 -0
- {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
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/0012_alter_learningpath_subtitle.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/migrations/__init__.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/receivers.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/settings.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/templates/learning_paths/base.html +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/urls.py +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths_plugin.egg-info/dependency_links.txt +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths_plugin.egg-info/entry_points.txt +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths_plugin.egg-info/not-zip-safe +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths_plugin.egg-info/top_level.txt +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/pyproject.toml +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/requirements/constraints.txt +0 -0
- {learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/setup.cfg +0 -0
- {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.
|
|
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,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
|
-
"
|
|
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
|
-
|
|
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
|
-
"
|
|
121
|
+
"enrollment_date",
|
|
122
122
|
"invite_only",
|
|
123
123
|
]
|
|
124
124
|
|
|
125
|
-
def
|
|
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, "
|
|
130
|
-
return obj.
|
|
131
|
-
return
|
|
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
|
-
"
|
|
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", "
|
|
187
|
+
fields = ("user", "learning_path", "is_active", "created")
|
{learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/v1/views.py
RENAMED
|
@@ -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(
|
|
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
|
|
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
|
|
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
|
|
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 `
|
|
47
|
-
|
|
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
|
|
52
|
-
|
|
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(
|
|
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(
|
|
60
|
+
queryset = queryset.filter(Q(invite_only=False) | Q(enrollment_date__isnull=False))
|
|
60
61
|
|
|
61
|
-
|
|
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
|
-
|
|
114
|
+
duration = models.CharField(
|
|
115
|
+
max_length=255,
|
|
113
116
|
blank=True,
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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(
|
|
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,
|
|
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(
|
|
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,
|
|
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.
|
|
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/
|
|
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,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
|
-
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/__init__.py
RENAMED
|
File without changes
|
{learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/urls.py
RENAMED
|
File without changes
|
{learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/v1/__init__.py
RENAMED
|
File without changes
|
{learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/v1/filters.py
RENAMED
|
File without changes
|
|
File without changes
|
{learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/v1/urls.py
RENAMED
|
File without changes
|
{learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/api/v1/utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/conftest.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/receivers.py
RENAMED
|
File without changes
|
{learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/learning_paths/settings.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{learning_paths_plugin-0.3.4rc6 → learning_paths_plugin-0.3.4rc9}/requirements/constraints.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|