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.
@@ -0,0 +1,5 @@
1
+ """
2
+ Learning Paths plugin.
3
+ """
4
+
5
+ __version__ = "0.2.3"
@@ -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
@@ -0,0 +1,7 @@
1
+ """API URLs."""
2
+
3
+ from django.urls import include, path
4
+
5
+ urlpatterns = [
6
+ path("v1/", include("learning_paths.api.v1.urls")),
7
+ ]
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