edx-completion 5.1.0__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.
completion/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """
2
+ Completion App
3
+ """
4
+
5
+ from importlib.metadata import version
6
+
7
+ __version__ = version("edx-completion")
File without changes
@@ -0,0 +1,47 @@
1
+ """
2
+ Permissions classes for the API.
3
+ """
4
+
5
+
6
+ from django.http import Http404
7
+ from rest_framework.permissions import BasePermission
8
+
9
+
10
+ class IsStaffOrOwner(BasePermission):
11
+ """
12
+ Permission that allows access to admin users or the owner of an object.
13
+ The owner is considered the User object represented by obj.user.
14
+
15
+ Copied from edx-platform/openedx/core/lib/api/permissions.py
16
+ """
17
+
18
+ def has_object_permission(self, request, view, obj):
19
+ return request.user.is_staff or obj.user == request.user
20
+
21
+ def has_permission(self, request, view):
22
+ user = request.user
23
+ return user.is_staff \
24
+ or (user.username == request.GET.get('username')) \
25
+ or (user.username == getattr(request, 'data', {}).get('username')) \
26
+ or (user.username == getattr(request, 'data', {}).get('user')) \
27
+ or (user.username == getattr(view, 'kwargs', {}).get('username'))
28
+
29
+
30
+ class IsUserInUrl(BasePermission):
31
+ """
32
+ Permission that checks to see if the request user matches the user in the URL.
33
+ """
34
+
35
+ def has_permission(self, request, view):
36
+ """
37
+ Returns true if the current request is by the user themselves.
38
+
39
+ Note: a 404 is returned for non-staff instead of a 403. This is to prevent
40
+ users from being able to detect the existence of accounts.
41
+ """
42
+ url_username = request.parser_context.get('kwargs', {}).get('username', '')
43
+ if request.user.username.lower() != url_username.lower():
44
+ if request.user.is_staff:
45
+ return False # staff gets 403
46
+ raise Http404()
47
+ return True
completion/api/urls.py ADDED
@@ -0,0 +1,11 @@
1
+ """
2
+ Api URLs.
3
+ """
4
+
5
+ from django.urls import include # pragma: no cover
6
+ from django.urls import path
7
+
8
+ app_name = 'completion' # pylint: disable=invalid-name
9
+ urlpatterns = [
10
+ path('v1/', include('completion.api.v1.urls')),
11
+ ]
File without changes
@@ -0,0 +1,18 @@
1
+ """
2
+ API v1 URLs.
3
+ """
4
+ from django.urls import path, re_path
5
+
6
+ from . import views
7
+
8
+ app_name = 'v1' # pylint: disable=invalid-name
9
+ urlpatterns = [
10
+ path('completion-batch', views.CompletionBatchView.as_view(), name='completion-batch'),
11
+ re_path(r'^subsection-completion/{username}/{course_key}/{subsection_id}'.format( # pylint: disable=consider-using-f-string
12
+ username=r'(?P<username>[^/]*)',
13
+ course_key=r'(?P<course_key>[^/+]+(/|\+)[^/+]+(/|\+)[^/?]+)',
14
+ subsection_id=r'(?P<subsection_id>[^/]*)'
15
+ ), # noqa
16
+ views.SubsectionCompletionView.as_view(),
17
+ name='subsection-completion')
18
+ ]
@@ -0,0 +1,256 @@
1
+ """
2
+ API v1 views.
3
+ """
4
+
5
+ from rest_framework import permissions, status
6
+ from rest_framework.response import Response
7
+ from rest_framework.views import APIView
8
+
9
+ from django.contrib import auth
10
+ from django.core.exceptions import ObjectDoesNotExist, ValidationError
11
+ from django.db import DatabaseError
12
+ from django.utils.translation import gettext as _
13
+
14
+ # pylint: disable=ungrouped-imports
15
+ try:
16
+ from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
17
+ except ImportError:
18
+ from edx_rest_framework_extensions.authentication import JwtAuthentication
19
+
20
+ try:
21
+ from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
22
+ except ImportError:
23
+ from edx_rest_framework_extensions.authentication import SessionAuthenticationAllowInactiveUser
24
+ # pylint: enable=ungrouped-imports
25
+
26
+ from opaque_keys import InvalidKeyError
27
+ from opaque_keys.edx.keys import LearningContextKey, UsageKey
28
+
29
+ try:
30
+ from common.djangoapps.student.models import CourseEnrollment
31
+ from lms.djangoapps.course_api.blocks.api import get_blocks
32
+ from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
33
+ except ImportError:
34
+ pass
35
+
36
+ from completion import waffle
37
+ from completion.api.permissions import IsStaffOrOwner, IsUserInUrl
38
+ from completion.models import BlockCompletion
39
+
40
+ User = auth.get_user_model()
41
+
42
+
43
+ class CompletionBatchView(APIView):
44
+ """
45
+ Handles API requests to submit batch completions.
46
+ """
47
+ authentication_classes = (
48
+ JwtAuthentication, BearerAuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser,
49
+ )
50
+ permission_classes = (permissions.IsAuthenticated, IsStaffOrOwner,)
51
+ REQUIRED_KEYS = ['username', 'course_key', 'blocks']
52
+
53
+ def _validate_and_parse(self, batch_object):
54
+ """
55
+ Performs validation on the batch object to make sure it is in the proper format.
56
+
57
+ Parameters:
58
+ * batch_object: The data provided to a POST. The expected format is the following:
59
+ ```
60
+ {
61
+ "username": "username",
62
+ "course_key": "context-key",
63
+ "blocks": {
64
+ "block_key1": 0.0,
65
+ "block_key2": 1.0,
66
+ "block_key3": 1.0,
67
+ }
68
+ }
69
+ ```
70
+
71
+ Return Value:
72
+ * tuple: (User, LearningContextKey, List of tuples (UsageKey, completion_float)
73
+
74
+ Raises:
75
+
76
+ django.core.exceptions.ValidationError:
77
+ If any aspect of validation fails a ValidationError is raised.
78
+
79
+ ObjectDoesNotExist:
80
+ If a database object cannot be found an ObjectDoesNotExist is raised.
81
+ """
82
+ if not waffle.ENABLE_COMPLETION_TRACKING_SWITCH.is_enabled():
83
+ raise ValidationError(
84
+ _("BlockCompletion.objects.submit_batch_completion should not be called when the feature is disabled.")
85
+ )
86
+
87
+ for key in self.REQUIRED_KEYS:
88
+ if key not in batch_object:
89
+ raise ValidationError(_("Key '{key}' not found.").format(key=key))
90
+
91
+ username = batch_object['username']
92
+ user = User.objects.get(username=username)
93
+
94
+ context_key_obj = self._validate_and_parse_context_key(batch_object['course_key'])
95
+
96
+ if context_key_obj.is_course and not CourseEnrollment.is_enrolled(user, context_key_obj):
97
+ raise ValidationError(_('User is not enrolled in course.'))
98
+
99
+ blocks = batch_object['blocks']
100
+ block_objs = []
101
+ for block_key in blocks:
102
+ block_key_obj = self._validate_and_parse_block_key(block_key, context_key_obj)
103
+ completion = float(blocks[block_key])
104
+ block_objs.append((block_key_obj, completion))
105
+
106
+ return user, block_objs
107
+
108
+ def _validate_and_parse_context_key(self, context_key):
109
+ """
110
+ Returns a validated parsed LearningContextKey deserialized from the given context_key.
111
+ """
112
+ try:
113
+ return LearningContextKey.from_string(context_key)
114
+ except InvalidKeyError as error:
115
+ raise ValidationError(_("Invalid learning context key: {}").format(context_key)) from error
116
+
117
+ def _validate_and_parse_block_key(self, block_key, context_key_obj):
118
+ """
119
+ Returns a validated, parsed UsageKey deserialized from the given block_key.
120
+ """
121
+ try:
122
+ block_key_obj = UsageKey.from_string(block_key)
123
+ except InvalidKeyError as error:
124
+ raise ValidationError(_("Invalid block key: {}").format(block_key)) from error
125
+
126
+ if block_key_obj.context_key.is_course and block_key_obj.context_key.run is None:
127
+ # block_key_obj is from an old mongo course and its context_key is missing run info:
128
+ block_key_obj = block_key_obj.replace(course_key=context_key_obj)
129
+
130
+ if block_key_obj.context_key != context_key_obj:
131
+ raise ValidationError(
132
+ _("Block with key: '{key}' is not in context {context}").format(key=block_key, context=context_key_obj)
133
+ )
134
+
135
+ return block_key_obj
136
+
137
+ def post(self, request, *args, **kwargs): # pylint: disable=unused-argument
138
+ """
139
+ Inserts a batch of completions.
140
+
141
+ REST Endpoint Format:
142
+ ```
143
+ {
144
+ "username": "username",
145
+ "course_key": "course-key",
146
+ "blocks": {
147
+ "block_key1": 0.0,
148
+ "block_key2": 1.0,
149
+ "block_key3": 1.0,
150
+ }
151
+ }
152
+ ```
153
+
154
+ **Returns**
155
+
156
+ A Response object, with an appropriate status code.
157
+
158
+ If successful, status code is 200.
159
+ ```
160
+ {
161
+ "detail" : _("ok")
162
+ }
163
+ ```
164
+
165
+ Otherwise, a 400 or 404 may be returned, and the "detail" content will explain the error.
166
+
167
+ """
168
+ batch_object = request.data or {}
169
+ try:
170
+ user, blocks = self._validate_and_parse(batch_object)
171
+ BlockCompletion.objects.submit_batch_completion(user, blocks)
172
+ except ValidationError as exc:
173
+ return Response({
174
+ "detail": _(' ').join(str(msg) for msg in exc.messages),
175
+ }, status=status.HTTP_400_BAD_REQUEST)
176
+ except ValueError as exc:
177
+ return Response({
178
+ "detail": str(exc),
179
+ }, status=status.HTTP_400_BAD_REQUEST)
180
+ except ObjectDoesNotExist as exc:
181
+ return Response({
182
+ "detail": str(exc),
183
+ }, status=status.HTTP_404_NOT_FOUND)
184
+ except DatabaseError as exc:
185
+ return Response({
186
+ "detail": str(exc),
187
+ }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
188
+
189
+ return Response({"detail": _("ok")}, status=status.HTTP_200_OK)
190
+
191
+
192
+ class SubsectionCompletionView(APIView):
193
+ """
194
+ Handles API endpoints for the milestones experiments.
195
+ TODO: EDUCATOR-2358 Remove this class after the
196
+ milestones experiment is no longer running.
197
+ """
198
+ authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser,)
199
+ permission_classes = (permissions.IsAuthenticated, IsUserInUrl)
200
+
201
+ def get(self, request, username, course_key, subsection_id):
202
+ """
203
+ Returns completion for a (user, subsection, course).
204
+ """
205
+
206
+ def get_completion(course_completions, all_blocks, block_id):
207
+ """
208
+ Recursively get the aggregate completion for a subsection,
209
+ given the subsection block and a list of all blocks.
210
+
211
+ Parameters:
212
+ course_completions: a dictionary of completion values by block IDs
213
+ all_blocks: a dictionary of the block structure for a subsection
214
+ block_id: an ID of a block for which to get completion
215
+ """
216
+ block = all_blocks.get(block_id)
217
+ child_ids = block.get('children', [])
218
+ if not child_ids:
219
+ return course_completions.get(block.serializer.instance, 0)
220
+
221
+ completion = 0
222
+ total_children = 0
223
+ for child_id in child_ids:
224
+ completion += get_completion(course_completions, all_blocks, child_id)
225
+ total_children += 1
226
+
227
+ return int(completion == total_children)
228
+
229
+ user_id = User.objects.get(username=username).id
230
+ block_types_filter = [
231
+ 'course',
232
+ 'chapter',
233
+ 'sequential',
234
+ 'vertical',
235
+ 'html',
236
+ 'problem',
237
+ 'video',
238
+ 'discussion',
239
+ 'drag-and-drop-v2'
240
+ ]
241
+
242
+ blocks = get_blocks(
243
+ request,
244
+ UsageKey.from_string(subsection_id),
245
+ nav_depth=2,
246
+ requested_fields=[
247
+ 'children'
248
+ ],
249
+ block_types_filter=block_types_filter
250
+ )
251
+
252
+ course_key = LearningContextKey.from_string(course_key)
253
+ context_completions = BlockCompletion.get_learning_context_completions(user_id, course_key)
254
+ aggregated_completion = get_completion(context_completions, blocks['blocks'], blocks['root'])
255
+
256
+ return Response({"completion": aggregated_completion}, status=status.HTTP_200_OK)
completion/apps.py ADDED
@@ -0,0 +1,43 @@
1
+ """
2
+ App Configuration for Completion
3
+ """
4
+
5
+
6
+ from django.apps import AppConfig
7
+
8
+
9
+ class CompletionAppConfig(AppConfig):
10
+ """
11
+ App Configuration for Completion
12
+ """
13
+ name = 'completion'
14
+ verbose_name = 'Completion'
15
+
16
+ plugin_app = {
17
+ 'url_config': {
18
+ 'lms.djangoapp': {
19
+ 'namespace': 'completion',
20
+ 'regex': r'^api/completion/',
21
+ 'relative_path': 'api.urls',
22
+ },
23
+ },
24
+ 'settings_config': {
25
+ 'lms.djangoapp': {
26
+ 'common': {
27
+ 'relative_path': 'settings.common',
28
+ },
29
+ },
30
+ },
31
+ 'signals_config': {
32
+ 'lms.djangoapp': {
33
+ 'relative_path': 'handlers',
34
+ 'receivers': [
35
+ {
36
+ 'receiver_func_name': 'scorable_block_completion',
37
+ 'signal_path': 'lms.djangoapps.grades.signals.signals.PROBLEM_WEIGHTED_SCORE_CHANGED',
38
+ 'dispatch_uid': 'completion.handlers.scorable_block_completion',
39
+ },
40
+ ],
41
+ },
42
+ },
43
+ }
@@ -0,0 +1,16 @@
1
+ """
2
+ This file contains custom exceptions used by the completion repository.
3
+ '''
4
+
5
+ """
6
+
7
+
8
+ class UnavailableCompletionData(Exception):
9
+ """
10
+ UnavailableCompletionData should be raised when a method is unable to gather
11
+ completion data from BlockCompletion.
12
+ """
13
+
14
+ def __init__(self, context_key):
15
+ Exception.__init__(
16
+ self, f"The learner does not have completion data within learning context {context_key}")
completion/handlers.py ADDED
@@ -0,0 +1,53 @@
1
+ """
2
+ Signal handlers to trigger completion updates.
3
+ """
4
+
5
+ import logging
6
+
7
+ from django.contrib import auth
8
+ from opaque_keys import InvalidKeyError
9
+ from opaque_keys.edx.keys import LearningContextKey, UsageKey
10
+ from xblock.completable import XBlockCompletionMode
11
+ from xblock.core import XBlock
12
+
13
+ from . import waffle
14
+ from .models import BlockCompletion
15
+
16
+ log = logging.getLogger(__name__)
17
+ User = auth.get_user_model()
18
+
19
+
20
+ def scorable_block_completion(sender, **kwargs): # pylint: disable=unused-argument
21
+ """
22
+ When a problem is scored, submit a new BlockCompletion for that block.
23
+ """
24
+ if not waffle.ENABLE_COMPLETION_TRACKING_SWITCH.is_enabled():
25
+ return
26
+ try:
27
+ block_key = UsageKey.from_string(kwargs['usage_id'])
28
+ except InvalidKeyError:
29
+ log.exception("Unable to parse XBlock usage_id for completion: %s", kwargs['usage_id'])
30
+ return
31
+
32
+ if block_key.context_key.is_course and block_key.context_key.run is None:
33
+ # In the case of old mongo courses, the context_key cannot be derived
34
+ # from the block key alone since it will be missing run info:
35
+ course_key_with_run = LearningContextKey.from_string(kwargs['course_id'])
36
+ block_key = block_key.replace(course_key=course_key_with_run)
37
+
38
+ block_cls = XBlock.load_class(block_key.block_type)
39
+ if XBlockCompletionMode.get_mode(block_cls) != XBlockCompletionMode.COMPLETABLE:
40
+ return
41
+ if getattr(block_cls, 'has_custom_completion', False):
42
+ return
43
+ user = User.objects.get(id=kwargs['user_id'])
44
+ if kwargs.get('score_deleted'):
45
+ completion = 0.0
46
+ else:
47
+ completion = 1.0
48
+ if not kwargs.get('grader_response'):
49
+ BlockCompletion.objects.submit_completion(
50
+ user=user,
51
+ block_key=block_key,
52
+ completion=completion,
53
+ )
@@ -0,0 +1,41 @@
1
+ from opaque_keys.edx.django.models import CourseKeyField, UsageKeyField
2
+
3
+ import django.utils.timezone
4
+ from django.conf import settings
5
+ from django.db import migrations, models
6
+ from django.db.models import BigAutoField
7
+
8
+ import model_utils.fields
9
+
10
+ from completion.models import validate_percent
11
+
12
+
13
+ class Migration(migrations.Migration):
14
+
15
+ dependencies = [
16
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
17
+ ]
18
+
19
+ operations = [
20
+ migrations.CreateModel(
21
+ name='BlockCompletion',
22
+ fields=[
23
+ ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)),
24
+ ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)),
25
+ ('id', BigAutoField(serialize=False, primary_key=True)),
26
+ ('course_key', CourseKeyField(max_length=255)),
27
+ ('block_key', UsageKeyField(max_length=255)),
28
+ ('block_type', models.CharField(max_length=64)),
29
+ ('completion', models.FloatField(validators=[validate_percent])),
30
+ ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
31
+ ],
32
+ ),
33
+ migrations.AlterUniqueTogether(
34
+ name='blockcompletion',
35
+ unique_together={('course_key', 'block_key', 'user')},
36
+ ),
37
+ migrations.AlterIndexTogether(
38
+ name='blockcompletion',
39
+ index_together={('course_key', 'block_type', 'user'), ('user', 'course_key', 'modified')},
40
+ ),
41
+ ]
@@ -0,0 +1,79 @@
1
+ # Generated by Django 5.1.8 on 2025-04-10 08:24
2
+
3
+ import completion.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
+ from django.conf import settings
9
+ from django.db import migrations, models
10
+
11
+
12
+ class Migration(migrations.Migration):
13
+
14
+ replaces = [
15
+ ("completion", "0001_initial"),
16
+ ("completion", "0002_auto_20180125_1510"),
17
+ ("completion", "0003_learning_context"),
18
+ ]
19
+
20
+ initial = True
21
+
22
+ dependencies = [
23
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
24
+ ]
25
+
26
+ operations = [
27
+ migrations.CreateModel(
28
+ name="BlockCompletion",
29
+ fields=[
30
+ (
31
+ "created",
32
+ model_utils.fields.AutoCreatedField(
33
+ default=django.utils.timezone.now,
34
+ editable=False,
35
+ verbose_name="created",
36
+ ),
37
+ ),
38
+ (
39
+ "modified",
40
+ model_utils.fields.AutoLastModifiedField(
41
+ default=django.utils.timezone.now,
42
+ editable=False,
43
+ verbose_name="modified",
44
+ ),
45
+ ),
46
+ ("id", models.BigAutoField(primary_key=True, serialize=False)),
47
+ (
48
+ "context_key",
49
+ opaque_keys.edx.django.models.LearningContextKeyField(
50
+ db_column="course_key", max_length=255
51
+ ),
52
+ ),
53
+ (
54
+ "block_key",
55
+ opaque_keys.edx.django.models.UsageKeyField(max_length=255),
56
+ ),
57
+ ("block_type", models.CharField(max_length=64)),
58
+ (
59
+ "completion",
60
+ models.FloatField(validators=[completion.models.validate_percent]),
61
+ ),
62
+ (
63
+ "user",
64
+ models.ForeignKey(
65
+ on_delete=django.db.models.deletion.CASCADE,
66
+ to=settings.AUTH_USER_MODEL,
67
+ ),
68
+ ),
69
+ ],
70
+ options={
71
+ "unique_together": {("context_key", "block_key", "user")},
72
+ "index_together": {
73
+ ("context_key", "block_type", "user"),
74
+ ("user", "context_key", "modified"),
75
+ },
76
+ "get_latest_by": "modified",
77
+ },
78
+ ),
79
+ ]
@@ -0,0 +1,15 @@
1
+ from django.db import migrations, models
2
+
3
+
4
+ class Migration(migrations.Migration):
5
+
6
+ dependencies = [
7
+ ('completion', '0001_initial'),
8
+ ]
9
+
10
+ operations = [
11
+ migrations.AlterModelOptions(
12
+ name='blockcompletion',
13
+ options={'get_latest_by': 'modified'},
14
+ ),
15
+ ]
@@ -0,0 +1,39 @@
1
+ # Changes completion to track blocks for any learning context,
2
+ # and not just for courses.
3
+ #
4
+ # This migration does not produce any database-level changes. You can verify
5
+ # this with: ./manage.py lms sqlmigrate completion 0003
6
+
7
+ from django.conf import settings
8
+ from django.db import migrations
9
+ import opaque_keys.edx.django.models
10
+
11
+
12
+ class Migration(migrations.Migration):
13
+
14
+ dependencies = [
15
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
16
+ ('completion', '0002_auto_20180125_1510'),
17
+ ]
18
+
19
+ operations = [
20
+ migrations.AlterField(
21
+ model_name='blockcompletion',
22
+ name='course_key',
23
+ field=opaque_keys.edx.django.models.LearningContextKeyField(db_column='course_key', max_length=255),
24
+ preserve_default=False,
25
+ ),
26
+ migrations.RenameField(
27
+ model_name='blockcompletion',
28
+ old_name='course_key',
29
+ new_name='context_key',
30
+ ),
31
+ migrations.AlterUniqueTogether(
32
+ name='blockcompletion',
33
+ unique_together={('context_key', 'block_key', 'user')},
34
+ ),
35
+ migrations.AlterIndexTogether(
36
+ name='blockcompletion',
37
+ index_together={('user', 'context_key', 'modified'), ('context_key', 'block_type', 'user')},
38
+ ),
39
+ ]
@@ -0,0 +1,23 @@
1
+ # Generated by Django 4.2.20 on 2025-04-03 05:37
2
+
3
+ from django.db import migrations
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ("completion", "0003_learning_context"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.RenameIndex(
14
+ model_name="blockcompletion",
15
+ new_name="completion__user_id_861fe5_idx",
16
+ old_fields=("user", "context_key", "modified"),
17
+ ),
18
+ migrations.RenameIndex(
19
+ model_name="blockcompletion",
20
+ new_name="completion__course__c6e6ad_idx",
21
+ old_fields=("context_key", "block_type", "user"),
22
+ ),
23
+ ]
File without changes