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.
This commit is contained in:
committed by
David Ormsbee
parent
e1747f3844
commit
70ea641c99
@@ -1,13 +1,14 @@
|
||||
"""
|
||||
Grades related signals.
|
||||
"""
|
||||
|
||||
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from logging import getLogger
|
||||
|
||||
from django.dispatch import receiver
|
||||
from opaque_keys.edx.keys import LearningContextKey
|
||||
from opaque_keys.edx.keys import CourseKey, LearningContextKey, UsageKey
|
||||
from opaque_keys import InvalidKeyError
|
||||
from openedx_events.learning.signals import EXTERNAL_GRADER_SCORE_SUBMITTED
|
||||
from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED, EXAM_ATTEMPT_VERIFIED
|
||||
from submissions.models import score_reset, score_set
|
||||
from xblock.scorable import ScorableXBlockMixin, Score
|
||||
@@ -23,23 +24,24 @@ from lms.djangoapps.grades.tasks import (
|
||||
recalculate_subsection_grade_v3
|
||||
)
|
||||
from openedx.core.djangoapps.course_groups.signals.signals import COHORT_MEMBERSHIP_UPDATED
|
||||
from openedx.core.djangoapps.signals.signals import ( # lint-amnesty, pylint: disable=wrong-import-order
|
||||
COURSE_GRADE_NOW_FAILED,
|
||||
COURSE_GRADE_NOW_PASSED
|
||||
)
|
||||
from openedx.core.lib.grade_utils import is_score_higher_or_equal
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from .. import events
|
||||
from ..constants import GradeOverrideFeatureEnum, ScoreDatabaseTableEnum
|
||||
from ..course_grade_factory import CourseGradeFactory
|
||||
from ..scores import weighted_score
|
||||
from .signals import (
|
||||
COURSE_GRADE_PASSED_FIRST_TIME,
|
||||
PROBLEM_RAW_SCORE_CHANGED,
|
||||
PROBLEM_WEIGHTED_SCORE_CHANGED,
|
||||
SCORE_PUBLISHED,
|
||||
SUBSECTION_OVERRIDE_CHANGED,
|
||||
SUBSECTION_SCORE_CHANGED,
|
||||
COURSE_GRADE_PASSED_FIRST_TIME
|
||||
)
|
||||
from openedx.core.djangoapps.signals.signals import ( # lint-amnesty, pylint: disable=wrong-import-order
|
||||
COURSE_GRADE_NOW_FAILED,
|
||||
COURSE_GRADE_NOW_PASSED
|
||||
SUBSECTION_SCORE_CHANGED
|
||||
)
|
||||
|
||||
log = getLogger(__name__)
|
||||
@@ -347,3 +349,98 @@ def exam_attempt_rejected_event_handler(sender, signal, **kwargs): # pylint: di
|
||||
overrider=None,
|
||||
comment=None,
|
||||
)
|
||||
|
||||
|
||||
@receiver(EXTERNAL_GRADER_SCORE_SUBMITTED)
|
||||
def handle_external_grader_score(signal, sender, score, **kwargs):
|
||||
"""
|
||||
Event handler for external grader score submissions.
|
||||
|
||||
This function is triggered when an external grader submits a score through the
|
||||
EXTERNAL_GRADER_SCORE_SUBMITTED signal. It processes the score and updates
|
||||
the corresponding XBlock instance with the grading results.
|
||||
|
||||
Args:
|
||||
signal: The signal that triggered this handler
|
||||
sender: The object that sent the signal
|
||||
score: An object containing the score data with attributes:
|
||||
- score_msg: The actual score message/response from the grader
|
||||
- course_id: String ID of the course
|
||||
- user_id: ID of the user who submitted the problem
|
||||
- module_id: ID of the module/problem
|
||||
- submission_id: ID of the submission
|
||||
- queue_key: Key identifying the submission in the queue
|
||||
- queue_name: Name of the queue used for grading
|
||||
**kwargs: Additional keyword arguments passed with the signal
|
||||
|
||||
The function logs details about the score event, formats the grader message
|
||||
appropriately, and then calls the module's score_update handler to record
|
||||
the grade in the learning management system.
|
||||
"""
|
||||
|
||||
log.info(f"Received external grader score event: {signal}, {sender}, {score}, {kwargs}")
|
||||
|
||||
grader_msg = score.score_msg
|
||||
log.info(
|
||||
"External grader event score payload received: user_id=%s, module_id=%s, submission_id=%s, course_id=%s",
|
||||
score.user_id,
|
||||
score.module_id,
|
||||
score.submission_id,
|
||||
score.course_id,
|
||||
)
|
||||
|
||||
# Since we already confirm this in edx-submissions, it is safe to parse this
|
||||
grader_msg = json.loads(grader_msg)
|
||||
log.info(f"External grader score: {grader_msg['score']}")
|
||||
|
||||
data = {
|
||||
'xqueue_header': json.dumps({
|
||||
'lms_key': str(score.submission_id),
|
||||
'queue_name': score.queue_name
|
||||
}),
|
||||
'xqueue_body': json.dumps(grader_msg),
|
||||
'queuekey': score.queue_key
|
||||
}
|
||||
|
||||
try:
|
||||
course_key = CourseKey.from_string(score.course_id)
|
||||
course = modulestore().get_course(course_key, depth=0)
|
||||
except InvalidKeyError:
|
||||
log.error("Invalid course_id received from external grader: %s", score.course_id)
|
||||
return
|
||||
|
||||
try:
|
||||
usage_key = UsageKey.from_string(score.module_id)
|
||||
except InvalidKeyError:
|
||||
log.error("Invalid usage key received from external grader: %s", score.module_id)
|
||||
return
|
||||
|
||||
# pylint: disable=broad-exception-caught
|
||||
try:
|
||||
# Use our new function instead of load_single_xblock
|
||||
# NOTE: Importing this at module level causes a circular import because
|
||||
# score_render → block_render → grades signals → back into this module.
|
||||
# Keeping it inside the handler avoids that by loading it only when needed.
|
||||
from xmodule.capa.score_render import load_xblock_for_external_grader
|
||||
instance = load_xblock_for_external_grader(score.user_id,
|
||||
course_key,
|
||||
usage_key,
|
||||
course=course)
|
||||
|
||||
# Call the handler method (mirroring the original xqueue_callback)
|
||||
instance.handle_ajax('score_update', data)
|
||||
|
||||
# Save any state changes
|
||||
instance.save()
|
||||
|
||||
log.info(f"Successfully processed external grade for module {score.module_id}, user {score.user_id}")
|
||||
|
||||
except Exception as e:
|
||||
log.exception(
|
||||
"Error processing external grade for user_id=%s, module_id=%s, submission_id=%s: %s",
|
||||
score.user_id,
|
||||
score.module_id,
|
||||
score.submission_id,
|
||||
e,
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -3597,6 +3597,12 @@ EVENT_BUS_PRODUCER_CONFIG = {
|
||||
"enabled": Derived(should_send_learning_badge_events),
|
||||
},
|
||||
},
|
||||
"org.openedx.learning.external_grader.score.submitted.v1": {
|
||||
"learning-external-grader-score-lifecycle": {
|
||||
"event_key_field": "score.submission_id",
|
||||
"enabled": False
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
#### Survey Report ####
|
||||
|
||||
13
lms/urls.py
13
lms/urls.py
@@ -12,6 +12,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic.base import RedirectView
|
||||
from edx_api_doc_tools import make_docs_urls
|
||||
from edx_django_utils.plugins import get_plugin_url_patterns
|
||||
from submissions import urls as submissions_urls
|
||||
|
||||
from common.djangoapps.student import views as student_views
|
||||
from common.djangoapps.util import views as util_views
|
||||
@@ -355,6 +356,14 @@ urlpatterns += [
|
||||
name='xqueue_callback',
|
||||
),
|
||||
|
||||
re_path(
|
||||
r'^courses/{}/xqueue/(?P<userid>[^/]*)/(?P<mod_id>.*?)/(?P<dispatch>[^/]*)$'.format(
|
||||
settings.COURSE_ID_PATTERN,
|
||||
),
|
||||
xqueue_callback,
|
||||
name='callback_submission',
|
||||
),
|
||||
|
||||
# TODO: These views need to be updated before they work
|
||||
path('calculate', util_views.calculate),
|
||||
|
||||
@@ -1052,3 +1061,7 @@ urlpatterns += [
|
||||
urlpatterns += [
|
||||
path('api/notifications/', include('openedx.core.djangoapps.notifications.urls')),
|
||||
]
|
||||
|
||||
urlpatterns += [
|
||||
path('xqueue/', include((submissions_urls, 'submissions'), namespace='submissions')),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user