learning-paths-plugin 0.3.0rc1__tar.gz → 0.3.2__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/CHANGELOG.rst +102 -0
- {learning_paths_plugin-0.3.0rc1/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.2}/PKG-INFO +47 -2
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/__init__.py +1 -1
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/admin.py +55 -8
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/serializers.py +103 -4
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/urls.py +10 -2
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/views.py +109 -14
- learning_paths_plugin-0.3.2/learning_paths/compat.py +78 -0
- learning_paths_plugin-0.3.2/learning_paths/conftest.py +13 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/keys.py +8 -9
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0007_replace_uuid_with_learningpathkey.py +1 -1
- learning_paths_plugin-0.3.2/learning_paths/migrations/0008_remove_learningpathstep_relative_due_date_in_days.py +17 -0
- learning_paths_plugin-0.3.2/learning_paths/migrations/0009_remove_learningpath_slug.py +17 -0
- learning_paths_plugin-0.3.2/learning_paths/migrations/0010_learningpath_invite_only.py +22 -0
- learning_paths_plugin-0.3.2/learning_paths/migrations/0011_replace_learningpath_image_url_with_image.py +29 -0
- learning_paths_plugin-0.3.2/learning_paths/migrations/0012_alter_learningpath_subtitle.py +18 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/models.py +118 -23
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2/learning_paths_plugin.egg-info}/PKG-INFO +47 -2
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/SOURCES.txt +7 -4
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/requires.txt +1 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/requirements/base.in +1 -0
- learning_paths_plugin-0.3.0rc1/CHANGELOG.rst +0 -58
- learning_paths_plugin-0.3.0rc1/learning_paths/compat.py +0 -48
- learning_paths_plugin-0.3.0rc1/tests/test_keys.py +0 -110
- learning_paths_plugin-0.3.0rc1/tests/test_models.py +0 -79
- learning_paths_plugin-0.3.0rc1/tests/test_receivers.py +0 -78
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/LICENSE.txt +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/MANIFEST.in +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/README.rst +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/api/__init__.py +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/api/urls.py +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/__init__.py +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/filters.py +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/permissions.py +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/utils.py +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/apps.py +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0001_initial.py +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0002_learningpath_uuid.py +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0003_learningpath_subtitle.py +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0004_auto_20240207_1633.py +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/0006_enrollment_models.py +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/migrations/__init__.py +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/receivers.py +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/settings.py +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/templates/learning_paths/base.html +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/urls.py +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/dependency_links.txt +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/entry_points.txt +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/not-zip-safe +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths_plugin.egg-info/top_level.txt +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/pyproject.toml +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/requirements/constraints.txt +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/setup.cfg +0 -0
- {learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/setup.py +0 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Change Log
|
|
2
|
+
##########
|
|
3
|
+
|
|
4
|
+
..
|
|
5
|
+
All enhancements and patches to learning_paths will be documented
|
|
6
|
+
in this file. It adheres to the structure of https://keepachangelog.com/ ,
|
|
7
|
+
but in reStructuredText instead of Markdown (for ease of incorporation into
|
|
8
|
+
Sphinx documentation and the PyPI description).
|
|
9
|
+
|
|
10
|
+
This project adheres to Semantic Versioning (https://semver.org/).
|
|
11
|
+
|
|
12
|
+
.. There should always be an "Unreleased" section for changes pending release.
|
|
13
|
+
|
|
14
|
+
Unreleased
|
|
15
|
+
**********
|
|
16
|
+
|
|
17
|
+
*
|
|
18
|
+
|
|
19
|
+
0.3.2 - 2025-05-02
|
|
20
|
+
******************
|
|
21
|
+
|
|
22
|
+
Added
|
|
23
|
+
=====
|
|
24
|
+
|
|
25
|
+
* Course key selection in admin forms.
|
|
26
|
+
* Learning Path selection field in admin forms.
|
|
27
|
+
* Enrollment status to the Learning Path list and retrieve APIs.
|
|
28
|
+
* Invite-only functionality for Learning Paths.
|
|
29
|
+
* Course enrollment API.
|
|
30
|
+
|
|
31
|
+
Changed
|
|
32
|
+
=======
|
|
33
|
+
|
|
34
|
+
* The Learning Path ``subtitle`` to ``TextField`` and made it optional.
|
|
35
|
+
* The image URL field to ``ImageField``.
|
|
36
|
+
* The user field on the admin enrollments page to raw ID, to prevent the page
|
|
37
|
+
from retrieving all users in the system.
|
|
38
|
+
|
|
39
|
+
Removed
|
|
40
|
+
=======
|
|
41
|
+
|
|
42
|
+
* The ``slug`` field from the Learning Path model.
|
|
43
|
+
* The UUID compatibility layer from Learning Path keys.
|
|
44
|
+
|
|
45
|
+
0.3.1 - 2025-04-14
|
|
46
|
+
******************
|
|
47
|
+
|
|
48
|
+
Added
|
|
49
|
+
=====
|
|
50
|
+
|
|
51
|
+
* API for listing and retrieving Learning Paths.
|
|
52
|
+
|
|
53
|
+
Fixed
|
|
54
|
+
=====
|
|
55
|
+
|
|
56
|
+
* Automatically create grading criteria for Learning Paths.
|
|
57
|
+
|
|
58
|
+
Changed
|
|
59
|
+
=======
|
|
60
|
+
|
|
61
|
+
* Replaced relative due dates with actual due dates from course runs.
|
|
62
|
+
|
|
63
|
+
0.3.0 - 2025-04-03
|
|
64
|
+
******************
|
|
65
|
+
|
|
66
|
+
Changed
|
|
67
|
+
=======
|
|
68
|
+
|
|
69
|
+
* Replaced Learning Path UUID with LearningPathKey.
|
|
70
|
+
|
|
71
|
+
0.2.3 - 2025-03-31
|
|
72
|
+
******************
|
|
73
|
+
|
|
74
|
+
Added
|
|
75
|
+
=====
|
|
76
|
+
|
|
77
|
+
* Enrollment API.
|
|
78
|
+
|
|
79
|
+
0.2.2 - 2024-12-05
|
|
80
|
+
******************
|
|
81
|
+
|
|
82
|
+
Added
|
|
83
|
+
=====
|
|
84
|
+
|
|
85
|
+
* User grade API
|
|
86
|
+
|
|
87
|
+
0.2.1 - 2024-10-28
|
|
88
|
+
******************
|
|
89
|
+
|
|
90
|
+
Added
|
|
91
|
+
=====
|
|
92
|
+
|
|
93
|
+
* Progress API
|
|
94
|
+
|
|
95
|
+
0.2.0 - 2024-01-23
|
|
96
|
+
******************
|
|
97
|
+
|
|
98
|
+
Added
|
|
99
|
+
=====
|
|
100
|
+
|
|
101
|
+
* Database models
|
|
102
|
+
* Django Admin interface
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning-paths-plugin
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Learning Paths plugin
|
|
5
5
|
Home-page: https://github.com/open-craft/learning-paths-plugin
|
|
6
6
|
Author: OpenCraft
|
|
@@ -24,6 +24,7 @@ Requires-Dist: edx-django-utils
|
|
|
24
24
|
Requires-Dist: edx-opaque-keys
|
|
25
25
|
Requires-Dist: openedx-atlas
|
|
26
26
|
Requires-Dist: openedx-completion-aggregator
|
|
27
|
+
Requires-Dist: pillow
|
|
27
28
|
Dynamic: author
|
|
28
29
|
Dynamic: author-email
|
|
29
30
|
Dynamic: classifier
|
|
@@ -120,7 +121,51 @@ Unreleased
|
|
|
120
121
|
|
|
121
122
|
*
|
|
122
123
|
|
|
123
|
-
0.3.
|
|
124
|
+
0.3.2 - 2025-05-02
|
|
125
|
+
******************
|
|
126
|
+
|
|
127
|
+
Added
|
|
128
|
+
=====
|
|
129
|
+
|
|
130
|
+
* Course key selection in admin forms.
|
|
131
|
+
* Learning Path selection field in admin forms.
|
|
132
|
+
* Enrollment status to the Learning Path list and retrieve APIs.
|
|
133
|
+
* Invite-only functionality for Learning Paths.
|
|
134
|
+
* Course enrollment API.
|
|
135
|
+
|
|
136
|
+
Changed
|
|
137
|
+
=======
|
|
138
|
+
|
|
139
|
+
* The Learning Path ``subtitle`` to ``TextField`` and made it optional.
|
|
140
|
+
* The image URL field to ``ImageField``.
|
|
141
|
+
* The user field on the admin enrollments page to raw ID, to prevent the page
|
|
142
|
+
from retrieving all users in the system.
|
|
143
|
+
|
|
144
|
+
Removed
|
|
145
|
+
=======
|
|
146
|
+
|
|
147
|
+
* The ``slug`` field from the Learning Path model.
|
|
148
|
+
* The UUID compatibility layer from Learning Path keys.
|
|
149
|
+
|
|
150
|
+
0.3.1 - 2025-04-14
|
|
151
|
+
******************
|
|
152
|
+
|
|
153
|
+
Added
|
|
154
|
+
=====
|
|
155
|
+
|
|
156
|
+
* API for listing and retrieving Learning Paths.
|
|
157
|
+
|
|
158
|
+
Fixed
|
|
159
|
+
=====
|
|
160
|
+
|
|
161
|
+
* Automatically create grading criteria for Learning Paths.
|
|
162
|
+
|
|
163
|
+
Changed
|
|
164
|
+
=======
|
|
165
|
+
|
|
166
|
+
* Replaced relative due dates with actual due dates from course runs.
|
|
167
|
+
|
|
168
|
+
0.3.0 - 2025-04-03
|
|
124
169
|
******************
|
|
125
170
|
|
|
126
171
|
Changed
|
|
@@ -29,13 +29,59 @@ def get_course_keys_choices():
|
|
|
29
29
|
yield key, key
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
class CourseKeyDatalistWidget(forms.TextInput):
|
|
33
|
+
"""A widget that provides a datalist for course keys."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, choices=None, attrs=None):
|
|
36
|
+
"""Initialize the widget with a datalist and apply styles."""
|
|
37
|
+
attrs = attrs or {}
|
|
38
|
+
attrs.update(
|
|
39
|
+
{
|
|
40
|
+
"style": "width: 30em;",
|
|
41
|
+
"class": "form-control datalist-input",
|
|
42
|
+
"placeholder": _("Type to search courses..."),
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
super().__init__(attrs)
|
|
46
|
+
self.choices = choices or []
|
|
47
|
+
|
|
48
|
+
def render(self, name, value, attrs=None, renderer=None):
|
|
49
|
+
"""Render the widget with a datalist."""
|
|
50
|
+
final_attrs = attrs or {}
|
|
51
|
+
data_list_id = f"datalist_{name}"
|
|
52
|
+
final_attrs["list"] = data_list_id
|
|
53
|
+
|
|
54
|
+
text_input_html = super().render(name, value, attrs, renderer)
|
|
55
|
+
data_list_id = f"datalist_{name}"
|
|
56
|
+
options = "\n".join(f'<option value="{choice}" />' for choice in self.choices)
|
|
57
|
+
datalist_html = f'<datalist id="{data_list_id}">\n{options}\n</datalist>'
|
|
58
|
+
return f"{text_input_html}\n{datalist_html}"
|
|
59
|
+
|
|
60
|
+
|
|
32
61
|
class LearningPathStepForm(forms.ModelForm):
|
|
33
|
-
"""
|
|
62
|
+
"""Form for Learning Path step."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, *args, **kwargs):
|
|
65
|
+
"""Lazily fetch course keys to avoid calling compat code in all environments."""
|
|
66
|
+
super().__init__(*args, **kwargs)
|
|
67
|
+
self._course_keys = get_course_keys_with_outlines()
|
|
68
|
+
self.fields["course_key"].widget = CourseKeyDatalistWidget(
|
|
69
|
+
choices=self._course_keys
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
course_key = forms.CharField(label=_("Course"))
|
|
73
|
+
|
|
74
|
+
def clean_course_key(self):
|
|
75
|
+
"""Validate that the course key is on the list of available course keys."""
|
|
76
|
+
course_key = self.cleaned_data.get("course_key")
|
|
77
|
+
valid_keys = {str(key).strip() for key in self._course_keys}
|
|
78
|
+
|
|
79
|
+
if course_key not in valid_keys:
|
|
80
|
+
raise ValidationError(
|
|
81
|
+
_("Invalid course key. Please select a course from the suggestions.")
|
|
82
|
+
)
|
|
34
83
|
|
|
35
|
-
|
|
36
|
-
# See <https://github.com/open-craft/section-to-course/blob/db6fd6f8f4478e91bb531e6c2fa50143e1c2e012/
|
|
37
|
-
# section_to_course/admin.py#L31-L140>
|
|
38
|
-
course_key = forms.ChoiceField(choices=get_course_keys_choices, label=_("Course"))
|
|
84
|
+
return course_key
|
|
39
85
|
|
|
40
86
|
|
|
41
87
|
class LearningPathStepInline(admin.TabularInline):
|
|
@@ -102,17 +148,17 @@ class LearningPathAdmin(admin.ModelAdmin):
|
|
|
102
148
|
form = BulkEnrollUsersForm
|
|
103
149
|
|
|
104
150
|
search_fields = [
|
|
105
|
-
"slug",
|
|
106
151
|
"display_name",
|
|
107
152
|
"key",
|
|
108
153
|
]
|
|
109
154
|
list_display = (
|
|
110
155
|
"key",
|
|
111
|
-
"slug",
|
|
112
156
|
"display_name",
|
|
113
157
|
"level",
|
|
114
158
|
"duration_in_days",
|
|
159
|
+
"invite_only",
|
|
115
160
|
)
|
|
161
|
+
list_filter = ("invite_only",)
|
|
116
162
|
readonly_fields = ("key",)
|
|
117
163
|
|
|
118
164
|
inlines = [
|
|
@@ -148,12 +194,13 @@ class EnrolledUsersAdmin(admin.ModelAdmin):
|
|
|
148
194
|
"""Admin for Learning Path enrollment."""
|
|
149
195
|
|
|
150
196
|
model = LearningPathEnrollment
|
|
197
|
+
raw_id_fields = ("user",)
|
|
198
|
+
autocomplete_fields = ["learning_path"]
|
|
151
199
|
|
|
152
200
|
search_fields = [
|
|
153
201
|
"id",
|
|
154
202
|
"user__username",
|
|
155
203
|
"learning_path__key",
|
|
156
|
-
"learning_path__slug",
|
|
157
204
|
"learning_path__display_name",
|
|
158
205
|
]
|
|
159
206
|
|
{learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/serializers.py
RENAMED
|
@@ -4,7 +4,14 @@ Serializer for LearningPath.
|
|
|
4
4
|
|
|
5
5
|
from rest_framework import serializers
|
|
6
6
|
|
|
7
|
-
from learning_paths.models import
|
|
7
|
+
from learning_paths.models import (
|
|
8
|
+
AcquiredSkill,
|
|
9
|
+
LearningPath,
|
|
10
|
+
LearningPathEnrollment,
|
|
11
|
+
LearningPathStep,
|
|
12
|
+
RequiredSkill,
|
|
13
|
+
Skill,
|
|
14
|
+
)
|
|
8
15
|
|
|
9
16
|
DEFAULT_STATUS = "active"
|
|
10
17
|
IMAGE_WIDTH = 1440
|
|
@@ -28,15 +35,15 @@ class LearningPathAsProgramSerializer(serializers.ModelSerializer):
|
|
|
28
35
|
course_codes = serializers.SerializerMethodField()
|
|
29
36
|
|
|
30
37
|
def get_marketing_slug(self, obj):
|
|
31
|
-
return obj.
|
|
38
|
+
return str(obj.key)
|
|
32
39
|
|
|
33
40
|
def get_status(self, obj): # pylint: disable=unused-argument
|
|
34
41
|
return DEFAULT_STATUS
|
|
35
42
|
|
|
36
43
|
def get_banner_image_urls(self, obj):
|
|
37
|
-
if obj.
|
|
44
|
+
if obj.image:
|
|
38
45
|
image_key = f"w{IMAGE_WIDTH}h{IMAGE_HEIGHT}"
|
|
39
|
-
return {image_key: obj.
|
|
46
|
+
return {image_key: obj.image.url}
|
|
40
47
|
return {}
|
|
41
48
|
|
|
42
49
|
def get_organizations(self, obj): # pylint: disable=unused-argument
|
|
@@ -87,6 +94,98 @@ class LearningPathGradeSerializer(serializers.Serializer):
|
|
|
87
94
|
required_grade = serializers.FloatField()
|
|
88
95
|
|
|
89
96
|
|
|
97
|
+
class LearningPathStepSerializer(serializers.ModelSerializer):
|
|
98
|
+
class Meta:
|
|
99
|
+
model = LearningPathStep
|
|
100
|
+
fields = ["order", "course_key", "due_date", "weight"]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class LearningPathListSerializer(serializers.ModelSerializer):
|
|
104
|
+
"""Serializer for the learning path list."""
|
|
105
|
+
|
|
106
|
+
steps = LearningPathStepSerializer(many=True, read_only=True)
|
|
107
|
+
required_completion = serializers.FloatField(
|
|
108
|
+
source="grading_criteria.required_completion", read_only=True
|
|
109
|
+
)
|
|
110
|
+
is_enrolled = serializers.SerializerMethodField()
|
|
111
|
+
invite_only = serializers.BooleanField()
|
|
112
|
+
image = serializers.ImageField(read_only=True)
|
|
113
|
+
|
|
114
|
+
class Meta:
|
|
115
|
+
model = LearningPath
|
|
116
|
+
fields = [
|
|
117
|
+
"key",
|
|
118
|
+
"display_name",
|
|
119
|
+
"image",
|
|
120
|
+
"sequential",
|
|
121
|
+
"steps",
|
|
122
|
+
"required_completion",
|
|
123
|
+
"is_enrolled",
|
|
124
|
+
"invite_only",
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
def get_is_enrolled(self, obj):
|
|
128
|
+
"""
|
|
129
|
+
Check if the current user is enrolled in this learning path.
|
|
130
|
+
"""
|
|
131
|
+
if hasattr(obj, "is_enrolled"):
|
|
132
|
+
return obj.is_enrolled
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class SkillSerializer(serializers.ModelSerializer):
|
|
137
|
+
class Meta:
|
|
138
|
+
model = Skill
|
|
139
|
+
fields = ["id", "display_name"]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class RequiredSkillSerializer(serializers.ModelSerializer):
|
|
143
|
+
"""
|
|
144
|
+
Serializer for required skill.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
skill = SkillSerializer()
|
|
148
|
+
|
|
149
|
+
class Meta:
|
|
150
|
+
model = RequiredSkill
|
|
151
|
+
fields = ["skill", "level"]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class AcquiredSkillSerializer(serializers.ModelSerializer):
|
|
155
|
+
"""
|
|
156
|
+
Serializer for acquired skill.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
skill = SkillSerializer()
|
|
160
|
+
|
|
161
|
+
class Meta:
|
|
162
|
+
model = AcquiredSkill
|
|
163
|
+
fields = ["skill", "level"]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class LearningPathDetailSerializer(LearningPathListSerializer):
|
|
167
|
+
"""
|
|
168
|
+
Serializer for learning path details.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
required_skills = RequiredSkillSerializer(
|
|
172
|
+
source="requiredskill_set", many=True, read_only=True
|
|
173
|
+
)
|
|
174
|
+
acquired_skills = AcquiredSkillSerializer(
|
|
175
|
+
source="acquiredskill_set", many=True, read_only=True
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
class Meta(LearningPathListSerializer.Meta):
|
|
179
|
+
fields = LearningPathListSerializer.Meta.fields + [
|
|
180
|
+
"subtitle",
|
|
181
|
+
"description",
|
|
182
|
+
"level",
|
|
183
|
+
"duration_in_days",
|
|
184
|
+
"required_skills",
|
|
185
|
+
"acquired_skills",
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
|
|
90
189
|
class LearningPathEnrollmentSerializer(serializers.ModelSerializer):
|
|
91
190
|
class Meta:
|
|
92
191
|
model = LearningPathEnrollment
|
{learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/urls.py
RENAMED
|
@@ -6,17 +6,20 @@ from rest_framework import routers
|
|
|
6
6
|
from learning_paths.api.v1.views import (
|
|
7
7
|
BulkEnrollView,
|
|
8
8
|
LearningPathAsProgramViewSet,
|
|
9
|
+
LearningPathCourseEnrollmentView,
|
|
9
10
|
LearningPathEnrollmentView,
|
|
10
11
|
LearningPathUserGradeView,
|
|
11
12
|
LearningPathUserProgressView,
|
|
13
|
+
LearningPathViewSet,
|
|
12
14
|
ListEnrollmentsView,
|
|
13
15
|
)
|
|
14
|
-
from learning_paths.keys import LEARNING_PATH_URL_PATTERN
|
|
16
|
+
from learning_paths.keys import COURSE_KEY_URL_PATTERN, LEARNING_PATH_URL_PATTERN
|
|
15
17
|
|
|
16
18
|
router = routers.SimpleRouter()
|
|
17
19
|
router.register(
|
|
18
20
|
r"programs", LearningPathAsProgramViewSet, basename="learning-path-as-program"
|
|
19
21
|
)
|
|
22
|
+
router.register(r"learning-paths", LearningPathViewSet, basename="learning-path")
|
|
20
23
|
|
|
21
24
|
urlpatterns = router.urls + [
|
|
22
25
|
re_path(
|
|
@@ -30,7 +33,7 @@ urlpatterns = router.urls + [
|
|
|
30
33
|
name="learning-path-grade",
|
|
31
34
|
),
|
|
32
35
|
re_path(
|
|
33
|
-
rf"{LEARNING_PATH_URL_PATTERN}/enrollments
|
|
36
|
+
rf"{LEARNING_PATH_URL_PATTERN}/enrollments/$",
|
|
34
37
|
LearningPathEnrollmentView.as_view(),
|
|
35
38
|
name="learning-path-enrollments",
|
|
36
39
|
),
|
|
@@ -44,4 +47,9 @@ urlpatterns = router.urls + [
|
|
|
44
47
|
BulkEnrollView.as_view(),
|
|
45
48
|
name="bulk-enroll",
|
|
46
49
|
),
|
|
50
|
+
re_path(
|
|
51
|
+
rf"{LEARNING_PATH_URL_PATTERN}/enrollments/{COURSE_KEY_URL_PATTERN}/",
|
|
52
|
+
LearningPathCourseEnrollmentView.as_view(),
|
|
53
|
+
name="learning-path-course-enroll",
|
|
54
|
+
),
|
|
47
55
|
]
|
{learning_paths_plugin-0.3.0rc1 → learning_paths_plugin-0.3.2}/learning_paths/api/v1/views.py
RENAMED
|
@@ -11,7 +11,9 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, Validat
|
|
|
11
11
|
from django.core.validators import validate_email
|
|
12
12
|
from django.shortcuts import get_object_or_404
|
|
13
13
|
from opaque_keys import InvalidKeyError
|
|
14
|
+
from opaque_keys.edx.keys import CourseKey
|
|
14
15
|
from rest_framework import generics, status, viewsets
|
|
16
|
+
from rest_framework.exceptions import NotFound, ParseError
|
|
15
17
|
from rest_framework.pagination import PageNumberPagination
|
|
16
18
|
from rest_framework.permissions import IsAdminUser, IsAuthenticated
|
|
17
19
|
from rest_framework.response import Response
|
|
@@ -19,10 +21,13 @@ from rest_framework.views import APIView
|
|
|
19
21
|
|
|
20
22
|
from learning_paths.api.v1.serializers import (
|
|
21
23
|
LearningPathAsProgramSerializer,
|
|
24
|
+
LearningPathDetailSerializer,
|
|
22
25
|
LearningPathEnrollmentSerializer,
|
|
23
26
|
LearningPathGradeSerializer,
|
|
27
|
+
LearningPathListSerializer,
|
|
24
28
|
LearningPathProgressSerializer,
|
|
25
29
|
)
|
|
30
|
+
from learning_paths.compat import enroll_user_in_course
|
|
26
31
|
from learning_paths.keys import LearningPathKey
|
|
27
32
|
from learning_paths.models import (
|
|
28
33
|
LearningPath,
|
|
@@ -48,11 +53,14 @@ class LearningPathAsProgramViewSet(viewsets.ReadOnlyModelViewSet):
|
|
|
48
53
|
https://github.com/openedx/course-discovery/blob/d6a57fd69479b3d5f5afb682d2668b58503a6af6/course_discovery/apps/course_metadata/data_loaders/api.py#L843
|
|
49
54
|
"""
|
|
50
55
|
|
|
51
|
-
queryset = LearningPath.objects.all()
|
|
52
56
|
permission_classes = (IsAuthenticated,)
|
|
53
57
|
serializer_class = LearningPathAsProgramSerializer
|
|
54
58
|
pagination_class = PageNumberPagination
|
|
55
59
|
|
|
60
|
+
def get_queryset(self):
|
|
61
|
+
"""Get the learning paths visible to the current user."""
|
|
62
|
+
return LearningPath.objects.get_paths_visible_to_user(self.request.user)
|
|
63
|
+
|
|
56
64
|
|
|
57
65
|
class LearningPathUserProgressView(APIView):
|
|
58
66
|
"""
|
|
@@ -65,8 +73,10 @@ class LearningPathUserProgressView(APIView):
|
|
|
65
73
|
"""
|
|
66
74
|
Fetch the learning path progress
|
|
67
75
|
"""
|
|
68
|
-
|
|
69
|
-
|
|
76
|
+
learning_path = get_object_or_404(
|
|
77
|
+
LearningPath.objects.get_paths_visible_to_user(self.request.user),
|
|
78
|
+
key=learning_path_key_str,
|
|
79
|
+
)
|
|
70
80
|
|
|
71
81
|
progress = get_aggregate_progress(request.user, learning_path)
|
|
72
82
|
required_completion = None
|
|
@@ -77,7 +87,7 @@ class LearningPathUserProgressView(APIView):
|
|
|
77
87
|
pass
|
|
78
88
|
|
|
79
89
|
data = {
|
|
80
|
-
"learning_path_key":
|
|
90
|
+
"learning_path_key": learning_path_key_str,
|
|
81
91
|
"progress": progress,
|
|
82
92
|
"required_completion": required_completion,
|
|
83
93
|
}
|
|
@@ -100,8 +110,10 @@ class LearningPathUserGradeView(APIView):
|
|
|
100
110
|
Fetch learning path grade
|
|
101
111
|
"""
|
|
102
112
|
|
|
103
|
-
|
|
104
|
-
|
|
113
|
+
learning_path = get_object_or_404(
|
|
114
|
+
LearningPath.objects.get_paths_visible_to_user(self.request.user),
|
|
115
|
+
key=learning_path_key_str,
|
|
116
|
+
)
|
|
105
117
|
|
|
106
118
|
try:
|
|
107
119
|
grading_criteria = learning_path.grading_criteria
|
|
@@ -114,7 +126,7 @@ class LearningPathUserGradeView(APIView):
|
|
|
114
126
|
grade = grading_criteria.calculate_grade(request.user)
|
|
115
127
|
|
|
116
128
|
data = {
|
|
117
|
-
"learning_path_key":
|
|
129
|
+
"learning_path_key": learning_path_key_str,
|
|
118
130
|
"grade": grade,
|
|
119
131
|
"required_grade": grading_criteria.required_grade,
|
|
120
132
|
}
|
|
@@ -125,6 +137,42 @@ class LearningPathUserGradeView(APIView):
|
|
|
125
137
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
126
138
|
|
|
127
139
|
|
|
140
|
+
class LearningPathViewSet(viewsets.ReadOnlyModelViewSet):
|
|
141
|
+
"""
|
|
142
|
+
ViewSet for listing all learning paths and retrieving a specific learning path's details,
|
|
143
|
+
including steps and associated skills.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
permission_classes = (IsAuthenticated,)
|
|
147
|
+
pagination_class = PageNumberPagination
|
|
148
|
+
lookup_field = "key"
|
|
149
|
+
|
|
150
|
+
def get_queryset(self):
|
|
151
|
+
"""
|
|
152
|
+
Get all learning paths and prefetch the related data.
|
|
153
|
+
"""
|
|
154
|
+
user = self.request.user
|
|
155
|
+
queryset = LearningPath.objects.get_paths_visible_to_user(
|
|
156
|
+
user
|
|
157
|
+
).prefetch_related(
|
|
158
|
+
"steps",
|
|
159
|
+
"grading_criteria",
|
|
160
|
+
)
|
|
161
|
+
return queryset
|
|
162
|
+
|
|
163
|
+
def get_serializer_class(self):
|
|
164
|
+
if self.action == "list":
|
|
165
|
+
return LearningPathListSerializer
|
|
166
|
+
return LearningPathDetailSerializer
|
|
167
|
+
|
|
168
|
+
def get_object(self):
|
|
169
|
+
"""Gracefully handle an invalid learning path key format."""
|
|
170
|
+
try:
|
|
171
|
+
return super().get_object()
|
|
172
|
+
except InvalidKeyError as exc:
|
|
173
|
+
raise NotFound("Invalid learning path key format.") from exc
|
|
174
|
+
|
|
175
|
+
|
|
128
176
|
class LearningPathEnrollmentView(APIView):
|
|
129
177
|
"""
|
|
130
178
|
API View to handle changes to LearningPathEnrollment model
|
|
@@ -132,6 +180,17 @@ class LearningPathEnrollmentView(APIView):
|
|
|
132
180
|
|
|
133
181
|
permission_classes = [IsAuthenticated, IsAdminOrSelf]
|
|
134
182
|
|
|
183
|
+
def _get_learning_path(self, learning_path_key_str: str) -> LearningPath:
|
|
184
|
+
"""
|
|
185
|
+
Get the learning path and verify user has access to it.
|
|
186
|
+
|
|
187
|
+
:raises: Http404 if the learning path is not found or user does not have access.
|
|
188
|
+
"""
|
|
189
|
+
return get_object_or_404(
|
|
190
|
+
LearningPath.objects.get_paths_visible_to_user(self.request.user),
|
|
191
|
+
key=learning_path_key_str,
|
|
192
|
+
)
|
|
193
|
+
|
|
135
194
|
def get(self, request, learning_path_key_str: str):
|
|
136
195
|
"""Get the learning path of users.
|
|
137
196
|
|
|
@@ -142,8 +201,7 @@ class LearningPathEnrollmentView(APIView):
|
|
|
142
201
|
username (optional): When provided it returns the enrollment for
|
|
143
202
|
the specified user.
|
|
144
203
|
"""
|
|
145
|
-
|
|
146
|
-
learning_path = get_object_or_404(LearningPath, key=learning_path_key)
|
|
204
|
+
learning_path = self._get_learning_path(learning_path_key_str)
|
|
147
205
|
|
|
148
206
|
enrollments = LearningPathEnrollment.objects.filter(
|
|
149
207
|
learning_path=learning_path, is_active=True
|
|
@@ -162,7 +220,7 @@ class LearningPathEnrollmentView(APIView):
|
|
|
162
220
|
"""Enroll learners in Learning Paths.
|
|
163
221
|
|
|
164
222
|
Staff/Admin can enroll anyone with the username query param.
|
|
165
|
-
Learners can enroll only themselves.
|
|
223
|
+
Learners can enroll only themselves, and only if the learning path is not invite-only.
|
|
166
224
|
|
|
167
225
|
Example payload::
|
|
168
226
|
|
|
@@ -171,8 +229,7 @@ class LearningPathEnrollmentView(APIView):
|
|
|
171
229
|
}
|
|
172
230
|
|
|
173
231
|
"""
|
|
174
|
-
|
|
175
|
-
learning_path = get_object_or_404(LearningPath, key=learning_path_key)
|
|
232
|
+
learning_path = self._get_learning_path(learning_path_key_str)
|
|
176
233
|
username = request.data.get("username")
|
|
177
234
|
user = get_object_or_404(User, username=username) if username else request.user
|
|
178
235
|
|
|
@@ -208,8 +265,7 @@ class LearningPathEnrollmentView(APIView):
|
|
|
208
265
|
}
|
|
209
266
|
|
|
210
267
|
"""
|
|
211
|
-
|
|
212
|
-
learning_path = get_object_or_404(LearningPath, key=learning_path_key)
|
|
268
|
+
learning_path = self._get_learning_path(learning_path_key_str)
|
|
213
269
|
username = request.data.get("username")
|
|
214
270
|
user = get_object_or_404(User, username=username) if username else request.user
|
|
215
271
|
|
|
@@ -342,3 +398,42 @@ class BulkEnrollView(APIView):
|
|
|
342
398
|
},
|
|
343
399
|
status=status.HTTP_201_CREATED,
|
|
344
400
|
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class LearningPathCourseEnrollmentView(APIView):
|
|
404
|
+
"""API View to enroll a user in a course that's part of a learning path."""
|
|
405
|
+
|
|
406
|
+
permission_classes = [IsAuthenticated, IsAdminOrSelf]
|
|
407
|
+
|
|
408
|
+
def _get_enrolled_learning_path(self, learning_path_key_str: str) -> LearningPath:
|
|
409
|
+
"""
|
|
410
|
+
Get the learning path and verify the user has access and is enrolled.
|
|
411
|
+
|
|
412
|
+
:raises: Http404 if the learning path is not found or the user does not have access.
|
|
413
|
+
"""
|
|
414
|
+
return get_object_or_404(
|
|
415
|
+
LearningPath.objects.get_paths_visible_to_user(self.request.user).filter(
|
|
416
|
+
is_enrolled=True
|
|
417
|
+
),
|
|
418
|
+
key=learning_path_key_str,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
def post(self, request, learning_path_key_str: str, course_key_str: str):
|
|
422
|
+
"""
|
|
423
|
+
Enroll a user in a course that's part of a learning path.
|
|
424
|
+
|
|
425
|
+
The user must be enrolled in the learning path, and the course must be a step in the path.
|
|
426
|
+
"""
|
|
427
|
+
learning_path = self._get_enrolled_learning_path(learning_path_key_str)
|
|
428
|
+
course_key = CourseKey.from_string(course_key_str)
|
|
429
|
+
|
|
430
|
+
if not learning_path.steps.filter(course_key=course_key).exists():
|
|
431
|
+
raise ParseError("The course is not part of this learning path.")
|
|
432
|
+
|
|
433
|
+
if enroll_user_in_course(request.user, course_key):
|
|
434
|
+
return Response(
|
|
435
|
+
{"detail": "User successfully enrolled in the course."},
|
|
436
|
+
status=status.HTTP_201_CREATED,
|
|
437
|
+
)
|
|
438
|
+
else:
|
|
439
|
+
raise ParseError("Failed to enroll the user in the course.")
|