Skip to content

Commit 70ea641

Browse files
leoaulasneo98ormsbee
authored andcommitted
feat: Improve robust score rendering with event-based architecture
This commit implements a comprehensive solution for test score integration in the enhancement system along with improvements to the score rendering mechanism. Key changes include: - Add event handler for rendering blocks with edx-submissions scores - Implement event-based mechanism to render XBlocks with scoring data - Create signal handlers in handlers.py to process external grader scores - Develop specialized XBlock loader for rendering without HTTP requests - Add queue_key propagation across the submission pipeline - Register submission URLs in LMS routing configuration - Add complete docstrings to score render module for better code maintainability - Add ADR for XBlock rendering with external grader integration - Add openedx-events fork branch as a dependency in testing.in - Upgrade edx submission dependency These changes support the migration from traditional XQueue callback HTTP requests to a more robust event-based architecture, improving performance and reliability when processing submission scores. The included ADR documents the architectural decision and implementation approach for this significant improvement to the external grading workflow.
1 parent e1747f3 commit 70ea641

File tree

15 files changed

+790
-51
lines changed

15 files changed

+790
-51
lines changed

lms/djangoapps/grades/signals/handlers.py

Lines changed: 106 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
"""
22
Grades related signals.
33
"""
4-
5-
4+
import json
65
from contextlib import contextmanager
76
from logging import getLogger
87

98
from django.dispatch import receiver
10-
from opaque_keys.edx.keys import LearningContextKey
9+
from opaque_keys.edx.keys import CourseKey, LearningContextKey, UsageKey
10+
from opaque_keys import InvalidKeyError
11+
from openedx_events.learning.signals import EXTERNAL_GRADER_SCORE_SUBMITTED
1112
from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED, EXAM_ATTEMPT_VERIFIED
1213
from submissions.models import score_reset, score_set
1314
from xblock.scorable import ScorableXBlockMixin, Score
@@ -23,23 +24,24 @@
2324
recalculate_subsection_grade_v3
2425
)
2526
from openedx.core.djangoapps.course_groups.signals.signals import COHORT_MEMBERSHIP_UPDATED
27+
from openedx.core.djangoapps.signals.signals import ( # lint-amnesty, pylint: disable=wrong-import-order
28+
COURSE_GRADE_NOW_FAILED,
29+
COURSE_GRADE_NOW_PASSED
30+
)
2631
from openedx.core.lib.grade_utils import is_score_higher_or_equal
32+
from xmodule.modulestore.django import modulestore
2733

2834
from .. import events
2935
from ..constants import GradeOverrideFeatureEnum, ScoreDatabaseTableEnum
3036
from ..course_grade_factory import CourseGradeFactory
3137
from ..scores import weighted_score
3238
from .signals import (
39+
COURSE_GRADE_PASSED_FIRST_TIME,
3340
PROBLEM_RAW_SCORE_CHANGED,
3441
PROBLEM_WEIGHTED_SCORE_CHANGED,
3542
SCORE_PUBLISHED,
3643
SUBSECTION_OVERRIDE_CHANGED,
37-
SUBSECTION_SCORE_CHANGED,
38-
COURSE_GRADE_PASSED_FIRST_TIME
39-
)
40-
from openedx.core.djangoapps.signals.signals import ( # lint-amnesty, pylint: disable=wrong-import-order
41-
COURSE_GRADE_NOW_FAILED,
42-
COURSE_GRADE_NOW_PASSED
44+
SUBSECTION_SCORE_CHANGED
4345
)
4446

4547
log = getLogger(__name__)
@@ -347,3 +349,98 @@ def exam_attempt_rejected_event_handler(sender, signal, **kwargs): # pylint: di
347349
overrider=None,
348350
comment=None,
349351
)
352+
353+
354+
@receiver(EXTERNAL_GRADER_SCORE_SUBMITTED)
355+
def handle_external_grader_score(signal, sender, score, **kwargs):
356+
"""
357+
Event handler for external grader score submissions.
358+
359+
This function is triggered when an external grader submits a score through the
360+
EXTERNAL_GRADER_SCORE_SUBMITTED signal. It processes the score and updates
361+
the corresponding XBlock instance with the grading results.
362+
363+
Args:
364+
signal: The signal that triggered this handler
365+
sender: The object that sent the signal
366+
score: An object containing the score data with attributes:
367+
- score_msg: The actual score message/response from the grader
368+
- course_id: String ID of the course
369+
- user_id: ID of the user who submitted the problem
370+
- module_id: ID of the module/problem
371+
- submission_id: ID of the submission
372+
- queue_key: Key identifying the submission in the queue
373+
- queue_name: Name of the queue used for grading
374+
**kwargs: Additional keyword arguments passed with the signal
375+
376+
The function logs details about the score event, formats the grader message
377+
appropriately, and then calls the module's score_update handler to record
378+
the grade in the learning management system.
379+
"""
380+
381+
log.info(f"Received external grader score event: {signal}, {sender}, {score}, {kwargs}")
382+
383+
grader_msg = score.score_msg
384+
log.info(
385+
"External grader event score payload received: user_id=%s, module_id=%s, submission_id=%s, course_id=%s",
386+
score.user_id,
387+
score.module_id,
388+
score.submission_id,
389+
score.course_id,
390+
)
391+
392+
# Since we already confirm this in edx-submissions, it is safe to parse this
393+
grader_msg = json.loads(grader_msg)
394+
log.info(f"External grader score: {grader_msg['score']}")
395+
396+
data = {
397+
'xqueue_header': json.dumps({
398+
'lms_key': str(score.submission_id),
399+
'queue_name': score.queue_name
400+
}),
401+
'xqueue_body': json.dumps(grader_msg),
402+
'queuekey': score.queue_key
403+
}
404+
405+
try:
406+
course_key = CourseKey.from_string(score.course_id)
407+
course = modulestore().get_course(course_key, depth=0)
408+
except InvalidKeyError:
409+
log.error("Invalid course_id received from external grader: %s", score.course_id)
410+
return
411+
412+
try:
413+
usage_key = UsageKey.from_string(score.module_id)
414+
except InvalidKeyError:
415+
log.error("Invalid usage key received from external grader: %s", score.module_id)
416+
return
417+
418+
# pylint: disable=broad-exception-caught
419+
try:
420+
# Use our new function instead of load_single_xblock
421+
# NOTE: Importing this at module level causes a circular import because
422+
# score_render → block_render → grades signals → back into this module.
423+
# Keeping it inside the handler avoids that by loading it only when needed.
424+
from xmodule.capa.score_render import load_xblock_for_external_grader
425+
instance = load_xblock_for_external_grader(score.user_id,
426+
course_key,
427+
usage_key,
428+
course=course)
429+
430+
# Call the handler method (mirroring the original xqueue_callback)
431+
instance.handle_ajax('score_update', data)
432+
433+
# Save any state changes
434+
instance.save()
435+
436+
log.info(f"Successfully processed external grade for module {score.module_id}, user {score.user_id}")
437+
438+
except Exception as e:
439+
log.exception(
440+
"Error processing external grade for user_id=%s, module_id=%s, submission_id=%s: %s",
441+
score.user_id,
442+
score.module_id,
443+
score.submission_id,
444+
e,
445+
)
446+
raise

lms/envs/common.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3597,6 +3597,12 @@ def _should_send_certificate_events(settings):
35973597
"enabled": Derived(should_send_learning_badge_events),
35983598
},
35993599
},
3600+
"org.openedx.learning.external_grader.score.submitted.v1": {
3601+
"learning-external-grader-score-lifecycle": {
3602+
"event_key_field": "score.submission_id",
3603+
"enabled": False
3604+
},
3605+
},
36003606
}
36013607

36023608
#### Survey Report ####

lms/urls.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from django.views.generic.base import RedirectView
1313
from edx_api_doc_tools import make_docs_urls
1414
from edx_django_utils.plugins import get_plugin_url_patterns
15+
from submissions import urls as submissions_urls
1516

1617
from common.djangoapps.student import views as student_views
1718
from common.djangoapps.util import views as util_views
@@ -355,6 +356,14 @@
355356
name='xqueue_callback',
356357
),
357358

359+
re_path(
360+
r'^courses/{}/xqueue/(?P<userid>[^/]*)/(?P<mod_id>.*?)/(?P<dispatch>[^/]*)$'.format(
361+
settings.COURSE_ID_PATTERN,
362+
),
363+
xqueue_callback,
364+
name='callback_submission',
365+
),
366+
358367
# TODO: These views need to be updated before they work
359368
path('calculate', util_views.calculate),
360369

@@ -1052,3 +1061,7 @@
10521061
urlpatterns += [
10531062
path('api/notifications/', include('openedx.core.djangoapps.notifications.urls')),
10541063
]
1064+
1065+
urlpatterns += [
1066+
path('xqueue/', include((submissions_urls, 'submissions'), namespace='submissions')),
1067+
]

requirements/edx/base.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -526,7 +526,7 @@ edx-search==4.3.0
526526
# openedx-forum
527527
edx-sga==0.27.0
528528
# via -r requirements/edx/bundled.in
529-
edx-submissions==3.12.1
529+
edx-submissions==3.12.2
530530
# via
531531
# -r requirements/edx/kernel.in
532532
# ora2

requirements/edx/development.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -828,7 +828,7 @@ edx-sga==0.27.0
828828
# via
829829
# -r requirements/edx/doc.txt
830830
# -r requirements/edx/testing.txt
831-
edx-submissions==3.12.1
831+
edx-submissions==3.12.2
832832
# via
833833
# -r requirements/edx/doc.txt
834834
# -r requirements/edx/testing.txt

requirements/edx/doc.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,7 @@ edx-search==4.3.0
615615
# openedx-forum
616616
edx-sga==0.27.0
617617
# via -r requirements/edx/base.txt
618-
edx-submissions==3.12.1
618+
edx-submissions==3.12.2
619619
# via
620620
# -r requirements/edx/base.txt
621621
# ora2

requirements/edx/github.in

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@
8080

8181
# ... add dependencies here
8282

83-
8483
##############################################################################
8584
# Critical fixes for packages that are not yet available in a PyPI release.
8685
##############################################################################

requirements/edx/testing.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -639,7 +639,7 @@ edx-search==4.3.0
639639
# openedx-forum
640640
edx-sga==0.27.0
641641
# via -r requirements/edx/base.txt
642-
edx-submissions==3.12.1
642+
edx-submissions==3.12.2
643643
# via
644644
# -r requirements/edx/base.txt
645645
# ora2

xmodule/capa/score_render.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""
2+
Score rendering when submission is evaluated for external grader and has been saved successfully
3+
"""
4+
import logging
5+
from functools import partial
6+
7+
from django.http import Http404
8+
from edx_when.field_data import DateLookupFieldData
9+
from opaque_keys.edx.keys import CourseKey, UsageKey
10+
from xblock.runtime import KvsFieldData
11+
12+
from common.djangoapps.student.models import AnonymousUserId
13+
from lms.djangoapps.courseware.block_render import prepare_runtime_for_user
14+
from lms.djangoapps.courseware.field_overrides import OverrideFieldData
15+
from lms.djangoapps.courseware.model_data import DjangoKeyValueStore, FieldDataCache
16+
from lms.djangoapps.lms_xblock.field_data import LmsFieldData
17+
from xmodule.modulestore.django import modulestore
18+
19+
log = logging.getLogger(__name__)
20+
21+
22+
def load_xblock_for_external_grader(
23+
user_id: str,
24+
course_key: CourseKey,
25+
usage_key: UsageKey,
26+
course=None,
27+
):
28+
"""
29+
Load a single XBlock for external grading without user access checks.
30+
"""
31+
32+
user = AnonymousUserId.objects.get(anonymous_user_id=user_id).user
33+
34+
# pylint: disable=broad-exception-caught
35+
try:
36+
block = modulestore().get_item(usage_key)
37+
except Exception as e:
38+
log.exception(f"Could not find block {usage_key} in modulestore: {e}")
39+
raise Http404(f"Module {usage_key} was not found") from e
40+
41+
field_data_cache = FieldDataCache.cache_for_block_descendents(
42+
course_key, user, block, depth=0
43+
)
44+
45+
student_kvs = DjangoKeyValueStore(field_data_cache)
46+
student_data = KvsFieldData(student_kvs)
47+
48+
instance = get_block_for_descriptor_without_access_check(
49+
user=user,
50+
block=block,
51+
student_data=student_data,
52+
course_key=course_key,
53+
course=course
54+
)
55+
56+
if instance is None:
57+
msg = f"Could not bind XBlock instance for usage key: {usage_key}"
58+
log.error(msg)
59+
raise Http404(msg)
60+
61+
return instance
62+
63+
64+
def get_block_for_descriptor_without_access_check(user, block, student_data, course_key, course=None):
65+
"""
66+
Modified version of get_block_for_descriptor that skips access checks for system operations.
67+
"""
68+
69+
prepare_runtime_for_user(
70+
user=user,
71+
student_data=student_data,
72+
runtime=block.runtime,
73+
course_id=course_key,
74+
course=course,
75+
track_function=lambda event_type, event: None,
76+
request_token="external-grader-token",
77+
position=None,
78+
wrap_xblock_display=True,
79+
)
80+
81+
block.bind_for_student(
82+
user.id,
83+
[
84+
partial(DateLookupFieldData, course_id=course_key, user=user),
85+
partial(OverrideFieldData.wrap, user, course),
86+
partial(LmsFieldData, student_data=student_data),
87+
],
88+
)
89+
90+
return block

0 commit comments

Comments
 (0)