learning-paths-plugin 0.2.3__tar.gz → 0.3.0__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 (48) hide show
  1. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/CHANGELOG.rst +9 -1
  2. {learning_paths_plugin-0.2.3/learning_paths_plugin.egg-info → learning_paths_plugin-0.3.0}/PKG-INFO +10 -2
  3. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/__init__.py +1 -1
  4. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/admin.py +10 -2
  5. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/api/v1/serializers.py +2 -2
  6. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/api/v1/urls.py +8 -7
  7. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/api/v1/views.py +24 -21
  8. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/apps.py +1 -0
  9. learning_paths_plugin-0.3.0/learning_paths/keys.py +77 -0
  10. learning_paths_plugin-0.3.0/learning_paths/migrations/0007_replace_uuid_with_learningpathkey.py +77 -0
  11. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/models.py +19 -2
  12. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0/learning_paths_plugin.egg-info}/PKG-INFO +10 -2
  13. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths_plugin.egg-info/SOURCES.txt +3 -0
  14. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths_plugin.egg-info/entry_points.txt +3 -0
  15. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/setup.py +3 -0
  16. learning_paths_plugin-0.3.0/tests/test_keys.py +110 -0
  17. learning_paths_plugin-0.3.0/tests/test_models.py +79 -0
  18. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/tests/test_receivers.py +3 -3
  19. learning_paths_plugin-0.2.3/tests/test_models.py +0 -15
  20. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/LICENSE.txt +0 -0
  21. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/MANIFEST.in +0 -0
  22. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/README.rst +0 -0
  23. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/api/__init__.py +0 -0
  24. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/api/urls.py +0 -0
  25. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/api/v1/__init__.py +0 -0
  26. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/api/v1/filters.py +0 -0
  27. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/api/v1/permissions.py +0 -0
  28. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/api/v1/utils.py +0 -0
  29. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/compat.py +0 -0
  30. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/migrations/0001_initial.py +0 -0
  31. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/migrations/0002_learningpath_uuid.py +0 -0
  32. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/migrations/0003_learningpath_subtitle.py +0 -0
  33. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/migrations/0004_auto_20240207_1633.py +0 -0
  34. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/migrations/0005_learningpathstep_weight_learningpathgradingcriteria.py +0 -0
  35. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/migrations/0006_enrollment_models.py +0 -0
  36. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/migrations/__init__.py +0 -0
  37. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/receivers.py +0 -0
  38. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/settings.py +0 -0
  39. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/templates/learning_paths/base.html +0 -0
  40. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths/urls.py +0 -0
  41. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths_plugin.egg-info/dependency_links.txt +0 -0
  42. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths_plugin.egg-info/not-zip-safe +0 -0
  43. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths_plugin.egg-info/requires.txt +0 -0
  44. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/learning_paths_plugin.egg-info/top_level.txt +0 -0
  45. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/pyproject.toml +0 -0
  46. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/requirements/base.in +0 -0
  47. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/requirements/constraints.txt +0 -0
  48. {learning_paths_plugin-0.2.3 → learning_paths_plugin-0.3.0}/setup.cfg +0 -0
@@ -16,6 +16,14 @@ Unreleased
16
16
 
17
17
  *
18
18
 
19
+ 0.3.0 - 2025-04-03
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
- * Pathway progress API
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.2.3
3
+ Version: 0.3.0
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-04-03
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
- * Pathway progress API
153
+ * Progress API
146
154
 
147
155
  0.2.0 - 2024-01-23
148
156
  ******************
@@ -2,4 +2,4 @@
2
2
  Learning Paths plugin.
3
3
  """
4
4
 
5
- __version__ = "0.2.3"
5
+ __version__ = "0.3.0"
@@ -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
- "uuid",
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
- "learning_path__uuid",
155
+ "learning_path__key",
148
156
  "learning_path__slug",
149
157
  "learning_path__display_name",
150
158
  ]
@@ -72,7 +72,7 @@ class LearningPathAsProgramSerializer(serializers.ModelSerializer):
72
72
 
73
73
  # pylint: disable=abstract-method
74
74
  class LearningPathProgressSerializer(serializers.Serializer):
75
- learning_path_id = serializers.UUIDField()
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
- learning_path_id = serializers.UUIDField()
85
+ learning_path_key = serializers.CharField()
86
86
  grade = serializers.FloatField()
87
87
  required_grade = serializers.FloatField()
88
88
 
@@ -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
- path(
22
- "<uuid:learning_path_uuid>/progress/",
22
+ re_path(
23
+ rf"{LEARNING_PATH_URL_PATTERN}/progress/",
23
24
  LearningPathUserProgressView.as_view(),
24
25
  name="learning-path-progress",
25
26
  ),
26
- path(
27
- "<uuid:learning_path_uuid>/grade/",
27
+ re_path(
28
+ rf"{LEARNING_PATH_URL_PATTERN}/grade/",
28
29
  LearningPathUserGradeView.as_view(),
29
30
  name="learning-path-grade",
30
31
  ),
31
- path(
32
- "<uuid:learning_path_id>/enrollments/",
32
+ re_path(
33
+ rf"{LEARNING_PATH_URL_PATTERN}/enrollments/",
33
34
  LearningPathEnrollmentView.as_view(),
34
35
  name="learning-path-enrollments",
35
36
  ),
@@ -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, learning_path_uuid):
64
+ def get(self, request, learning_path_key_str: str):
64
65
  """
65
66
  Fetch the learning path progress
66
67
  """
67
- learning_path = get_object_or_404(LearningPath, uuid=learning_path_uuid)
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
- "learning_path_id": learning_path.uuid,
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, learning_path_uuid):
98
+ def get(self, request, learning_path_key_str: str):
97
99
  """
98
100
  Fetch learning path grade
99
101
  """
100
102
 
101
- learning_path = get_object_or_404(LearningPath, uuid=learning_path_uuid)
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
- "learning_path_id": learning_path_uuid,
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, learning_path_id):
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
- learning_path = get_object_or_404(LearningPath, uuid=learning_path_id)
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, learning_path_id):
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
- learning_path = get_object_or_404(LearningPath, uuid=learning_path_id)
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, learning_path_id):
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
- learning_path = get_object_or_404(LearningPath, uuid=learning_path_id)
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
- UUID(key)
285
- except ValueError:
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).all()
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 = []
@@ -12,6 +12,7 @@ class LearningPathsConfig(AppConfig):
12
12
  """
13
13
 
14
14
  name = "learning_paths"
15
+ verbose_name = "Learning Paths"
15
16
 
16
17
  plugin_app = {
17
18
  # Configuration setting for Plugin URLs for this app.
@@ -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
+ _learning_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._learning_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
+ )
@@ -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=False, # Temporarily allow non-unique keys.
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 and this why we must have this UUID field.
36
- uuid = models.UUIDField(blank=True, default=uuid4, editable=False, unique=True)
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.2.3
3
+ Version: 0.3.0
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-04-03
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
- * Pathway progress API
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
@@ -1,2 +1,5 @@
1
+ [context_key]
2
+ path-v1 = learning_paths.keys:LearningPathKey
3
+
1
4
  [lms.djangoapp]
2
5
  learning_paths = learning_paths.apps:LearningPathsConfig
@@ -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 LearnerPathwayFactory
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 = LearnerPathwayFactory()
21
- self.learning_path_2 = LearnerPathwayFactory()
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
- """