learning-paths-plugin 0.2.3__py2.py3-none-any.whl
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/__init__.py +5 -0
- learning_paths/admin.py +155 -0
- learning_paths/api/__init__.py +0 -0
- learning_paths/api/urls.py +7 -0
- learning_paths/api/v1/__init__.py +0 -0
- learning_paths/api/v1/filters.py +16 -0
- learning_paths/api/v1/permissions.py +27 -0
- learning_paths/api/v1/serializers.py +93 -0
- learning_paths/api/v1/urls.py +46 -0
- learning_paths/api/v1/utils.py +65 -0
- learning_paths/api/v1/views.py +341 -0
- learning_paths/apps.py +52 -0
- learning_paths/compat.py +48 -0
- learning_paths/migrations/0001_initial.py +93 -0
- learning_paths/migrations/0002_learningpath_uuid.py +19 -0
- learning_paths/migrations/0003_learningpath_subtitle.py +18 -0
- learning_paths/migrations/0004_auto_20240207_1633.py +36 -0
- learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py +29 -0
- learning_paths/migrations/0006_enrollment_models.py +66 -0
- learning_paths/migrations/__init__.py +0 -0
- learning_paths/models.py +283 -0
- learning_paths/receivers.py +52 -0
- learning_paths/settings.py +15 -0
- learning_paths/templates/learning_paths/base.html +26 -0
- learning_paths/urls.py +9 -0
- learning_paths_plugin-0.2.3.dist-info/METADATA +154 -0
- learning_paths_plugin-0.2.3.dist-info/RECORD +31 -0
- learning_paths_plugin-0.2.3.dist-info/WHEEL +6 -0
- learning_paths_plugin-0.2.3.dist-info/entry_points.txt +2 -0
- learning_paths_plugin-0.2.3.dist-info/licenses/LICENSE.txt +1 -0
- learning_paths_plugin-0.2.3.dist-info/top_level.txt +1 -0
learning_paths/admin.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django Admin for learning_paths.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from django import forms
|
|
6
|
+
from django.contrib import admin, auth
|
|
7
|
+
from django.core.exceptions import ValidationError
|
|
8
|
+
from django.db import transaction
|
|
9
|
+
from django.utils.translation import gettext_lazy as _
|
|
10
|
+
|
|
11
|
+
from .compat import get_course_keys_with_outlines
|
|
12
|
+
from .models import (
|
|
13
|
+
AcquiredSkill,
|
|
14
|
+
LearningPath,
|
|
15
|
+
LearningPathEnrollment,
|
|
16
|
+
LearningPathGradingCriteria,
|
|
17
|
+
LearningPathStep,
|
|
18
|
+
RequiredSkill,
|
|
19
|
+
Skill,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
User = auth.get_user_model()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_course_keys_choices():
|
|
26
|
+
"""Get course keys in an adequate format for a choice field."""
|
|
27
|
+
yield None, ""
|
|
28
|
+
for key in get_course_keys_with_outlines():
|
|
29
|
+
yield key, key
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LearningPathStepForm(forms.ModelForm):
|
|
33
|
+
"""Admin form for Learning Path step."""
|
|
34
|
+
|
|
35
|
+
# TODO: Use autocomplete select instead.
|
|
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"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LearningPathStepInline(admin.TabularInline):
|
|
42
|
+
"""Inline Admin for Learning Path step."""
|
|
43
|
+
|
|
44
|
+
model = LearningPathStep
|
|
45
|
+
form = LearningPathStepForm
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AcquiredSkillInline(admin.TabularInline):
|
|
49
|
+
"""Inline Admin for Learning Path acquired skill."""
|
|
50
|
+
|
|
51
|
+
model = AcquiredSkill
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RequiredSkillInline(admin.TabularInline):
|
|
55
|
+
"""Inline Admin for Learning Path required skill."""
|
|
56
|
+
|
|
57
|
+
model = RequiredSkill
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class LearningPathGradingCriteriaInline(admin.TabularInline):
|
|
61
|
+
"""Inline Admin for Learning path grading criteria."""
|
|
62
|
+
|
|
63
|
+
model = LearningPathGradingCriteria
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class BulkEnrollUsersForm(forms.ModelForm):
|
|
67
|
+
"""Form to bulk enroll users in a learning path."""
|
|
68
|
+
|
|
69
|
+
usernames = forms.CharField(
|
|
70
|
+
widget=forms.Textarea,
|
|
71
|
+
help_text="Enter usernames separated by newlines",
|
|
72
|
+
label="Bulk enroll users",
|
|
73
|
+
required=False,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
class Meta:
|
|
77
|
+
"""Form options."""
|
|
78
|
+
|
|
79
|
+
model = LearningPath
|
|
80
|
+
fields = "__all__"
|
|
81
|
+
|
|
82
|
+
def clean_usernames(self):
|
|
83
|
+
"""Validate usernames and return a list of users."""
|
|
84
|
+
data = self.cleaned_data["usernames"]
|
|
85
|
+
if not data:
|
|
86
|
+
return []
|
|
87
|
+
usernames = [username.strip() for username in data.split("\n")]
|
|
88
|
+
users = User.objects.filter(username__in=usernames)
|
|
89
|
+
found_usernames = list(users.values_list("username", flat=True))
|
|
90
|
+
invalid_usernames = set(usernames) - set(found_usernames)
|
|
91
|
+
if invalid_usernames:
|
|
92
|
+
raise ValidationError(
|
|
93
|
+
f"The following usernames are not valid: {', '.join(invalid_usernames)}"
|
|
94
|
+
)
|
|
95
|
+
return users
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class LearningPathAdmin(admin.ModelAdmin):
|
|
99
|
+
"""Admin for Learning Path."""
|
|
100
|
+
|
|
101
|
+
model = LearningPath
|
|
102
|
+
form = BulkEnrollUsersForm
|
|
103
|
+
|
|
104
|
+
search_fields = [
|
|
105
|
+
"slug",
|
|
106
|
+
"display_name",
|
|
107
|
+
]
|
|
108
|
+
list_display = (
|
|
109
|
+
"uuid",
|
|
110
|
+
"slug",
|
|
111
|
+
"display_name",
|
|
112
|
+
"level",
|
|
113
|
+
"duration_in_days",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
inlines = [
|
|
117
|
+
LearningPathStepInline,
|
|
118
|
+
RequiredSkillInline,
|
|
119
|
+
AcquiredSkillInline,
|
|
120
|
+
LearningPathGradingCriteriaInline,
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
def save_related(self, request, form, formsets, change):
|
|
124
|
+
"""Save related objects and enroll users in the learning path."""
|
|
125
|
+
super().save_related(request, form, formsets, change)
|
|
126
|
+
with transaction.atomic():
|
|
127
|
+
for user in form.cleaned_data["usernames"]:
|
|
128
|
+
LearningPathEnrollment.objects.get_or_create(
|
|
129
|
+
user=user, learning_path=form.instance
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class SkillAdmin(admin.ModelAdmin):
|
|
134
|
+
"""Admin for Learning Path generic skill."""
|
|
135
|
+
|
|
136
|
+
model = Skill
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class EnrolledUsersAdmin(admin.ModelAdmin):
|
|
140
|
+
"""Admin for Learning Path enrollment."""
|
|
141
|
+
|
|
142
|
+
model = LearningPathEnrollment
|
|
143
|
+
|
|
144
|
+
search_fields = [
|
|
145
|
+
"id",
|
|
146
|
+
"user__username",
|
|
147
|
+
"learning_path__uuid",
|
|
148
|
+
"learning_path__slug",
|
|
149
|
+
"learning_path__display_name",
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
admin.site.register(LearningPath, LearningPathAdmin)
|
|
154
|
+
admin.site.register(Skill, SkillAdmin)
|
|
155
|
+
admin.site.register(LearningPathEnrollment, EnrolledUsersAdmin)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django REST framework filters.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from rest_framework.filters import BaseFilterBackend
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AdminOrSelfFilterBackend(BaseFilterBackend):
|
|
9
|
+
"""
|
|
10
|
+
A filter backend that limits the queryset to the current user for non-staff.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def filter_queryset(self, request, queryset, view):
|
|
14
|
+
if request.user.is_staff:
|
|
15
|
+
return queryset
|
|
16
|
+
return queryset.filter(user=request.user)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django REST framework permissions.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from rest_framework.permissions import BasePermission
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class IsAdminOrSelf(BasePermission):
|
|
9
|
+
"""
|
|
10
|
+
Permission to allow only admins or the user themselves to access the API.
|
|
11
|
+
|
|
12
|
+
Non-staff users cannot pass "username" that is not their own.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def has_permission(self, request, view):
|
|
16
|
+
if request.user.is_staff:
|
|
17
|
+
return True
|
|
18
|
+
|
|
19
|
+
if request.method == "GET":
|
|
20
|
+
username = request.query_params.get("username")
|
|
21
|
+
else:
|
|
22
|
+
username = request.data.get("username")
|
|
23
|
+
|
|
24
|
+
# For learners, the username passed should match the logged in user
|
|
25
|
+
if username:
|
|
26
|
+
return request.user.username == username
|
|
27
|
+
return True
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Serializer for LearningPath.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from rest_framework import serializers
|
|
6
|
+
|
|
7
|
+
from learning_paths.models import LearningPath, LearningPathEnrollment
|
|
8
|
+
|
|
9
|
+
DEFAULT_STATUS = "active"
|
|
10
|
+
IMAGE_WIDTH = 1440
|
|
11
|
+
IMAGE_HEIGHT = 480
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LearningPathAsProgramSerializer(serializers.ModelSerializer):
|
|
15
|
+
"""
|
|
16
|
+
Serialize LearningPath as a Program to be ingested by course-discovery.
|
|
17
|
+
|
|
18
|
+
Mocked data example:
|
|
19
|
+
https://github.com/openedx/course-discovery/blob/d6a57fd69479b3d5f5afb682d2668b58503a6af6/course_discovery/apps/course_metadata/data_loaders/tests/mock_data.py#L580
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
name = serializers.CharField(source="display_name")
|
|
23
|
+
marketing_slug = serializers.SerializerMethodField()
|
|
24
|
+
title = serializers.CharField(source="display_name")
|
|
25
|
+
status = serializers.SerializerMethodField()
|
|
26
|
+
banner_image_urls = serializers.SerializerMethodField()
|
|
27
|
+
organizations = serializers.SerializerMethodField()
|
|
28
|
+
course_codes = serializers.SerializerMethodField()
|
|
29
|
+
|
|
30
|
+
def get_marketing_slug(self, obj):
|
|
31
|
+
return obj.slug
|
|
32
|
+
|
|
33
|
+
def get_status(self, obj): # pylint: disable=unused-argument
|
|
34
|
+
return DEFAULT_STATUS
|
|
35
|
+
|
|
36
|
+
def get_banner_image_urls(self, obj):
|
|
37
|
+
if obj.image_url:
|
|
38
|
+
image_key = f"w{IMAGE_WIDTH}h{IMAGE_HEIGHT}"
|
|
39
|
+
return {image_key: obj.image_url}
|
|
40
|
+
return {}
|
|
41
|
+
|
|
42
|
+
def get_organizations(self, obj): # pylint: disable=unused-argument
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
def get_course_codes(self, obj):
|
|
46
|
+
"""returns course_codes as expected by course-discovery"""
|
|
47
|
+
course_codes_dict = {}
|
|
48
|
+
learning_path_course_keys = [course.course_key for course in obj.steps.all()]
|
|
49
|
+
for course_key in learning_path_course_keys:
|
|
50
|
+
run_mode = {"course_key": str(course_key), "run_key": course_key.run}
|
|
51
|
+
if course_key.course in course_codes_dict:
|
|
52
|
+
course_codes_dict[course_key.course]["run_modes"].append(run_mode)
|
|
53
|
+
else:
|
|
54
|
+
course_codes_dict[course_key.course] = {"run_modes": [run_mode]}
|
|
55
|
+
|
|
56
|
+
return [{"key": key, **value} for key, value in course_codes_dict.items()]
|
|
57
|
+
|
|
58
|
+
class Meta:
|
|
59
|
+
model = LearningPath
|
|
60
|
+
fields = (
|
|
61
|
+
"uuid",
|
|
62
|
+
"name",
|
|
63
|
+
"marketing_slug",
|
|
64
|
+
"title",
|
|
65
|
+
"subtitle",
|
|
66
|
+
"status",
|
|
67
|
+
"banner_image_urls",
|
|
68
|
+
"organizations",
|
|
69
|
+
"course_codes",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# pylint: disable=abstract-method
|
|
74
|
+
class LearningPathProgressSerializer(serializers.Serializer):
|
|
75
|
+
learning_path_id = serializers.UUIDField()
|
|
76
|
+
progress = serializers.FloatField()
|
|
77
|
+
required_completion = serializers.FloatField()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class LearningPathGradeSerializer(serializers.Serializer):
|
|
81
|
+
"""
|
|
82
|
+
Serializer for learning path grade.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
learning_path_id = serializers.UUIDField()
|
|
86
|
+
grade = serializers.FloatField()
|
|
87
|
+
required_grade = serializers.FloatField()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class LearningPathEnrollmentSerializer(serializers.ModelSerializer):
|
|
91
|
+
class Meta:
|
|
92
|
+
model = LearningPathEnrollment
|
|
93
|
+
fields = ("user", "learning_path", "is_active", "enrolled_at")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""API v1 URLs."""
|
|
2
|
+
|
|
3
|
+
from django.urls import path
|
|
4
|
+
from rest_framework import routers
|
|
5
|
+
|
|
6
|
+
from learning_paths.api.v1.views import (
|
|
7
|
+
BulkEnrollView,
|
|
8
|
+
LearningPathAsProgramViewSet,
|
|
9
|
+
LearningPathEnrollmentView,
|
|
10
|
+
LearningPathUserGradeView,
|
|
11
|
+
LearningPathUserProgressView,
|
|
12
|
+
ListEnrollmentsView,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
router = routers.SimpleRouter()
|
|
16
|
+
router.register(
|
|
17
|
+
r"programs", LearningPathAsProgramViewSet, basename="learning-path-as-program"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
urlpatterns = router.urls + [
|
|
21
|
+
path(
|
|
22
|
+
"<uuid:learning_path_uuid>/progress/",
|
|
23
|
+
LearningPathUserProgressView.as_view(),
|
|
24
|
+
name="learning-path-progress",
|
|
25
|
+
),
|
|
26
|
+
path(
|
|
27
|
+
"<uuid:learning_path_uuid>/grade/",
|
|
28
|
+
LearningPathUserGradeView.as_view(),
|
|
29
|
+
name="learning-path-grade",
|
|
30
|
+
),
|
|
31
|
+
path(
|
|
32
|
+
"<uuid:learning_path_id>/enrollments/",
|
|
33
|
+
LearningPathEnrollmentView.as_view(),
|
|
34
|
+
name="learning-path-enrollments",
|
|
35
|
+
),
|
|
36
|
+
path(
|
|
37
|
+
"enrollments/",
|
|
38
|
+
ListEnrollmentsView.as_view(),
|
|
39
|
+
name="list-enrollments",
|
|
40
|
+
),
|
|
41
|
+
path(
|
|
42
|
+
"enrollments/bulk-enroll/",
|
|
43
|
+
BulkEnrollView.as_view(),
|
|
44
|
+
name="bulk-enroll",
|
|
45
|
+
),
|
|
46
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Util methods for LearningPath
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
from opaque_keys.edx.keys import CourseKey
|
|
9
|
+
from requests.exceptions import HTTPError
|
|
10
|
+
from rest_framework.exceptions import APIException
|
|
11
|
+
|
|
12
|
+
from ...compat import get_catalog_api_client
|
|
13
|
+
from ...models import LearningPathStep
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_course_completion(username: str, course_key: CourseKey, client: Any) -> float:
|
|
17
|
+
"""
|
|
18
|
+
Fetch the completion percentage of a course for a specific user via an internal API request.
|
|
19
|
+
"""
|
|
20
|
+
course_id = str(course_key)
|
|
21
|
+
lms_base_url = settings.LMS_ROOT_URL
|
|
22
|
+
completion_url = f"{lms_base_url}/completion-aggregator/v1/course/{course_id}/?username={username}"
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
response = client.get(completion_url)
|
|
26
|
+
response.raise_for_status()
|
|
27
|
+
data = response.json()
|
|
28
|
+
except HTTPError as err:
|
|
29
|
+
if err.response.status_code == 404:
|
|
30
|
+
return 0.0
|
|
31
|
+
else:
|
|
32
|
+
raise APIException(
|
|
33
|
+
f"Error fetching completion for course {course_id}: {err}"
|
|
34
|
+
) from err
|
|
35
|
+
|
|
36
|
+
if data and data.get("results"):
|
|
37
|
+
return data["results"][0]["completion"]["percent"]
|
|
38
|
+
return 0.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_aggregate_progress(user, learning_path):
|
|
42
|
+
"""
|
|
43
|
+
Calculate the aggregate progress for all courses in the learning path.
|
|
44
|
+
"""
|
|
45
|
+
steps = LearningPathStep.objects.filter(learning_path=learning_path)
|
|
46
|
+
|
|
47
|
+
client = get_catalog_api_client(user)
|
|
48
|
+
# TODO: Create a native Python API in the completion aggregator
|
|
49
|
+
# to avoid the overhead of making HTTP requests and improve performance.
|
|
50
|
+
|
|
51
|
+
total_completion = 0.0
|
|
52
|
+
|
|
53
|
+
for step in steps:
|
|
54
|
+
course_completion = get_course_completion(
|
|
55
|
+
user.username, step.course_key, client
|
|
56
|
+
)
|
|
57
|
+
total_completion += course_completion
|
|
58
|
+
|
|
59
|
+
total_courses = len(steps)
|
|
60
|
+
|
|
61
|
+
if total_courses == 0:
|
|
62
|
+
return 0.0
|
|
63
|
+
|
|
64
|
+
aggregate_progress = total_completion / total_courses
|
|
65
|
+
return aggregate_progress
|