learning-paths-plugin 0.2.3__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.
Files changed (44) hide show
  1. learning_paths_plugin-0.2.3/CHANGELOG.rst +50 -0
  2. learning_paths_plugin-0.2.3/LICENSE.txt +1 -0
  3. learning_paths_plugin-0.2.3/MANIFEST.in +6 -0
  4. learning_paths_plugin-0.2.3/PKG-INFO +154 -0
  5. learning_paths_plugin-0.2.3/README.rst +65 -0
  6. learning_paths_plugin-0.2.3/learning_paths/__init__.py +5 -0
  7. learning_paths_plugin-0.2.3/learning_paths/admin.py +155 -0
  8. learning_paths_plugin-0.2.3/learning_paths/api/__init__.py +0 -0
  9. learning_paths_plugin-0.2.3/learning_paths/api/urls.py +7 -0
  10. learning_paths_plugin-0.2.3/learning_paths/api/v1/__init__.py +0 -0
  11. learning_paths_plugin-0.2.3/learning_paths/api/v1/filters.py +16 -0
  12. learning_paths_plugin-0.2.3/learning_paths/api/v1/permissions.py +27 -0
  13. learning_paths_plugin-0.2.3/learning_paths/api/v1/serializers.py +93 -0
  14. learning_paths_plugin-0.2.3/learning_paths/api/v1/urls.py +46 -0
  15. learning_paths_plugin-0.2.3/learning_paths/api/v1/utils.py +65 -0
  16. learning_paths_plugin-0.2.3/learning_paths/api/v1/views.py +341 -0
  17. learning_paths_plugin-0.2.3/learning_paths/apps.py +52 -0
  18. learning_paths_plugin-0.2.3/learning_paths/compat.py +48 -0
  19. learning_paths_plugin-0.2.3/learning_paths/migrations/0001_initial.py +93 -0
  20. learning_paths_plugin-0.2.3/learning_paths/migrations/0002_learningpath_uuid.py +19 -0
  21. learning_paths_plugin-0.2.3/learning_paths/migrations/0003_learningpath_subtitle.py +18 -0
  22. learning_paths_plugin-0.2.3/learning_paths/migrations/0004_auto_20240207_1633.py +36 -0
  23. learning_paths_plugin-0.2.3/learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py +29 -0
  24. learning_paths_plugin-0.2.3/learning_paths/migrations/0006_enrollment_models.py +66 -0
  25. learning_paths_plugin-0.2.3/learning_paths/migrations/__init__.py +0 -0
  26. learning_paths_plugin-0.2.3/learning_paths/models.py +283 -0
  27. learning_paths_plugin-0.2.3/learning_paths/receivers.py +52 -0
  28. learning_paths_plugin-0.2.3/learning_paths/settings.py +15 -0
  29. learning_paths_plugin-0.2.3/learning_paths/templates/learning_paths/base.html +26 -0
  30. learning_paths_plugin-0.2.3/learning_paths/urls.py +9 -0
  31. learning_paths_plugin-0.2.3/learning_paths_plugin.egg-info/PKG-INFO +154 -0
  32. learning_paths_plugin-0.2.3/learning_paths_plugin.egg-info/SOURCES.txt +43 -0
  33. learning_paths_plugin-0.2.3/learning_paths_plugin.egg-info/dependency_links.txt +1 -0
  34. learning_paths_plugin-0.2.3/learning_paths_plugin.egg-info/entry_points.txt +2 -0
  35. learning_paths_plugin-0.2.3/learning_paths_plugin.egg-info/not-zip-safe +1 -0
  36. learning_paths_plugin-0.2.3/learning_paths_plugin.egg-info/requires.txt +8 -0
  37. learning_paths_plugin-0.2.3/learning_paths_plugin.egg-info/top_level.txt +1 -0
  38. learning_paths_plugin-0.2.3/pyproject.toml +9 -0
  39. learning_paths_plugin-0.2.3/requirements/base.in +12 -0
  40. learning_paths_plugin-0.2.3/requirements/constraints.txt +17 -0
  41. learning_paths_plugin-0.2.3/setup.cfg +16 -0
  42. learning_paths_plugin-0.2.3/setup.py +181 -0
  43. learning_paths_plugin-0.2.3/tests/test_models.py +15 -0
  44. learning_paths_plugin-0.2.3/tests/test_receivers.py +78 -0
@@ -0,0 +1,50 @@
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.2.3 - 2025-03-31
20
+ ******************
21
+
22
+ Added
23
+ =====
24
+
25
+ * Enrollment API.
26
+
27
+ 0.2.2 - 2024-12-05
28
+ ******************
29
+
30
+ Added
31
+ =====
32
+
33
+ * User grade API
34
+
35
+ 0.2.1 - 2024-10-28
36
+ ******************
37
+
38
+ Added
39
+ =====
40
+
41
+ * Pathway progress API
42
+
43
+ 0.2.0 - 2024-01-23
44
+ ******************
45
+
46
+ Added
47
+ =====
48
+
49
+ * Database models
50
+ * Django Admin interface
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,6 @@
1
+ include CHANGELOG.rst
2
+ include LICENSE.txt
3
+ include README.rst
4
+ include requirements/base.in
5
+ include requirements/constraints.txt
6
+ recursive-include learning_paths *.html *.png *.gif *.js *.css *.jpg *.jpeg *.svg
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: learning-paths-plugin
3
+ Version: 0.2.3
4
+ Summary: Learning Paths plugin
5
+ Home-page: https://github.com/open-craft/learning-paths-plugin
6
+ Author: OpenCraft
7
+ Author-email: help@opencraft.com
8
+ Keywords: Python edx
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: Django
11
+ Classifier: Framework :: Django :: 4.2
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: Other/Proprietary License
14
+ Classifier: Natural Language :: English
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Requires-Python: >=3.11
18
+ License-File: LICENSE.txt
19
+ Requires-Dist: Django
20
+ Requires-Dist: django-model-utils
21
+ Requires-Dist: django-simple-history==3.4.0
22
+ Requires-Dist: djangorestframework
23
+ Requires-Dist: edx-django-utils
24
+ Requires-Dist: edx-opaque-keys
25
+ Requires-Dist: openedx-atlas
26
+ Requires-Dist: openedx-completion-aggregator
27
+ Dynamic: author
28
+ Dynamic: author-email
29
+ Dynamic: classifier
30
+ Dynamic: description
31
+ Dynamic: home-page
32
+ Dynamic: keywords
33
+ Dynamic: license-file
34
+ Dynamic: requires-dist
35
+ Dynamic: requires-python
36
+ Dynamic: summary
37
+
38
+ learning-paths-plugin
39
+ #####################
40
+
41
+ Purpose
42
+ *******
43
+
44
+ A Learning Path consists of a selection of courses bundled together for
45
+ learners to progress through. This plugin enables the creation and
46
+ management of Learning Paths.
47
+
48
+ License
49
+ *******
50
+
51
+ The code in this repository is licensed under the Not open source unless
52
+ otherwise noted.
53
+
54
+ Please see `LICENSE.txt <LICENSE.txt>`_ for details.
55
+
56
+ Installation and Configuration
57
+ ******************************
58
+
59
+ 1. **Clone the Repository**
60
+
61
+ Clone the repository containing the plugin to the `src` directory under your devstack root:
62
+
63
+ .. code-block:: bash
64
+
65
+ git clone <repository_url> <devstack_root>/src/learning-paths-plugin
66
+
67
+ 2. **Install the Plugin**
68
+
69
+ Inside the LMS shell, install the plugin by running:
70
+
71
+ .. code-block:: bash
72
+
73
+ pip install -e /edx/src/learning-paths-plugin/
74
+
75
+ 3. **Run Migrations for the Plugin**
76
+
77
+ After installing the plugin, run the database migrations for `learning_paths`:
78
+
79
+ .. code-block:: bash
80
+
81
+ ./manage.py lms migrate learning_paths
82
+
83
+ 4. **Run Completion Aggregator Migrations**
84
+
85
+ Ensure that the **completion aggregator** service is also up to date by running its migrations:
86
+
87
+ .. code-block:: bash
88
+
89
+ ./manage.py lms migrate completion_aggregator
90
+
91
+ .. warning::
92
+
93
+ Please read the section about `synchronous vs asynchronous modes <https://github.com/open-craft/openedx-completion-aggregator/?tab=readme-ov-file#synchronous-vs-asynchronous-calculations>`_
94
+ for completion aggregator before enabling this in a production environment. Running in synchronous mode can lead to an outage.
95
+
96
+
97
+ Once these steps are complete, the Learning Paths plugin should be successfully installed and ready to use.
98
+
99
+
100
+ Usage
101
+ *****
102
+ After installing the plugin, a learning path can be created in the django admin panel `{LMS_URL}/admin/learning_paths/learningpath/`.
103
+
104
+
105
+ Change Log
106
+ ##########
107
+
108
+ ..
109
+ All enhancements and patches to learning_paths will be documented
110
+ in this file. It adheres to the structure of https://keepachangelog.com/ ,
111
+ but in reStructuredText instead of Markdown (for ease of incorporation into
112
+ Sphinx documentation and the PyPI description).
113
+
114
+ This project adheres to Semantic Versioning (https://semver.org/).
115
+
116
+ .. There should always be an "Unreleased" section for changes pending release.
117
+
118
+ Unreleased
119
+ **********
120
+
121
+ *
122
+
123
+ 0.2.3 - 2025-03-31
124
+ ******************
125
+
126
+ Added
127
+ =====
128
+
129
+ * Enrollment API.
130
+
131
+ 0.2.2 - 2024-12-05
132
+ ******************
133
+
134
+ Added
135
+ =====
136
+
137
+ * User grade API
138
+
139
+ 0.2.1 - 2024-10-28
140
+ ******************
141
+
142
+ Added
143
+ =====
144
+
145
+ * Pathway progress API
146
+
147
+ 0.2.0 - 2024-01-23
148
+ ******************
149
+
150
+ Added
151
+ =====
152
+
153
+ * Database models
154
+ * Django Admin interface
@@ -0,0 +1,65 @@
1
+ learning-paths-plugin
2
+ #####################
3
+
4
+ Purpose
5
+ *******
6
+
7
+ A Learning Path consists of a selection of courses bundled together for
8
+ learners to progress through. This plugin enables the creation and
9
+ management of Learning Paths.
10
+
11
+ License
12
+ *******
13
+
14
+ The code in this repository is licensed under the Not open source unless
15
+ otherwise noted.
16
+
17
+ Please see `LICENSE.txt <LICENSE.txt>`_ for details.
18
+
19
+ Installation and Configuration
20
+ ******************************
21
+
22
+ 1. **Clone the Repository**
23
+
24
+ Clone the repository containing the plugin to the `src` directory under your devstack root:
25
+
26
+ .. code-block:: bash
27
+
28
+ git clone <repository_url> <devstack_root>/src/learning-paths-plugin
29
+
30
+ 2. **Install the Plugin**
31
+
32
+ Inside the LMS shell, install the plugin by running:
33
+
34
+ .. code-block:: bash
35
+
36
+ pip install -e /edx/src/learning-paths-plugin/
37
+
38
+ 3. **Run Migrations for the Plugin**
39
+
40
+ After installing the plugin, run the database migrations for `learning_paths`:
41
+
42
+ .. code-block:: bash
43
+
44
+ ./manage.py lms migrate learning_paths
45
+
46
+ 4. **Run Completion Aggregator Migrations**
47
+
48
+ Ensure that the **completion aggregator** service is also up to date by running its migrations:
49
+
50
+ .. code-block:: bash
51
+
52
+ ./manage.py lms migrate completion_aggregator
53
+
54
+ .. warning::
55
+
56
+ Please read the section about `synchronous vs asynchronous modes <https://github.com/open-craft/openedx-completion-aggregator/?tab=readme-ov-file#synchronous-vs-asynchronous-calculations>`_
57
+ for completion aggregator before enabling this in a production environment. Running in synchronous mode can lead to an outage.
58
+
59
+
60
+ Once these steps are complete, the Learning Paths plugin should be successfully installed and ready to use.
61
+
62
+
63
+ Usage
64
+ *****
65
+ After installing the plugin, a learning path can be created in the django admin panel `{LMS_URL}/admin/learning_paths/learningpath/`.
@@ -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)
@@ -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
+ ]
@@ -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
+ ]