learning-paths-plugin 0.2.3__tar.gz → 0.3.0rc1__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.2.3 → learning_paths_plugin-0.3.0rc1}/CHANGELOG.rst +9 -1
- {learning_paths_plugin-0.2.3/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.0rc1}/PKG-INFO +10 -2
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/__init__.py +1 -1
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/admin.py +10 -2
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/api/v1/serializers.py +2 -2
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/api/v1/urls.py +8 -7
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/api/v1/views.py +24 -21
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/apps.py +1 -0
- learning_paths_plugin-0.3.0rc1/learning_paths/keys.py +77 -0
- learning_paths_plugin-0.3.0rc1/learning_paths/migrations/0007_replace_uuid_with_learningpathkey.py +77 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/models.py +19 -2
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1/learning_paths_plugin.egg-info}/PKG-INFO +10 -2
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths_plugin.egg-info/SOURCES.txt +3 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths_plugin.egg-info/entry_points.txt +3 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/setup.py +3 -0
- learning_paths_plugin-0.3.0rc1/tests/test_keys.py +110 -0
- learning_paths_plugin-0.3.0rc1/tests/test_models.py +79 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/tests/test_receivers.py +3 -3
- learning_paths_plugin-0.2.3/tests/test_models.py +0 -15
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/LICENSE.txt +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/MANIFEST.in +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/README.rst +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/api/__init__.py +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/api/urls.py +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/api/v1/__init__.py +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/api/v1/filters.py +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/api/v1/permissions.py +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/api/v1/utils.py +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/compat.py +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/migrations/0001_initial.py +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/migrations/0002_learningpath_uuid.py +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/migrations/0003_learningpath_subtitle.py +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/migrations/0004_auto_20240207_1633.py +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/migrations/0006_enrollment_models.py +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/migrations/__init__.py +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/receivers.py +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/settings.py +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/templates/learning_paths/base.html +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/urls.py +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths_plugin.egg-info/dependency_links.txt +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths_plugin.egg-info/not-zip-safe +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths_plugin.egg-info/requires.txt +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths_plugin.egg-info/top_level.txt +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/pyproject.toml +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/requirements/base.in +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/requirements/constraints.txt +0 -0
- {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/setup.cfg +0 -0
|
@@ -16,6 +16,14 @@ Unreleased
|
|
|
16
16
|
|
|
17
17
|
*
|
|
18
18
|
|
|
19
|
+
0.3.0 - 2025-03-31
|
|
20
|
+
******************
|
|
21
|
+
|
|
22
|
+
Changed
|
|
23
|
+
=======
|
|
24
|
+
|
|
25
|
+
* Replaced Learning Path UUID with LearningPathKey.
|
|
26
|
+
|
|
19
27
|
0.2.3 - 2025-03-31
|
|
20
28
|
******************
|
|
21
29
|
|
|
@@ -38,7 +46,7 @@ Added
|
|
|
38
46
|
Added
|
|
39
47
|
=====
|
|
40
48
|
|
|
41
|
-
*
|
|
49
|
+
* Progress API
|
|
42
50
|
|
|
43
51
|
0.2.0 - 2024-01-23
|
|
44
52
|
******************
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning-paths-plugin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0rc1
|
|
4
4
|
Summary: Learning Paths plugin
|
|
5
5
|
Home-page: https://github.com/open-craft/learning-paths-plugin
|
|
6
6
|
Author: OpenCraft
|
|
@@ -120,6 +120,14 @@ Unreleased
|
|
|
120
120
|
|
|
121
121
|
*
|
|
122
122
|
|
|
123
|
+
0.3.0 - 2025-03-31
|
|
124
|
+
******************
|
|
125
|
+
|
|
126
|
+
Changed
|
|
127
|
+
=======
|
|
128
|
+
|
|
129
|
+
* Replaced Learning Path UUID with LearningPathKey.
|
|
130
|
+
|
|
123
131
|
0.2.3 - 2025-03-31
|
|
124
132
|
******************
|
|
125
133
|
|
|
@@ -142,7 +150,7 @@ Added
|
|
|
142
150
|
Added
|
|
143
151
|
=====
|
|
144
152
|
|
|
145
|
-
*
|
|
153
|
+
* Progress API
|
|
146
154
|
|
|
147
155
|
0.2.0 - 2024-01-23
|
|
148
156
|
******************
|
|
@@ -104,14 +104,16 @@ class LearningPathAdmin(admin.ModelAdmin):
|
|
|
104
104
|
search_fields = [
|
|
105
105
|
"slug",
|
|
106
106
|
"display_name",
|
|
107
|
+
"key",
|
|
107
108
|
]
|
|
108
109
|
list_display = (
|
|
109
|
-
"
|
|
110
|
+
"key",
|
|
110
111
|
"slug",
|
|
111
112
|
"display_name",
|
|
112
113
|
"level",
|
|
113
114
|
"duration_in_days",
|
|
114
115
|
)
|
|
116
|
+
readonly_fields = ("key",)
|
|
115
117
|
|
|
116
118
|
inlines = [
|
|
117
119
|
LearningPathStepInline,
|
|
@@ -120,6 +122,12 @@ class LearningPathAdmin(admin.ModelAdmin):
|
|
|
120
122
|
LearningPathGradingCriteriaInline,
|
|
121
123
|
]
|
|
122
124
|
|
|
125
|
+
def get_readonly_fields(self, request, obj=None):
|
|
126
|
+
"""Make key read-only only for existing objects."""
|
|
127
|
+
if obj: # Editing an existing object.
|
|
128
|
+
return self.readonly_fields
|
|
129
|
+
return () # Allow all fields during creation.
|
|
130
|
+
|
|
123
131
|
def save_related(self, request, form, formsets, change):
|
|
124
132
|
"""Save related objects and enroll users in the learning path."""
|
|
125
133
|
super().save_related(request, form, formsets, change)
|
|
@@ -144,7 +152,7 @@ class EnrolledUsersAdmin(admin.ModelAdmin):
|
|
|
144
152
|
search_fields = [
|
|
145
153
|
"id",
|
|
146
154
|
"user__username",
|
|
147
|
-
"
|
|
155
|
+
"learning_path__key",
|
|
148
156
|
"learning_path__slug",
|
|
149
157
|
"learning_path__display_name",
|
|
150
158
|
]
|
{learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/api/v1/serializers.py
RENAMED
|
@@ -72,7 +72,7 @@ class LearningPathAsProgramSerializer(serializers.ModelSerializer):
|
|
|
72
72
|
|
|
73
73
|
# pylint: disable=abstract-method
|
|
74
74
|
class LearningPathProgressSerializer(serializers.Serializer):
|
|
75
|
-
|
|
75
|
+
learning_path_key = serializers.CharField()
|
|
76
76
|
progress = serializers.FloatField()
|
|
77
77
|
required_completion = serializers.FloatField()
|
|
78
78
|
|
|
@@ -82,7 +82,7 @@ class LearningPathGradeSerializer(serializers.Serializer):
|
|
|
82
82
|
Serializer for learning path grade.
|
|
83
83
|
"""
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
learning_path_key = serializers.CharField()
|
|
86
86
|
grade = serializers.FloatField()
|
|
87
87
|
required_grade = serializers.FloatField()
|
|
88
88
|
|
{learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/api/v1/urls.py
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""API v1 URLs."""
|
|
2
2
|
|
|
3
|
-
from django.urls import path
|
|
3
|
+
from django.urls import path, re_path
|
|
4
4
|
from rest_framework import routers
|
|
5
5
|
|
|
6
6
|
from learning_paths.api.v1.views import (
|
|
@@ -11,6 +11,7 @@ from learning_paths.api.v1.views import (
|
|
|
11
11
|
LearningPathUserProgressView,
|
|
12
12
|
ListEnrollmentsView,
|
|
13
13
|
)
|
|
14
|
+
from learning_paths.keys import LEARNING_PATH_URL_PATTERN
|
|
14
15
|
|
|
15
16
|
router = routers.SimpleRouter()
|
|
16
17
|
router.register(
|
|
@@ -18,18 +19,18 @@ router.register(
|
|
|
18
19
|
)
|
|
19
20
|
|
|
20
21
|
urlpatterns = router.urls + [
|
|
21
|
-
|
|
22
|
-
"
|
|
22
|
+
re_path(
|
|
23
|
+
rf"{LEARNING_PATH_URL_PATTERN}/progress/",
|
|
23
24
|
LearningPathUserProgressView.as_view(),
|
|
24
25
|
name="learning-path-progress",
|
|
25
26
|
),
|
|
26
|
-
|
|
27
|
-
"
|
|
27
|
+
re_path(
|
|
28
|
+
rf"{LEARNING_PATH_URL_PATTERN}/grade/",
|
|
28
29
|
LearningPathUserGradeView.as_view(),
|
|
29
30
|
name="learning-path-grade",
|
|
30
31
|
),
|
|
31
|
-
|
|
32
|
-
"
|
|
32
|
+
re_path(
|
|
33
|
+
rf"{LEARNING_PATH_URL_PATTERN}/enrollments/",
|
|
33
34
|
LearningPathEnrollmentView.as_view(),
|
|
34
35
|
name="learning-path-enrollments",
|
|
35
36
|
),
|
{learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/api/v1/views.py
RENAMED
|
@@ -4,13 +4,13 @@ Views for LearningPath.
|
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
6
|
from datetime import datetime, timezone
|
|
7
|
-
from uuid import UUID
|
|
8
7
|
|
|
9
8
|
from django.conf import settings
|
|
10
9
|
from django.contrib.auth import get_user_model
|
|
11
10
|
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
|
|
12
11
|
from django.core.validators import validate_email
|
|
13
12
|
from django.shortcuts import get_object_or_404
|
|
13
|
+
from opaque_keys import InvalidKeyError
|
|
14
14
|
from rest_framework import generics, status, viewsets
|
|
15
15
|
from rest_framework.pagination import PageNumberPagination
|
|
16
16
|
from rest_framework.permissions import IsAdminUser, IsAuthenticated
|
|
@@ -23,6 +23,7 @@ from learning_paths.api.v1.serializers import (
|
|
|
23
23
|
LearningPathGradeSerializer,
|
|
24
24
|
LearningPathProgressSerializer,
|
|
25
25
|
)
|
|
26
|
+
from learning_paths.keys import LearningPathKey
|
|
26
27
|
from learning_paths.models import (
|
|
27
28
|
LearningPath,
|
|
28
29
|
LearningPathEnrollment,
|
|
@@ -60,11 +61,12 @@ class LearningPathUserProgressView(APIView):
|
|
|
60
61
|
|
|
61
62
|
permission_classes = (IsAuthenticated,)
|
|
62
63
|
|
|
63
|
-
def get(self, request,
|
|
64
|
+
def get(self, request, learning_path_key_str: str):
|
|
64
65
|
"""
|
|
65
66
|
Fetch the learning path progress
|
|
66
67
|
"""
|
|
67
|
-
|
|
68
|
+
learning_path_key = LearningPathKey.from_string(learning_path_key_str)
|
|
69
|
+
learning_path = get_object_or_404(LearningPath, key=learning_path_key)
|
|
68
70
|
|
|
69
71
|
progress = get_aggregate_progress(request.user, learning_path)
|
|
70
72
|
required_completion = None
|
|
@@ -75,7 +77,7 @@ class LearningPathUserProgressView(APIView):
|
|
|
75
77
|
pass
|
|
76
78
|
|
|
77
79
|
data = {
|
|
78
|
-
"
|
|
80
|
+
"learning_path_key": str(learning_path_key),
|
|
79
81
|
"progress": progress,
|
|
80
82
|
"required_completion": required_completion,
|
|
81
83
|
}
|
|
@@ -93,12 +95,13 @@ class LearningPathUserGradeView(APIView):
|
|
|
93
95
|
|
|
94
96
|
permission_classes = (IsAuthenticated,)
|
|
95
97
|
|
|
96
|
-
def get(self, request,
|
|
98
|
+
def get(self, request, learning_path_key_str: str):
|
|
97
99
|
"""
|
|
98
100
|
Fetch learning path grade
|
|
99
101
|
"""
|
|
100
102
|
|
|
101
|
-
|
|
103
|
+
learning_path_key = LearningPathKey.from_string(learning_path_key_str)
|
|
104
|
+
learning_path = get_object_or_404(LearningPath, key=learning_path_key)
|
|
102
105
|
|
|
103
106
|
try:
|
|
104
107
|
grading_criteria = learning_path.grading_criteria
|
|
@@ -111,7 +114,7 @@ class LearningPathUserGradeView(APIView):
|
|
|
111
114
|
grade = grading_criteria.calculate_grade(request.user)
|
|
112
115
|
|
|
113
116
|
data = {
|
|
114
|
-
"
|
|
117
|
+
"learning_path_key": str(learning_path_key),
|
|
115
118
|
"grade": grade,
|
|
116
119
|
"required_grade": grading_criteria.required_grade,
|
|
117
120
|
}
|
|
@@ -129,7 +132,7 @@ class LearningPathEnrollmentView(APIView):
|
|
|
129
132
|
|
|
130
133
|
permission_classes = [IsAuthenticated, IsAdminOrSelf]
|
|
131
134
|
|
|
132
|
-
def get(self, request,
|
|
135
|
+
def get(self, request, learning_path_key_str: str):
|
|
133
136
|
"""Get the learning path of users.
|
|
134
137
|
|
|
135
138
|
Staff/Admin can get all the active enrollments of the learning path.
|
|
@@ -139,7 +142,8 @@ class LearningPathEnrollmentView(APIView):
|
|
|
139
142
|
username (optional): When provided it returns the enrollment for
|
|
140
143
|
the specified user.
|
|
141
144
|
"""
|
|
142
|
-
|
|
145
|
+
learning_path_key = LearningPathKey.from_string(learning_path_key_str)
|
|
146
|
+
learning_path = get_object_or_404(LearningPath, key=learning_path_key)
|
|
143
147
|
|
|
144
148
|
enrollments = LearningPathEnrollment.objects.filter(
|
|
145
149
|
learning_path=learning_path, is_active=True
|
|
@@ -154,7 +158,7 @@ class LearningPathEnrollmentView(APIView):
|
|
|
154
158
|
serializer = LearningPathEnrollmentSerializer(enrollments.all(), many=True)
|
|
155
159
|
return Response(serializer.data)
|
|
156
160
|
|
|
157
|
-
def post(self, request,
|
|
161
|
+
def post(self, request, learning_path_key_str: str):
|
|
158
162
|
"""Enroll learners in Learning Paths.
|
|
159
163
|
|
|
160
164
|
Staff/Admin can enroll anyone with the username query param.
|
|
@@ -167,7 +171,8 @@ class LearningPathEnrollmentView(APIView):
|
|
|
167
171
|
}
|
|
168
172
|
|
|
169
173
|
"""
|
|
170
|
-
|
|
174
|
+
learning_path_key = LearningPathKey.from_string(learning_path_key_str)
|
|
175
|
+
learning_path = get_object_or_404(LearningPath, key=learning_path_key)
|
|
171
176
|
username = request.data.get("username")
|
|
172
177
|
user = get_object_or_404(User, username=username) if username else request.user
|
|
173
178
|
|
|
@@ -189,7 +194,7 @@ class LearningPathEnrollmentView(APIView):
|
|
|
189
194
|
enrollment.save()
|
|
190
195
|
return Response(LearningPathEnrollmentSerializer(enrollment).data)
|
|
191
196
|
|
|
192
|
-
def delete(self, request,
|
|
197
|
+
def delete(self, request, learning_path_key_str: str):
|
|
193
198
|
"""
|
|
194
199
|
Unenroll a learner from a learning path.
|
|
195
200
|
|
|
@@ -203,7 +208,8 @@ class LearningPathEnrollmentView(APIView):
|
|
|
203
208
|
}
|
|
204
209
|
|
|
205
210
|
"""
|
|
206
|
-
|
|
211
|
+
learning_path_key = LearningPathKey.from_string(learning_path_key_str)
|
|
212
|
+
learning_path = get_object_or_404(LearningPath, key=learning_path_key)
|
|
207
213
|
username = request.data.get("username")
|
|
208
214
|
user = get_object_or_404(User, username=username) if username else request.user
|
|
209
215
|
|
|
@@ -281,17 +287,14 @@ class BulkEnrollView(APIView):
|
|
|
281
287
|
valid_learning_paths_keys = []
|
|
282
288
|
for key in learning_paths_keys:
|
|
283
289
|
try:
|
|
284
|
-
|
|
285
|
-
|
|
290
|
+
LearningPathKey.from_string(key)
|
|
291
|
+
valid_learning_paths_keys.append(key)
|
|
292
|
+
except InvalidKeyError:
|
|
286
293
|
logger.warning("BulkEnrollView: Invalid learning path key: %s", key)
|
|
287
|
-
continue
|
|
288
|
-
valid_learning_paths_keys.append(key)
|
|
289
294
|
|
|
290
|
-
learning_paths = LearningPath.objects.filter(
|
|
291
|
-
uuid__in=valid_learning_paths_keys
|
|
292
|
-
).all()
|
|
295
|
+
learning_paths = LearningPath.objects.filter(key__in=valid_learning_paths_keys)
|
|
293
296
|
|
|
294
|
-
existing_users = User.objects.filter(email__in=emails)
|
|
297
|
+
existing_users = User.objects.filter(email__in=emails)
|
|
295
298
|
non_existing_emails = set(emails) - set(u.email for u in existing_users)
|
|
296
299
|
|
|
297
300
|
enrollments_created = []
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Keys and fields used by learning-paths-plugin.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
from django.core.exceptions import ValidationError
|
|
9
|
+
from opaque_keys import InvalidKeyError
|
|
10
|
+
from opaque_keys.edx.django.models import LearningContextKeyField
|
|
11
|
+
from opaque_keys.edx.keys import LearningContextKey
|
|
12
|
+
|
|
13
|
+
LEARNING_PATH_NAMESPACE = "path-v1"
|
|
14
|
+
LEARNING_PATH_PATTERN = r"([^+]+)\+([^+]+)\+([^+]+)\+([^+]+)"
|
|
15
|
+
LEARNING_PATH_URL_PATTERN = (
|
|
16
|
+
rf"(?P<learning_path_key_str>{LEARNING_PATH_NAMESPACE}:{LEARNING_PATH_PATTERN})"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LearningPathKey(LearningContextKey):
|
|
21
|
+
"""
|
|
22
|
+
A key for a learning path.
|
|
23
|
+
|
|
24
|
+
Format: path-v1:{name}+{number}+{run}+{group}
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
CANONICAL_NAMESPACE = LEARNING_PATH_NAMESPACE
|
|
28
|
+
KEY_FIELDS = ("org", "number", "run", "group")
|
|
29
|
+
CHECKED_INIT = False
|
|
30
|
+
|
|
31
|
+
__slots__ = KEY_FIELDS
|
|
32
|
+
|
|
33
|
+
_learing_path_key_regex = re.compile(rf"^{LEARNING_PATH_PATTERN}$")
|
|
34
|
+
|
|
35
|
+
def __init__(self, org, number, run, group):
|
|
36
|
+
"""Initialize a LearningPathKey instance."""
|
|
37
|
+
super().__init__(org=org, number=number, run=run, group=group)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def _from_string(cls, serialized: str) -> Self:
|
|
41
|
+
"""Return an instance of this class constructed from the given string."""
|
|
42
|
+
match = cls._learing_path_key_regex.match(serialized)
|
|
43
|
+
if not match:
|
|
44
|
+
raise InvalidKeyError(cls, serialized)
|
|
45
|
+
return cls(*match.groups())
|
|
46
|
+
|
|
47
|
+
def _to_string(self) -> str:
|
|
48
|
+
"""Return a string representing this key."""
|
|
49
|
+
return "+".join(
|
|
50
|
+
[self.org, self.number, self.run, self.group] # pylint: disable=no-member
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class LearningPathKeyField(LearningContextKeyField):
|
|
55
|
+
"""Field for storing LearningPathKey objects."""
|
|
56
|
+
|
|
57
|
+
description = "A LearningPathKey object"
|
|
58
|
+
KEY_CLASS = LearningPathKey
|
|
59
|
+
# Declare the field types for the django-stubs mypy type hint plugin:
|
|
60
|
+
_pyi_private_set_type: LearningPathKey | str | None
|
|
61
|
+
_pyi_private_get_type: LearningPathKey | None
|
|
62
|
+
|
|
63
|
+
def to_python(self, value):
|
|
64
|
+
"""Convert the input value to a LearningPathKey object."""
|
|
65
|
+
# TODO: https://github.com/open-craft/learning-paths-plugin/issues/12
|
|
66
|
+
if not value:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
if not value:
|
|
71
|
+
raise InvalidKeyError(self.KEY_CLASS, None)
|
|
72
|
+
|
|
73
|
+
return super().to_python(value)
|
|
74
|
+
except InvalidKeyError:
|
|
75
|
+
raise ValidationError( # pylint: disable=raise-missing-from
|
|
76
|
+
"Invalid format. Use: 'path-v1:{org}+{number}+{run}+{group}'"
|
|
77
|
+
)
|
learning_paths_plugin-0.3.0rc1/learning_paths/migrations/0007_replace_uuid_with_learningpathkey.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Generated by Django 4.2.16 on 2025-03-12 13:08
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
import learning_paths.keys
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def set_keys_from_uuids(apps, schema_editor):
|
|
9
|
+
"""
|
|
10
|
+
Assign unique keys to existing learning paths based on their UUIDs.
|
|
11
|
+
"""
|
|
12
|
+
LearningPath = apps.get_model("learning_paths", "LearningPath")
|
|
13
|
+
|
|
14
|
+
for learning_path in LearningPath.objects.all():
|
|
15
|
+
key_str = f"path-v1:test+test+test+{learning_path.uuid}"
|
|
16
|
+
learning_path.key = learning_paths.keys.LearningPathKey.from_string(key_str)
|
|
17
|
+
learning_path.save(update_fields=["key"])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def reverse_migration(apps, schema_editor):
|
|
21
|
+
"""
|
|
22
|
+
No reverse operation needed
|
|
23
|
+
"""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Migration(migrations.Migration):
|
|
28
|
+
dependencies = [
|
|
29
|
+
("learning_paths", "0006_enrollment_models"),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
operations = [
|
|
33
|
+
migrations.AddField(
|
|
34
|
+
model_name="learningpath",
|
|
35
|
+
name="key",
|
|
36
|
+
field=learning_paths.keys.LearningPathKeyField(
|
|
37
|
+
db_index=True,
|
|
38
|
+
help_text="Unique identifier for this Learning Path.<br/>It must follow the format: <i>path-v1:{org}+{number}+{run}+{group}</i>.",
|
|
39
|
+
max_length=255,
|
|
40
|
+
null=True, # Temporarily allow nulls.
|
|
41
|
+
unique=True,
|
|
42
|
+
),
|
|
43
|
+
preserve_default=False,
|
|
44
|
+
),
|
|
45
|
+
migrations.RunPython(set_keys_from_uuids, reverse_migration),
|
|
46
|
+
migrations.AlterField(
|
|
47
|
+
model_name="learningpath",
|
|
48
|
+
name="key",
|
|
49
|
+
field=learning_paths.keys.LearningPathKeyField(
|
|
50
|
+
db_index=True,
|
|
51
|
+
help_text="Unique identifier for this Learning Path.<br/>It must follow the format: <i>path-v1:{org}+{number}+{run}+{group}</i>.",
|
|
52
|
+
max_length=255,
|
|
53
|
+
unique=True,
|
|
54
|
+
),
|
|
55
|
+
preserve_default=False,
|
|
56
|
+
),
|
|
57
|
+
migrations.AlterField(
|
|
58
|
+
model_name="learningpath",
|
|
59
|
+
name="uuid",
|
|
60
|
+
field=models.UUIDField(
|
|
61
|
+
blank=True,
|
|
62
|
+
default=uuid.uuid4,
|
|
63
|
+
editable=False,
|
|
64
|
+
help_text="Legacy identifier for compatibility with Course Discovery.",
|
|
65
|
+
unique=True,
|
|
66
|
+
),
|
|
67
|
+
),
|
|
68
|
+
migrations.AlterField(
|
|
69
|
+
model_name="learningpath",
|
|
70
|
+
name="sequential",
|
|
71
|
+
field=models.BooleanField(
|
|
72
|
+
default=False,
|
|
73
|
+
help_text="Whether the courses in this Learning Path are meant to be taken sequentially.",
|
|
74
|
+
verbose_name="Is sequential",
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
]
|
|
@@ -14,6 +14,7 @@ from opaque_keys.edx.django.models import CourseKeyField
|
|
|
14
14
|
from simple_history.models import HistoricalRecords
|
|
15
15
|
|
|
16
16
|
from .compat import get_user_course_grade
|
|
17
|
+
from .keys import LearningPathKeyField
|
|
17
18
|
|
|
18
19
|
User = auth.get_user_model()
|
|
19
20
|
|
|
@@ -31,9 +32,24 @@ class LearningPath(TimeStampedModel):
|
|
|
31
32
|
.. no_pii:
|
|
32
33
|
"""
|
|
33
34
|
|
|
35
|
+
key = LearningPathKeyField(
|
|
36
|
+
max_length=255,
|
|
37
|
+
unique=True,
|
|
38
|
+
db_index=True,
|
|
39
|
+
help_text=_(
|
|
40
|
+
"Unique identifier for this Learning Path.<br/>"
|
|
41
|
+
"It must follow the format: <i>path-v1:{org}+{number}+{run}+{group}</i>."
|
|
42
|
+
),
|
|
43
|
+
)
|
|
34
44
|
# LearningPath is consumed as a course-discovery Program.
|
|
35
|
-
# Programs are identified by UUIDs
|
|
36
|
-
uuid = models.UUIDField(
|
|
45
|
+
# Programs are identified by UUIDs, which is why we must have this UUID field.
|
|
46
|
+
uuid = models.UUIDField(
|
|
47
|
+
blank=True,
|
|
48
|
+
default=uuid4,
|
|
49
|
+
editable=False,
|
|
50
|
+
unique=True,
|
|
51
|
+
help_text=_("Legacy identifier for compatibility with Course Discovery."),
|
|
52
|
+
)
|
|
37
53
|
slug = models.SlugField(
|
|
38
54
|
db_index=True,
|
|
39
55
|
unique=True,
|
|
@@ -60,6 +76,7 @@ class LearningPath(TimeStampedModel):
|
|
|
60
76
|
),
|
|
61
77
|
)
|
|
62
78
|
sequential = models.BooleanField(
|
|
79
|
+
default=False,
|
|
63
80
|
verbose_name=_("Is sequential"),
|
|
64
81
|
help_text=_(
|
|
65
82
|
"Whether the courses in this Learning Path are meant to be taken sequentially."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning-paths-plugin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0rc1
|
|
4
4
|
Summary: Learning Paths plugin
|
|
5
5
|
Home-page: https://github.com/open-craft/learning-paths-plugin
|
|
6
6
|
Author: OpenCraft
|
|
@@ -120,6 +120,14 @@ Unreleased
|
|
|
120
120
|
|
|
121
121
|
*
|
|
122
122
|
|
|
123
|
+
0.3.0 - 2025-03-31
|
|
124
|
+
******************
|
|
125
|
+
|
|
126
|
+
Changed
|
|
127
|
+
=======
|
|
128
|
+
|
|
129
|
+
* Replaced Learning Path UUID with LearningPathKey.
|
|
130
|
+
|
|
123
131
|
0.2.3 - 2025-03-31
|
|
124
132
|
******************
|
|
125
133
|
|
|
@@ -142,7 +150,7 @@ Added
|
|
|
142
150
|
Added
|
|
143
151
|
=====
|
|
144
152
|
|
|
145
|
-
*
|
|
153
|
+
* Progress API
|
|
146
154
|
|
|
147
155
|
0.2.0 - 2024-01-23
|
|
148
156
|
******************
|
|
@@ -9,6 +9,7 @@ learning_paths/__init__.py
|
|
|
9
9
|
learning_paths/admin.py
|
|
10
10
|
learning_paths/apps.py
|
|
11
11
|
learning_paths/compat.py
|
|
12
|
+
learning_paths/keys.py
|
|
12
13
|
learning_paths/models.py
|
|
13
14
|
learning_paths/receivers.py
|
|
14
15
|
learning_paths/settings.py
|
|
@@ -28,6 +29,7 @@ learning_paths/migrations/0003_learningpath_subtitle.py
|
|
|
28
29
|
learning_paths/migrations/0004_auto_20240207_1633.py
|
|
29
30
|
learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py
|
|
30
31
|
learning_paths/migrations/0006_enrollment_models.py
|
|
32
|
+
learning_paths/migrations/0007_replace_uuid_with_learningpathkey.py
|
|
31
33
|
learning_paths/migrations/__init__.py
|
|
32
34
|
learning_paths/templates/learning_paths/base.html
|
|
33
35
|
learning_paths_plugin.egg-info/PKG-INFO
|
|
@@ -39,5 +41,6 @@ learning_paths_plugin.egg-info/requires.txt
|
|
|
39
41
|
learning_paths_plugin.egg-info/top_level.txt
|
|
40
42
|
requirements/base.in
|
|
41
43
|
requirements/constraints.txt
|
|
44
|
+
tests/test_keys.py
|
|
42
45
|
tests/test_models.py
|
|
43
46
|
tests/test_receivers.py
|
|
@@ -162,6 +162,9 @@ setup(
|
|
|
162
162
|
"lms.djangoapp": [
|
|
163
163
|
"learning_paths = learning_paths.apps:LearningPathsConfig",
|
|
164
164
|
],
|
|
165
|
+
"context_key": [
|
|
166
|
+
"path-v1 = learning_paths.keys:LearningPathKey",
|
|
167
|
+
],
|
|
165
168
|
},
|
|
166
169
|
include_package_data=True,
|
|
167
170
|
install_requires=load_requirements("requirements/base.in"),
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for the learning-paths-plugin keys module.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from django.core.exceptions import ValidationError
|
|
7
|
+
from opaque_keys import InvalidKeyError
|
|
8
|
+
|
|
9
|
+
from learning_paths.keys import LearningPathKey, LearningPathKeyField
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestLearningPathKey:
|
|
13
|
+
"""Tests for LearningPathKey class."""
|
|
14
|
+
|
|
15
|
+
# pylint: disable=no-member
|
|
16
|
+
def test_create_key(self):
|
|
17
|
+
"""Test creation of a valid key."""
|
|
18
|
+
key = LearningPathKey("org", "number", "run", "group")
|
|
19
|
+
assert key.org == "org"
|
|
20
|
+
assert key.number == "number"
|
|
21
|
+
assert key.run == "run"
|
|
22
|
+
assert key.group == "group"
|
|
23
|
+
assert key.CANONICAL_NAMESPACE == "path-v1"
|
|
24
|
+
|
|
25
|
+
def test_key_from_string(self):
|
|
26
|
+
"""Test creating a key from a string."""
|
|
27
|
+
key_str = "path-v1:org+number+run+group"
|
|
28
|
+
assert LearningPathKey.from_string(key_str) == LearningPathKey(
|
|
29
|
+
"org", "number", "run", "group"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def test_key_to_string(self):
|
|
33
|
+
"""Test serializing a key to a string."""
|
|
34
|
+
key = LearningPathKey("org", "number", "run", "group")
|
|
35
|
+
assert str(key) == "path-v1:org+number+run+group"
|
|
36
|
+
|
|
37
|
+
@pytest.mark.parametrize(
|
|
38
|
+
"key_str",
|
|
39
|
+
[
|
|
40
|
+
"path-v1:invalid_key_format",
|
|
41
|
+
"path-v1:org+number+run+group+extra", # Extra part
|
|
42
|
+
"path-v1:org+number+run", # Missing group
|
|
43
|
+
"number+run+group", # Missing namespace
|
|
44
|
+
],
|
|
45
|
+
)
|
|
46
|
+
def test_invalid_key_string(self, key_str):
|
|
47
|
+
"""Test that an invalid key string raises an error."""
|
|
48
|
+
with pytest.raises(InvalidKeyError):
|
|
49
|
+
LearningPathKey.from_string(key_str)
|
|
50
|
+
|
|
51
|
+
def test_key_equality(self):
|
|
52
|
+
"""Test that equal keys compare as equal."""
|
|
53
|
+
key1 = LearningPathKey("org", "number", "run", "group")
|
|
54
|
+
key2 = LearningPathKey("org", "number", "run", "group")
|
|
55
|
+
key3 = LearningPathKey("org", "different", "run", "group")
|
|
56
|
+
|
|
57
|
+
assert key1 == key2
|
|
58
|
+
assert key1 != key3
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestLearningPathKeyField:
|
|
62
|
+
"""Tests for LearningPathKeyField class."""
|
|
63
|
+
|
|
64
|
+
def test_to_python_with_none(self):
|
|
65
|
+
"""Test that None is returned for empty values."""
|
|
66
|
+
field = LearningPathKeyField()
|
|
67
|
+
assert field.to_python(None) is None
|
|
68
|
+
assert field.to_python("") is None
|
|
69
|
+
|
|
70
|
+
def test_to_python_with_key_object(self):
|
|
71
|
+
"""Test that a key object is returned as-is."""
|
|
72
|
+
field = LearningPathKeyField()
|
|
73
|
+
key = LearningPathKey("org", "number", "run", "group")
|
|
74
|
+
assert field.to_python(key) is key
|
|
75
|
+
|
|
76
|
+
def test_to_python_with_valid_string(self):
|
|
77
|
+
"""Test conversion of a valid string to a key."""
|
|
78
|
+
field = LearningPathKeyField()
|
|
79
|
+
key_str = "path-v1:org+number+run+group"
|
|
80
|
+
key = field.to_python(key_str)
|
|
81
|
+
|
|
82
|
+
assert isinstance(key, LearningPathKey)
|
|
83
|
+
assert key == LearningPathKey.from_string(key_str)
|
|
84
|
+
|
|
85
|
+
@pytest.mark.parametrize(
|
|
86
|
+
"key_str",
|
|
87
|
+
[
|
|
88
|
+
"path-v1:invalid_key_format",
|
|
89
|
+
"path-v1:org+number+run+group+extra", # Extra part
|
|
90
|
+
"path-v1:org+number+run", # Missing group
|
|
91
|
+
"number+run+group", # Missing namespace
|
|
92
|
+
],
|
|
93
|
+
)
|
|
94
|
+
def test_to_python_with_invalid_string(self, key_str):
|
|
95
|
+
"""Test that an invalid string raises a ValidationError."""
|
|
96
|
+
field = LearningPathKeyField()
|
|
97
|
+
|
|
98
|
+
with pytest.raises(ValidationError):
|
|
99
|
+
field.to_python(key_str)
|
|
100
|
+
|
|
101
|
+
def test_to_python_validation_error_message(self):
|
|
102
|
+
"""Test that the validation error message is as expected."""
|
|
103
|
+
field = LearningPathKeyField()
|
|
104
|
+
|
|
105
|
+
with pytest.raises(ValidationError) as excinfo:
|
|
106
|
+
field.to_python("invalid_key_format")
|
|
107
|
+
|
|
108
|
+
assert "Invalid format. Use: 'path-v1:{org}+{number}+{run}+{group}'" in str(
|
|
109
|
+
excinfo.value
|
|
110
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for the learning_paths models.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
# pylint: disable=redefined-outer-name,unused-argument
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from django.core.exceptions import ValidationError
|
|
9
|
+
from django.db import IntegrityError
|
|
10
|
+
|
|
11
|
+
from learning_paths.keys import LearningPathKey
|
|
12
|
+
from learning_paths.models import LearningPath
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def learning_path_key():
|
|
17
|
+
"""Create a learning path key for testing."""
|
|
18
|
+
return LearningPathKey("org", "number", "run", "group")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def learning_path(learning_path_key):
|
|
23
|
+
"""Create a basic learning path for tests."""
|
|
24
|
+
return LearningPath.objects.create(
|
|
25
|
+
key=learning_path_key,
|
|
26
|
+
slug="test-path",
|
|
27
|
+
display_name="Test Learning Path",
|
|
28
|
+
subtitle="Test Subtitle",
|
|
29
|
+
description="Test description",
|
|
30
|
+
level="intermediate",
|
|
31
|
+
duration_in_days=30,
|
|
32
|
+
sequential=True,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.django_db
|
|
37
|
+
class TestLearningPath:
|
|
38
|
+
"""Tests for the LearningPath model."""
|
|
39
|
+
|
|
40
|
+
def test_creation(self, learning_path):
|
|
41
|
+
"""Test creating a learning path."""
|
|
42
|
+
assert learning_path.display_name == "Test Learning Path"
|
|
43
|
+
assert learning_path.slug == "test-path"
|
|
44
|
+
assert learning_path.sequential is True
|
|
45
|
+
|
|
46
|
+
def test_string_representation(self, learning_path):
|
|
47
|
+
"""Test the string representation."""
|
|
48
|
+
assert str(learning_path) == "Test Learning Path"
|
|
49
|
+
|
|
50
|
+
def test_uuid_auto_generation(self, learning_path_key):
|
|
51
|
+
"""Test that the UUID is auto-generated."""
|
|
52
|
+
path = LearningPath.objects.create(key=learning_path_key)
|
|
53
|
+
assert path.uuid is not None
|
|
54
|
+
|
|
55
|
+
# TODO: https://github.com/open-craft/learning-paths-plugin/issues/12
|
|
56
|
+
@pytest.mark.skip(reason="UUID migration incomplete")
|
|
57
|
+
def test_key_required(self, learning_path_key):
|
|
58
|
+
"""Test that key is required."""
|
|
59
|
+
with pytest.raises(ValidationError):
|
|
60
|
+
LearningPath.objects.create()
|
|
61
|
+
|
|
62
|
+
def test_unique_key(self, learning_path, learning_path_key):
|
|
63
|
+
"""Test that key must be unique."""
|
|
64
|
+
with pytest.raises(
|
|
65
|
+
IntegrityError,
|
|
66
|
+
match="UNIQUE constraint failed: learning_paths_learningpath.key",
|
|
67
|
+
):
|
|
68
|
+
LearningPath.objects.create(key=learning_path_key)
|
|
69
|
+
|
|
70
|
+
def test_unique_slug(self, learning_path, learning_path_key):
|
|
71
|
+
"""Test that slug must be unique."""
|
|
72
|
+
with pytest.raises(
|
|
73
|
+
IntegrityError,
|
|
74
|
+
match="UNIQUE constraint failed: learning_paths_learningpath.slug",
|
|
75
|
+
):
|
|
76
|
+
LearningPath.objects.create(
|
|
77
|
+
key=LearningPathKey("org2", "number2", "run2", "group2"),
|
|
78
|
+
slug=learning_path.slug,
|
|
79
|
+
)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from django.contrib.auth import get_user_model
|
|
4
4
|
from django.test import TestCase
|
|
5
5
|
|
|
6
|
-
from learning_paths.api.v1.tests.factories import
|
|
6
|
+
from learning_paths.api.v1.tests.factories import LearningPathFactory
|
|
7
7
|
from learning_paths.models import LearningPathEnrollment, LearningPathEnrollmentAllowed
|
|
8
8
|
from learning_paths.receivers import process_pending_enrollments
|
|
9
9
|
|
|
@@ -17,8 +17,8 @@ class TestProcessPendingEnrollments(TestCase):
|
|
|
17
17
|
|
|
18
18
|
def setUp(self):
|
|
19
19
|
self.user_email = "test@example.com"
|
|
20
|
-
self.learning_path_1 =
|
|
21
|
-
self.learning_path_2 =
|
|
20
|
+
self.learning_path_1 = LearningPathFactory()
|
|
21
|
+
self.learning_path_2 = LearningPathFactory()
|
|
22
22
|
|
|
23
23
|
def test_process_pending_enrollments_with_pending_enrollments(self):
|
|
24
24
|
"""
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python
|
|
2
|
-
"""
|
|
3
|
-
Tests for the `learning-paths-plugin` models module.
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
import pytest
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
@pytest.mark.skip(
|
|
10
|
-
reason="Placeholder to allow pytest to succeed before real tests are in place."
|
|
11
|
-
)
|
|
12
|
-
def test_placeholder():
|
|
13
|
-
"""
|
|
14
|
-
TODO: Delete this test once there are real tests.
|
|
15
|
-
"""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/api/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/api/v1/__init__.py
RENAMED
|
File without changes
|
{learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/api/v1/filters.py
RENAMED
|
File without changes
|
{learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/api/v1/permissions.py
RENAMED
|
File without changes
|
{learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/api/v1/utils.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.2.3 → learning_paths_plugin-0.3.0rc1}/learning_paths/migrations/__init__.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
|