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,341 @@
1
+ """
2
+ Views for LearningPath.
3
+ """
4
+
5
+ import logging
6
+ from datetime import datetime, timezone
7
+ from uuid import UUID
8
+
9
+ from django.conf import settings
10
+ from django.contrib.auth import get_user_model
11
+ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
12
+ from django.core.validators import validate_email
13
+ from django.shortcuts import get_object_or_404
14
+ from rest_framework import generics, status, viewsets
15
+ from rest_framework.pagination import PageNumberPagination
16
+ from rest_framework.permissions import IsAdminUser, IsAuthenticated
17
+ from rest_framework.response import Response
18
+ from rest_framework.views import APIView
19
+
20
+ from learning_paths.api.v1.serializers import (
21
+ LearningPathAsProgramSerializer,
22
+ LearningPathEnrollmentSerializer,
23
+ LearningPathGradeSerializer,
24
+ LearningPathProgressSerializer,
25
+ )
26
+ from learning_paths.models import (
27
+ LearningPath,
28
+ LearningPathEnrollment,
29
+ LearningPathEnrollmentAllowed,
30
+ )
31
+
32
+ from .filters import AdminOrSelfFilterBackend
33
+ from .permissions import IsAdminOrSelf
34
+ from .utils import get_aggregate_progress
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ User = get_user_model()
39
+
40
+
41
+ class LearningPathAsProgramViewSet(viewsets.ReadOnlyModelViewSet):
42
+ """
43
+ This viewset exposes LearningPaths as Programs to be ingested
44
+ by the course-discovery's refresh_course_metadata command.
45
+ URL is: GET <LMS_URL>/api/v1/programs
46
+ The command makes use of the ProgramsApiDataLoader.
47
+ https://github.com/openedx/course-discovery/blob/d6a57fd69479b3d5f5afb682d2668b58503a6af6/course_discovery/apps/course_metadata/data_loaders/api.py#L843
48
+ """
49
+
50
+ queryset = LearningPath.objects.all()
51
+ permission_classes = (IsAuthenticated,)
52
+ serializer_class = LearningPathAsProgramSerializer
53
+ pagination_class = PageNumberPagination
54
+
55
+
56
+ class LearningPathUserProgressView(APIView):
57
+ """
58
+ API view to return the aggregate progress of a user in a learning path.
59
+ """
60
+
61
+ permission_classes = (IsAuthenticated,)
62
+
63
+ def get(self, request, learning_path_uuid):
64
+ """
65
+ Fetch the learning path progress
66
+ """
67
+ learning_path = get_object_or_404(LearningPath, uuid=learning_path_uuid)
68
+
69
+ progress = get_aggregate_progress(request.user, learning_path)
70
+ required_completion = None
71
+ try:
72
+ grading_criteria = learning_path.grading_criteria
73
+ required_completion = grading_criteria.required_completion
74
+ except ObjectDoesNotExist:
75
+ pass
76
+
77
+ data = {
78
+ "learning_path_id": learning_path.uuid,
79
+ "progress": progress,
80
+ "required_completion": required_completion,
81
+ }
82
+
83
+ serializer = LearningPathProgressSerializer(data=data)
84
+ if serializer.is_valid():
85
+ return Response(serializer.data, status=status.HTTP_200_OK)
86
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
87
+
88
+
89
+ class LearningPathUserGradeView(APIView):
90
+ """
91
+ API view to return the aggregate grade of a user in a learning path.
92
+ """
93
+
94
+ permission_classes = (IsAuthenticated,)
95
+
96
+ def get(self, request, learning_path_uuid):
97
+ """
98
+ Fetch learning path grade
99
+ """
100
+
101
+ learning_path = get_object_or_404(LearningPath, uuid=learning_path_uuid)
102
+
103
+ try:
104
+ grading_criteria = learning_path.grading_criteria
105
+ except ObjectDoesNotExist:
106
+ return Response(
107
+ {"detail": "Grading criteria not found for this learning path."},
108
+ status=status.HTTP_404_NOT_FOUND,
109
+ )
110
+
111
+ grade = grading_criteria.calculate_grade(request.user)
112
+
113
+ data = {
114
+ "learning_path_id": learning_path_uuid,
115
+ "grade": grade,
116
+ "required_grade": grading_criteria.required_grade,
117
+ }
118
+
119
+ serializer = LearningPathGradeSerializer(data=data)
120
+ if serializer.is_valid():
121
+ return Response(serializer.data, status=status.HTTP_200_OK)
122
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
123
+
124
+
125
+ class LearningPathEnrollmentView(APIView):
126
+ """
127
+ API View to handle changes to LearningPathEnrollment model
128
+ """
129
+
130
+ permission_classes = [IsAuthenticated, IsAdminOrSelf]
131
+
132
+ def get(self, request, learning_path_id):
133
+ """Get the learning path of users.
134
+
135
+ Staff/Admin can get all the active enrollments of the learning path.
136
+ Learners can get their enrollments only.
137
+
138
+ Query params:
139
+ username (optional): When provided it returns the enrollment for
140
+ the specified user.
141
+ """
142
+ learning_path = get_object_or_404(LearningPath, uuid=learning_path_id)
143
+
144
+ enrollments = LearningPathEnrollment.objects.filter(
145
+ learning_path=learning_path, is_active=True
146
+ )
147
+
148
+ if request.user.is_staff:
149
+ if username := request.query_params.get("username"):
150
+ enrollments = enrollments.filter(user__username=username)
151
+ else:
152
+ enrollments = enrollments.filter(user=request.user)
153
+
154
+ serializer = LearningPathEnrollmentSerializer(enrollments.all(), many=True)
155
+ return Response(serializer.data)
156
+
157
+ def post(self, request, learning_path_id):
158
+ """Enroll learners in Learning Paths.
159
+
160
+ Staff/Admin can enroll anyone with the username query param.
161
+ Learners can enroll only themselves.
162
+
163
+ Example payload::
164
+
165
+ {
166
+ "username": "user_1"
167
+ }
168
+
169
+ """
170
+ learning_path = get_object_or_404(LearningPath, uuid=learning_path_id)
171
+ username = request.data.get("username")
172
+ user = get_object_or_404(User, username=username) if username else request.user
173
+
174
+ enrollment, created = LearningPathEnrollment.objects.get_or_create(
175
+ learning_path=learning_path, user=user
176
+ )
177
+ if created:
178
+ return Response(
179
+ LearningPathEnrollmentSerializer(enrollment).data,
180
+ status=status.HTTP_201_CREATED,
181
+ )
182
+ if enrollment.is_active:
183
+ return Response(
184
+ {"detail": "Enrollment exists."}, status=status.HTTP_409_CONFLICT
185
+ )
186
+
187
+ enrollment.is_active = True
188
+ enrollment.enrolled_at = datetime.now(timezone.utc)
189
+ enrollment.save()
190
+ return Response(LearningPathEnrollmentSerializer(enrollment).data)
191
+
192
+ def delete(self, request, learning_path_id):
193
+ """
194
+ Unenroll a learner from a learning path.
195
+
196
+ Staff/admin can unenroll anyone with the username query param.
197
+ Learners can self-unenroll if settings.LEARNING_PATHS_ALLOW_SELF_UNENROLLMENT is True.
198
+
199
+ Example payload::
200
+
201
+ {
202
+ "username": "user_1"
203
+ }
204
+
205
+ """
206
+ learning_path = get_object_or_404(LearningPath, uuid=learning_path_id)
207
+ username = request.data.get("username")
208
+ user = get_object_or_404(User, username=username) if username else request.user
209
+
210
+ enrollment = get_object_or_404(
211
+ LearningPathEnrollment,
212
+ learning_path=learning_path,
213
+ is_active=True,
214
+ user=user,
215
+ )
216
+
217
+ if (
218
+ not request.user.is_staff
219
+ and not settings.LEARNING_PATHS_ALLOW_SELF_UNENROLLMENT
220
+ ):
221
+ raise PermissionDenied
222
+
223
+ enrollment.is_active = False
224
+ enrollment.save()
225
+ return Response(
226
+ LearningPathEnrollmentSerializer(enrollment).data,
227
+ status=status.HTTP_204_NO_CONTENT,
228
+ )
229
+
230
+
231
+ class ListEnrollmentsView(generics.ListAPIView):
232
+ """
233
+ List Learning Path Enrollments.
234
+
235
+ For staff, this returns enrollments from all learning paths for all users.
236
+ For non-staff, this returns all enrollments for the current user.
237
+ """
238
+
239
+ permission_classes = [IsAuthenticated]
240
+ queryset = LearningPathEnrollment.objects.all()
241
+ serializer_class = LearningPathEnrollmentSerializer
242
+ filter_backends = [AdminOrSelfFilterBackend]
243
+
244
+
245
+ class BulkEnrollView(APIView):
246
+ """
247
+ Bulk enrollment API for LearningPathEnrollment.
248
+
249
+ """
250
+
251
+ permission_classes = [IsAdminUser]
252
+
253
+ def post(self, request, *args, **kwargs):
254
+ """
255
+ Bulk Enroll learners in Learning Paths.
256
+
257
+ The "bulk enroll" API provides a way for the staff to enroll multiple learners
258
+ in multiple learning paths at once.
259
+
260
+ Example payload::
261
+
262
+ {
263
+ "learning_paths": "learning_path_1,learning_path_2",
264
+ "emails": "user_1@example.com,user_2@example.com"
265
+ }
266
+
267
+ `learning_paths` (str): A comma separated list of learning path IDs.
268
+ `emails` (str): A comma separated list of email addresses.
269
+
270
+ * For existing users, it creates a new LearningPathEnrollment record, automatically
271
+ enrolling them in the learning path. It also creates a LearningPathAllowed record
272
+ to store the meta-data for audit later.
273
+ * For non-existing users, it creates a new LearningPathEnrollmentAllowed record
274
+ with just the email address, allowing them to get enrolled when they register.
275
+
276
+ """
277
+ data = request.data
278
+ learning_paths_keys = data.get("learning_paths", "").split(",")
279
+ emails = data.get("emails", "").split(",")
280
+
281
+ valid_learning_paths_keys = []
282
+ for key in learning_paths_keys:
283
+ try:
284
+ UUID(key)
285
+ except ValueError:
286
+ logger.warning("BulkEnrollView: Invalid learning path key: %s", key)
287
+ continue
288
+ valid_learning_paths_keys.append(key)
289
+
290
+ learning_paths = LearningPath.objects.filter(
291
+ uuid__in=valid_learning_paths_keys
292
+ ).all()
293
+
294
+ existing_users = User.objects.filter(email__in=emails).all()
295
+ non_existing_emails = set(emails) - set(u.email for u in existing_users)
296
+
297
+ enrollments_created = []
298
+ enrollment_allowed_created = []
299
+
300
+ for learning_path in learning_paths:
301
+
302
+ # Create LearningPathEnrollment for existing users
303
+ for user in existing_users:
304
+ enrollment = LearningPathEnrollment.objects.filter(
305
+ user=user, learning_path=learning_path
306
+ ).first()
307
+ enrolled_now = False
308
+ if not enrollment:
309
+ enrollment = LearningPathEnrollment(
310
+ user=user,
311
+ learning_path=learning_path,
312
+ )
313
+ enrolled_now = True
314
+ if not enrollment.is_active:
315
+ enrollment.is_active = True
316
+ enrollment.enrolled_at = datetime.now(timezone.utc)
317
+ enrolled_now = True
318
+ enrollment.save()
319
+ if enrolled_now:
320
+ enrollments_created.append(enrollment)
321
+
322
+ # Create LearningPathEnrollmentAllowed for non-existing users
323
+ for email in non_existing_emails:
324
+ try:
325
+ validate_email(email)
326
+ except ValidationError:
327
+ logger.warning("BulkEnrollView: Invalid email: %s", email)
328
+ continue
329
+ allowed, created = LearningPathEnrollmentAllowed.objects.get_or_create(
330
+ email=email, learning_path=learning_path
331
+ )
332
+ if created:
333
+ enrollment_allowed_created.append(allowed)
334
+
335
+ return Response(
336
+ {
337
+ "enrollments_created": len(enrollments_created),
338
+ "enrollment_allowed_created": len(enrollment_allowed_created),
339
+ },
340
+ status=status.HTTP_201_CREATED,
341
+ )
learning_paths/apps.py ADDED
@@ -0,0 +1,52 @@
1
+ """
2
+ learning_paths Django application initialization.
3
+ """
4
+
5
+ from django.apps import AppConfig
6
+ from edx_django_utils.plugins.constants import PluginSettings, PluginSignals, PluginURLs
7
+
8
+
9
+ class LearningPathsConfig(AppConfig):
10
+ """
11
+ Configuration for the learning_paths Django application.
12
+ """
13
+
14
+ name = "learning_paths"
15
+
16
+ plugin_app = {
17
+ # Configuration setting for Plugin URLs for this app.
18
+ PluginURLs.CONFIG: {
19
+ "lms.djangoapp": {
20
+ # The namespace to provide to django's urls.include.
21
+ PluginURLs.NAMESPACE: "learning_paths",
22
+ # The application namespace to provide to django's urls.include.
23
+ # Optional; Defaults to None.
24
+ PluginURLs.APP_NAME: "learning_paths",
25
+ # The regex to provide to django's urls.url.
26
+ # Optional; Defaults to r''.
27
+ # PluginURLs.REGEX: r'^api/learning_paths/',
28
+ # The python path (relative to this app) to the URLs module to be plugged into the project.
29
+ # Optional; Defaults to 'urls'.
30
+ # PluginURLs.RELATIVE_PATH: 'api.urls',
31
+ }
32
+ },
33
+ PluginSettings.CONFIG: {
34
+ "lms.djangoapp": {
35
+ "common": {
36
+ PluginSettings.RELATIVE_PATH: "settings",
37
+ }
38
+ }
39
+ },
40
+ PluginSignals.CONFIG: {
41
+ "lms.djangoapp": {
42
+ PluginSignals.RELATIVE_PATH: "receivers",
43
+ PluginSignals.RECEIVERS: [
44
+ {
45
+ PluginSignals.RECEIVER_FUNC_NAME: "process_pending_enrollments",
46
+ PluginSignals.SIGNAL_PATH: "django.db.models.signals.post_save",
47
+ PluginSignals.SENDER_PATH: "django.contrib.auth.models.User",
48
+ }
49
+ ],
50
+ }
51
+ },
52
+ }
@@ -0,0 +1,48 @@
1
+ """
2
+ Compatibility layer for testing without Open edX.
3
+ """
4
+
5
+ from django.contrib.auth.models import AbstractBaseUser
6
+ from opaque_keys.edx.keys import CourseKey
7
+
8
+
9
+ def get_user_course_grade(user: AbstractBaseUser, course_key: CourseKey):
10
+ """
11
+ Retrieve the CourseGrade object for a user in a specific course.
12
+ """
13
+ # pylint: disable=import-outside-toplevel, import-error
14
+ from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
15
+
16
+ course_grade = CourseGradeFactory().read(user, course_key=course_key)
17
+ return course_grade
18
+
19
+
20
+ def get_catalog_api_client(user: AbstractBaseUser):
21
+ """
22
+ Retrieve the api client for user.
23
+ """
24
+ # pylint: disable=import-outside-toplevel, import-error
25
+ from openedx.core.djangoapps.catalog.utils import (
26
+ get_catalog_api_client as api_client,
27
+ )
28
+
29
+ return api_client(user)
30
+
31
+
32
+ def get_course_keys_with_outlines():
33
+ """
34
+ Retrieve course keys.
35
+ """
36
+ # pylint: disable=import-outside-toplevel, import-error
37
+ from openedx.core.djangoapps.content.learning_sequences.api import (
38
+ get_course_keys_with_outlines as course_keys_with_outlines,
39
+ )
40
+
41
+ return course_keys_with_outlines()
42
+
43
+
44
+ __all__ = [
45
+ "get_course_keys_with_outlines",
46
+ "get_catalog_api_client",
47
+ "get_user_course_grade",
48
+ ]
@@ -0,0 +1,93 @@
1
+ # Generated by Django 3.2.23 on 2024-01-23 12:16
2
+
3
+ from django.db import migrations, models
4
+ import django.db.models.deletion
5
+ import django.utils.timezone
6
+ import model_utils.fields
7
+ import opaque_keys.edx.django.models
8
+
9
+
10
+ class Migration(migrations.Migration):
11
+
12
+ initial = True
13
+
14
+ dependencies = [
15
+ ]
16
+
17
+ operations = [
18
+ migrations.CreateModel(
19
+ name='LearningPath',
20
+ fields=[
21
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22
+ ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
23
+ ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
24
+ ('slug', models.SlugField(help_text='Custom unique code identifying this Learning Path.', unique=True)),
25
+ ('display_name', models.CharField(max_length=255)),
26
+ ('description', models.TextField(blank=True)),
27
+ ('image_url', models.CharField(blank=True, help_text='URL to an image representing this Learning Path.', max_length=200, verbose_name='Image URL')),
28
+ ('level', models.CharField(blank=True, choices=[('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advanced', 'Advanced')], max_length=255)),
29
+ ('duration_in_days', models.PositiveIntegerField(blank=True, help_text='Approximate time (in days) it should take to complete this Learning Path.', null=True, verbose_name='Duration (days)')),
30
+ ('sequential', models.BooleanField(help_text='Whether the courses in this Learning Path are meant to be taken sequentially.', verbose_name='Is sequential')),
31
+ ],
32
+ options={
33
+ 'abstract': False,
34
+ },
35
+ ),
36
+ migrations.CreateModel(
37
+ name='Skill',
38
+ fields=[
39
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
40
+ ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
41
+ ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
42
+ ('display_name', models.CharField(max_length=255)),
43
+ ],
44
+ options={
45
+ 'abstract': False,
46
+ },
47
+ ),
48
+ migrations.CreateModel(
49
+ name='RequiredSkill',
50
+ fields=[
51
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
52
+ ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
53
+ ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
54
+ ('level', models.PositiveIntegerField(help_text='The skill level associated with this course.')),
55
+ ('learning_path', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='learning_paths.learningpath')),
56
+ ('skill', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='learning_paths.skill')),
57
+ ],
58
+ options={
59
+ 'abstract': False,
60
+ 'unique_together': {('learning_path', 'skill')},
61
+ },
62
+ ),
63
+ migrations.CreateModel(
64
+ name='LearningPathStep',
65
+ fields=[
66
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
67
+ ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
68
+ ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
69
+ ('course_key', opaque_keys.edx.django.models.CourseKeyField(max_length=255)),
70
+ ('relative_due_date_in_days', models.PositiveIntegerField(blank=True, help_text='Used to calculate the due date from the starting date of the course.', null=True, verbose_name='Due date (days)')),
71
+ ('order', models.PositiveIntegerField(blank=True, help_text='Ordinal position of this step in the sequence of the Learning Path, if applicable.', null=True, verbose_name='Sequential order')),
72
+ ('learning_path', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='steps', to='learning_paths.learningpath')),
73
+ ],
74
+ options={
75
+ 'unique_together': {('learning_path', 'course_key')},
76
+ },
77
+ ),
78
+ migrations.CreateModel(
79
+ name='AcquiredSkill',
80
+ fields=[
81
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
82
+ ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
83
+ ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
84
+ ('level', models.PositiveIntegerField(help_text='The skill level associated with this course.')),
85
+ ('learning_path', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='learning_paths.learningpath')),
86
+ ('skill', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='learning_paths.skill')),
87
+ ],
88
+ options={
89
+ 'abstract': False,
90
+ 'unique_together': {('learning_path', 'skill')},
91
+ },
92
+ ),
93
+ ]
@@ -0,0 +1,19 @@
1
+ # Generated by Django 3.2.23 on 2024-01-24 10:48
2
+
3
+ from django.db import migrations, models
4
+ import uuid
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ('learning_paths', '0001_initial'),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AddField(
15
+ model_name='learningpath',
16
+ name='uuid',
17
+ field=models.UUIDField(blank=True, default=uuid.uuid4, editable=False, unique=True),
18
+ ),
19
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 3.2.23 on 2024-01-31 17:52
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('learning_paths', '0002_learningpath_uuid'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='learningpath',
15
+ name='subtitle',
16
+ field=models.CharField(max_length=255),
17
+ ),
18
+ ]
@@ -0,0 +1,36 @@
1
+ # Generated by Django 3.2.21 on 2024-02-07 16:33
2
+
3
+ from django.conf import settings
4
+ from django.db import migrations, models
5
+ import django.db.models.deletion
6
+ import django.utils.timezone
7
+ import model_utils.fields
8
+
9
+
10
+ class Migration(migrations.Migration):
11
+
12
+ dependencies = [
13
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14
+ ('learning_paths', '0003_learningpath_subtitle'),
15
+ ]
16
+
17
+ operations = [
18
+ migrations.CreateModel(
19
+ name='LearningPathEnrollment',
20
+ fields=[
21
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22
+ ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
23
+ ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
24
+ ('learning_path', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='learning_paths.learningpath')),
25
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
26
+ ],
27
+ options={
28
+ 'unique_together': {('user', 'learning_path')},
29
+ },
30
+ ),
31
+ migrations.AddField(
32
+ model_name='learningpath',
33
+ name='enrolled_users',
34
+ field=models.ManyToManyField(through='learning_paths.LearningPathEnrollment', to=settings.AUTH_USER_MODEL),
35
+ ),
36
+ ]
@@ -0,0 +1,29 @@
1
+ # Generated by Django 4.2.16 on 2024-12-05 14:02
2
+
3
+ import django.core.validators
4
+ from django.db import migrations, models
5
+ import django.db.models.deletion
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ dependencies = [
11
+ ('learning_paths', '0004_auto_20240207_1633'),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.AddField(
16
+ model_name='learningpathstep',
17
+ name='weight',
18
+ field=models.FloatField(default=1.0, help_text="Weight of this course in the learning path's aggregate grade.Specify as a floating point number between 0 and 1, where 1 represents 100%.", validators=[django.core.validators.MinValueValidator(0.0), django.core.validators.MaxValueValidator(1.0)]),
19
+ ),
20
+ migrations.CreateModel(
21
+ name='LearningPathGradingCriteria',
22
+ fields=[
23
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
24
+ ('required_completion', models.FloatField(default=0.8, help_text='The minimum average completion (0.0-1.0) across all steps in the learning path required to mark it as completed.')),
25
+ ('required_grade', models.FloatField(default=0.75, help_text='Minimum weighted arithmetic mean grade (0.0-1.0) required across all steps to pass this learning path. The weight of each step is determined by its `weight` field.')),
26
+ ('learning_path', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='grading_criteria', to='learning_paths.learningpath')),
27
+ ],
28
+ ),
29
+ ]