Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 106 additions & 9 deletions lms/djangoapps/grades/signals/handlers.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -23,23 +24,24 @@
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__)
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Is this a case where the event itself should be defined differently (or have an alias attribute)?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This event is triggered from edx-submissions and it has already been merged. For this phase, we kept the event payload aligned with the legacy XQueue structure to maintain compatibility with the existing grading flow. Once we move further in the deprecation and no longer need to mirror XQueue, we can introduce a cleaner event schema or alias fields.

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
6 changes: 6 additions & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3597,6 +3597,12 @@ def _should_send_certificate_events(settings):
"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 ####
Expand Down
13 changes: 13 additions & 0 deletions lms/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
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
Expand Down Expand Up @@ -355,6 +356,14 @@
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),

Expand Down Expand Up @@ -1052,3 +1061,7 @@
urlpatterns += [
path('api/notifications/', include('openedx.core.djangoapps.notifications.urls')),
]

urlpatterns += [
path('xqueue/', include((submissions_urls, 'submissions'), namespace='submissions')),
]
2 changes: 1 addition & 1 deletion requirements/edx/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,7 @@ edx-search==4.3.0
# openedx-forum
edx-sga==0.27.0
# via -r requirements/edx/bundled.in
edx-submissions==3.12.1
edx-submissions==3.12.2
# via
# -r requirements/edx/kernel.in
# ora2
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/development.txt
Original file line number Diff line number Diff line change
Expand Up @@ -828,7 +828,7 @@ edx-sga==0.27.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
edx-submissions==3.12.1
edx-submissions==3.12.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/doc.txt
Original file line number Diff line number Diff line change
Expand Up @@ -615,7 +615,7 @@ edx-search==4.3.0
# openedx-forum
edx-sga==0.27.0
# via -r requirements/edx/base.txt
edx-submissions==3.12.1
edx-submissions==3.12.2
# via
# -r requirements/edx/base.txt
# ora2
Expand Down
1 change: 0 additions & 1 deletion requirements/edx/github.in
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@

# ... add dependencies here


##############################################################################
# Critical fixes for packages that are not yet available in a PyPI release.
##############################################################################
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/testing.txt
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,7 @@ edx-search==4.3.0
# openedx-forum
edx-sga==0.27.0
# via -r requirements/edx/base.txt
edx-submissions==3.12.1
edx-submissions==3.12.2
# via
# -r requirements/edx/base.txt
# ora2
Expand Down
90 changes: 90 additions & 0 deletions xmodule/capa/score_render.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""
Score rendering when submission is evaluated for external grader and has been saved successfully
"""
import logging
from functools import partial

from django.http import Http404
from edx_when.field_data import DateLookupFieldData
from opaque_keys.edx.keys import CourseKey, UsageKey
from xblock.runtime import KvsFieldData

from common.djangoapps.student.models import AnonymousUserId
from lms.djangoapps.courseware.block_render import prepare_runtime_for_user
from lms.djangoapps.courseware.field_overrides import OverrideFieldData
from lms.djangoapps.courseware.model_data import DjangoKeyValueStore, FieldDataCache
from lms.djangoapps.lms_xblock.field_data import LmsFieldData
from xmodule.modulestore.django import modulestore

log = logging.getLogger(__name__)


def load_xblock_for_external_grader(
user_id: str,
course_key: CourseKey,
usage_key: UsageKey,
course=None,
):
"""
Load a single XBlock for external grading without user access checks.
"""

user = AnonymousUserId.objects.get(anonymous_user_id=user_id).user

# pylint: disable=broad-exception-caught
try:
block = modulestore().get_item(usage_key)
except Exception as e:
log.exception(f"Could not find block {usage_key} in modulestore: {e}")
raise Http404(f"Module {usage_key} was not found") from e

field_data_cache = FieldDataCache.cache_for_block_descendents(
course_key, user, block, depth=0
)

student_kvs = DjangoKeyValueStore(field_data_cache)
student_data = KvsFieldData(student_kvs)

instance = get_block_for_descriptor_without_access_check(
user=user,
block=block,
student_data=student_data,
course_key=course_key,
course=course
)

if instance is None:
msg = f"Could not bind XBlock instance for usage key: {usage_key}"
log.error(msg)
raise Http404(msg)

return instance


def get_block_for_descriptor_without_access_check(user, block, student_data, course_key, course=None):
"""
Modified version of get_block_for_descriptor that skips access checks for system operations.
"""

prepare_runtime_for_user(
user=user,
student_data=student_data,
runtime=block.runtime,
course_id=course_key,
course=course,
track_function=lambda event_type, event: None,
request_token="external-grader-token",
position=None,
wrap_xblock_display=True,
)

block.bind_for_student(
user.id,
[
partial(DateLookupFieldData, course_id=course_key, user=user),
partial(OverrideFieldData.wrap, user, course),
partial(LmsFieldData, student_data=student_data),
],
)

return block
Loading
Loading