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.
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/CHANGELOG.rst +27 -0
- {learning_paths_plugin-0.3.2/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.4}/PKG-INFO +29 -2
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/__init__.py +1 -1
- learning_paths_plugin-0.3.4/learning_paths/admin.py +434 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/api/v1/serializers.py +13 -18
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/api/v1/urls.py +1 -3
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/api/v1/utils.py +2 -6
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/api/v1/views.py +157 -54
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/compat.py +5 -9
- learning_paths_plugin-0.3.4/learning_paths/conftest.py +51 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/keys.py +3 -9
- learning_paths_plugin-0.3.4/learning_paths/migrations/0006_enrollment_models.py +39 -0
- learning_paths_plugin-0.3.4/learning_paths/migrations/0013_enrollment_audit.py +169 -0
- learning_paths_plugin-0.3.4/learning_paths/migrations/0014_remove_learningpath_duration_in_days_and_more.py +59 -0
- learning_paths_plugin-0.3.4/learning_paths/migrations/0015_make_skill_level_optional.py +27 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/models.py +108 -57
- learning_paths_plugin-0.3.4/learning_paths/receivers.py +139 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4/learning_paths_plugin.egg-info}/PKG-INFO +29 -2
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths_plugin.egg-info/SOURCES.txt +3 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths_plugin.egg-info/requires.txt +1 -1
- learning_paths_plugin-0.3.4/pyproject.toml +16 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/requirements/base.in +1 -1
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/requirements/constraints.txt +1 -6
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/setup.py +8 -26
- learning_paths_plugin-0.3.2/learning_paths/admin.py +0 -210
- learning_paths_plugin-0.3.2/learning_paths/conftest.py +0 -13
- learning_paths_plugin-0.3.2/learning_paths/migrations/0006_enrollment_models.py +0 -66
- learning_paths_plugin-0.3.2/learning_paths/receivers.py +0 -52
- learning_paths_plugin-0.3.2/pyproject.toml +0 -9
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/LICENSE.txt +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/MANIFEST.in +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/README.rst +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/api/__init__.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/api/urls.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/api/v1/__init__.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/api/v1/filters.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/api/v1/permissions.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/apps.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/0001_initial.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/0002_learningpath_uuid.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/0003_learningpath_subtitle.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/0004_auto_20240207_1633.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/0007_replace_uuid_with_learningpathkey.py +0 -0
- {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
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/0009_remove_learningpath_slug.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/0010_learningpath_invite_only.py +0 -0
- {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
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/0012_alter_learningpath_subtitle.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/migrations/__init__.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/settings.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/templates/learning_paths/base.html +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/urls.py +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths_plugin.egg-info/dependency_links.txt +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths_plugin.egg-info/entry_points.txt +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths_plugin.egg-info/not-zip-safe +0 -0
- {learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths_plugin.egg-info/top_level.txt +0 -0
- {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
|
|
{learning_paths_plugin-0.3.2/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.4}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning-paths-plugin
|
|
3
|
-
Version: 0.3.
|
|
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-
|
|
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
|
|
|
@@ -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"
|
{learning_paths_plugin-0.3.2 → learning_paths_plugin-0.3.4}/learning_paths/api/v1/serializers.py
RENAMED
|
@@ -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", "
|
|
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
|
-
|
|
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
|
-
"
|
|
121
|
+
"enrollment_date",
|
|
124
122
|
"invite_only",
|
|
125
123
|
]
|
|
126
124
|
|
|
127
|
-
def
|
|
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, "
|
|
132
|
-
return obj.
|
|
133
|
-
return
|
|
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
|
-
|
|
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
|
-
"
|
|
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", "
|
|
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)
|