From ef6784826d8ca88220de82a00314547d4c49e130 Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Wed, 8 Jul 2026 16:18:09 -0400 Subject: [PATCH 1/4] feat: Add PERSISTENT_SUBSECTION_GRADE_CHANGED event support Adds a new openedx event that fires when a persistenty subsection grade changes and is event bus compatible. Needed for CBE work to help rate student mastery. --- lms/djangoapps/grades/models.py | 62 +++++++++++++++++++++++++++++++-- openedx/envs/common.py | 6 ++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/grades/models.py b/lms/djangoapps/grades/models.py index 50cfa2819ca4..2651552cc1e8 100644 --- a/lms/djangoapps/grades/models.py +++ b/lms/djangoapps/grades/models.py @@ -21,12 +21,21 @@ from model_utils.models import TimeStampedModel from opaque_keys.edx.django.models import CourseKeyField, UsageKeyField from opaque_keys.edx.keys import CourseKey, UsageKey -from openedx_events.learning.data import CourseData, PersistentCourseGradeData -from openedx_events.learning.signals import PERSISTENT_GRADE_SUMMARY_CHANGED +from openedx_events.learning.data import ( + CourseData, + PersistentCourseGradeData, + PersistentSubsectionGradeData, + XBlockWithScoringData, +) +from openedx_events.learning.signals import ( + PERSISTENT_GRADE_SUMMARY_CHANGED, + PERSISTENT_SUBSECTION_GRADE_CHANGED, +) from simple_history.models import HistoricalRecords from lms.djangoapps.courseware.fields import UnsignedBigIntAutoField from lms.djangoapps.grades import events # pylint: disable=unused-import +from lms.djangoapps.grades.course_data import CourseData as GradesCourseData from lms.djangoapps.grades.signals.signals import ( COURSE_GRADE_PASSED_FIRST_TIME, COURSE_GRADE_PASSED_UPDATE_IN_LEARNER_PATHWAY, @@ -478,6 +487,7 @@ def update_or_create_grade(cls, **params): grade.save() cls._emit_grade_calculated_event(grade) + cls._emit_openedx_persistent_subsection_grade_changed_event(grade) return grade @classmethod @@ -500,6 +510,7 @@ def bulk_create_grades(cls, grade_params_iter, user_id, course_key): grades = cls.objects.bulk_create(grades) for grade in grades: cls._emit_grade_calculated_event(grade) + cls._emit_openedx_persistent_subsection_grade_changed_event(grade) return grades @classmethod @@ -529,6 +540,53 @@ def _prepare_params_visible_blocks_id(cls, params): def _emit_grade_calculated_event(grade): events.subsection_grade_calculated(grade) + @staticmethod + def _emit_openedx_persistent_subsection_grade_changed_event(grade): + """ + When called emits an event when a persistent subsection grade is created or updated. + """ + # .. event_implemented_name: PERSISTENT_SUBSECTION_GRADE_CHANGED + # .. event_type: org.openedx.learning.course.persistent_subsection_grade.changed.v1 + try: + grading_policy_hash = GradesCourseData(user=None, course_key=grade.course_id).grading_policy_hash + except Exception: # pylint: disable=broad-except + grading_policy_hash = "" + log.debug( + "Unable to compute grading_policy_hash for course %s", + grade.course_id, + exc_info=True, + ) + + visible_blocks = [ + XBlockWithScoringData( + usage_key=block.locator, + block_type=block.locator.block_type, + graded=block.graded, + raw_possible=block.raw_possible, + weight=block.weight, + ) + for block in grade.visible_blocks.blocks + ] + + PERSISTENT_SUBSECTION_GRADE_CHANGED.send_event( + grade=PersistentSubsectionGradeData( + user_id=grade.user_id, + course=CourseData( + course_key=grade.course_id, + ), + subsection_edited_timestamp=grade.subtree_edited_timestamp, + grading_policy_hash=grading_policy_hash, + usage_key=grade.usage_key, + weighted_graded_earned=grade.earned_graded, + weighted_graded_possible=grade.possible_graded, + weighted_total_earned=grade.earned_all, + weighted_total_possible=grade.possible_all, + first_attempted=grade.first_attempted, + visible_blocks=visible_blocks, + visible_blocks_hash=str(grade.visible_blocks_id), + ) + ) + @classmethod def _cache_key(cls, course_id): return f"subsection_grades_cache.{course_id}" diff --git a/openedx/envs/common.py b/openedx/envs/common.py index 31b0015d34d9..892a1001a662 100644 --- a/openedx/envs/common.py +++ b/openedx/envs/common.py @@ -2736,6 +2736,12 @@ def should_send_learning_badge_events(settings): "enabled": Derived(should_send_learning_badge_events), }, }, + "org.openedx.learning.course.persistent_subsection_grade.changed.v1": { + "learning-subsection-grade": { + "event_key_field": "grade.course.course_key", + "enabled": True, + }, + }, } ### event tracking From 9382bfcb1736fa2dee578daecc0b0bf4d45dcfe0 Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Wed, 8 Jul 2026 16:18:22 -0400 Subject: [PATCH 2/4] temp: Use my branch until we release --- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/kernel.in | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 1ea898a4b98d..7db721f3d7f6 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -850,7 +850,7 @@ openedx-django-require==3.0.0 # via -r requirements/edx/kernel.in openedx-django-wiki==3.1.2 # via -r requirements/edx/kernel.in -openedx-events==11.2.0 +git+https://github.com/openedx/openedx-events.git@bmtcril/subsection-grade # via # -r requirements/edx/kernel.in # edx-enterprise diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 52557cf42256..77799cf4d67b 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1401,7 +1401,7 @@ openedx-django-wiki==3.1.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-events==11.2.0 +git+https://github.com/openedx/openedx-events.git@bmtcril/subsection-grade # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 7ebcafa83e84..d07490db6031 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -1030,7 +1030,7 @@ openedx-django-require==3.0.0 # via -r requirements/edx/base.txt openedx-django-wiki==3.1.2 # via -r requirements/edx/base.txt -openedx-events==11.2.0 +git+https://github.com/openedx/openedx-events.git@bmtcril/subsection-grade # via # -r requirements/edx/base.txt # edx-enterprise diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index 8abecd6ea77a..ebbfa9d7f3ac 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -115,7 +115,7 @@ openedx-atlas # CLI tool to manage translations openedx-calc # Library supporting mathematical calculations for Open edX openedx-core openedx-django-require -openedx-events # Open edX Events from Hooks Extension Framework (OEP-50) +git+https://github.com/openedx/openedx-events.git@bmtcril/subsection-grade # Open edX Events from Hooks Extension Framework (OEP-50) openedx-filters # Open edX Filters from Hooks Extension Framework (OEP-50) openedx-forum # Open edX forum v2 application openedx-django-wiki diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index a6c1501f7a25..0ec285c582c7 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1070,7 +1070,7 @@ openedx-django-require==3.0.0 # via -r requirements/edx/base.txt openedx-django-wiki==3.1.2 # via -r requirements/edx/base.txt -openedx-events==11.2.0 +git+https://github.com/openedx/openedx-events.git@bmtcril/subsection-grade # via # -r requirements/edx/base.txt # edx-enterprise From 32ee413d81244381dd7f49978e5016af158d5d02 Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Thu, 9 Jul 2026 15:17:52 -0400 Subject: [PATCH 3/4] fix: Reduce unnecessary queries Tests were failing due to additional new queries on visible blocks. This is a first attempt to cut down or eliminate the new queries. --- lms/djangoapps/grades/models.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/lms/djangoapps/grades/models.py b/lms/djangoapps/grades/models.py index 2651552cc1e8..6d96cc33e02c 100644 --- a/lms/djangoapps/grades/models.py +++ b/lms/djangoapps/grades/models.py @@ -466,7 +466,10 @@ def update_or_create_grade(cls, **params): Wrapper for objects.update_or_create. """ cls._prepare_params(params) - VisibleBlocks.cached_get_or_create(params['user_id'], params['visible_blocks']) + # Capture the BlockRecordList before it's removed from params below, so the + # event can be emitted without extra queries on grade.visible_blocks + visible_blocks = params['visible_blocks'] + VisibleBlocks.cached_get_or_create(params['user_id'], visible_blocks) cls._prepare_params_visible_blocks_id(params) # TODO: do we NEED to pop these? @@ -487,7 +490,7 @@ def update_or_create_grade(cls, **params): grade.save() cls._emit_grade_calculated_event(grade) - cls._emit_openedx_persistent_subsection_grade_changed_event(grade) + cls._emit_openedx_persistent_subsection_grade_changed_event(grade, visible_blocks) return grade @classmethod @@ -501,16 +504,17 @@ def bulk_create_grades(cls, grade_params_iter, user_id, course_key): PersistentSubsectionGradeOverride.prefetch(user_id, course_key) list(map(cls._prepare_params, grade_params_iter)) - VisibleBlocks.bulk_get_or_create( - user_id, course_key, [params['visible_blocks'] for params in grade_params_iter] - ) + # Capture the BlockRecordLists (in order) before they're removed from params below, + # so events can be emitted without extra queries on grade.visible_blocks + visible_blocks_list = [params['visible_blocks'] for params in grade_params_iter] + VisibleBlocks.bulk_get_or_create(user_id, course_key, visible_blocks_list) list(map(cls._prepare_params_visible_blocks_id, grade_params_iter)) grades = [PersistentSubsectionGrade(**params) for params in grade_params_iter] grades = cls.objects.bulk_create(grades) - for grade in grades: + for grade, visible_blocks in zip(grades, visible_blocks_list, strict=True): cls._emit_grade_calculated_event(grade) - cls._emit_openedx_persistent_subsection_grade_changed_event(grade) + cls._emit_openedx_persistent_subsection_grade_changed_event(grade, visible_blocks) return grades @classmethod @@ -541,7 +545,7 @@ def _emit_grade_calculated_event(grade): events.subsection_grade_calculated(grade) @staticmethod - def _emit_openedx_persistent_subsection_grade_changed_event(grade): + def _emit_openedx_persistent_subsection_grade_changed_event(grade, visible_blocks): """ When called emits an event when a persistent subsection grade is created or updated. """ @@ -557,7 +561,7 @@ def _emit_openedx_persistent_subsection_grade_changed_event(grade): exc_info=True, ) - visible_blocks = [ + xblocks_scoring_data = [ XBlockWithScoringData( usage_key=block.locator, block_type=block.locator.block_type, @@ -565,7 +569,7 @@ def _emit_openedx_persistent_subsection_grade_changed_event(grade): raw_possible=block.raw_possible, weight=block.weight, ) - for block in grade.visible_blocks.blocks + for block in visible_blocks ] PERSISTENT_SUBSECTION_GRADE_CHANGED.send_event( @@ -582,7 +586,7 @@ def _emit_openedx_persistent_subsection_grade_changed_event(grade): weighted_total_earned=grade.earned_all, weighted_total_possible=grade.possible_all, first_attempted=grade.first_attempted, - visible_blocks=visible_blocks, + visible_blocks=xblocks_scoring_data, visible_blocks_hash=str(grade.visible_blocks_id), ) ) From e2ae2a826ec000c38e9a2d4971f99528d9bced47 Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Fri, 10 Jul 2026 10:15:27 -0400 Subject: [PATCH 4/4] perf: Reduce queries by passing grading policy has through when it exists --- lms/djangoapps/grades/models.py | 33 +++++++++++-------- lms/djangoapps/grades/subsection_grade.py | 15 ++++++--- .../grades/subsection_grade_factory.py | 11 +++++-- 3 files changed, 38 insertions(+), 21 deletions(-) diff --git a/lms/djangoapps/grades/models.py b/lms/djangoapps/grades/models.py index 6d96cc33e02c..6b0d753bdd18 100644 --- a/lms/djangoapps/grades/models.py +++ b/lms/djangoapps/grades/models.py @@ -461,7 +461,7 @@ def bulk_read_grades(cls, user_id, course_key): ) @classmethod - def update_or_create_grade(cls, **params): + def update_or_create_grade(cls, grading_policy_hash=None, **params): """ Wrapper for objects.update_or_create. """ @@ -490,11 +490,11 @@ def update_or_create_grade(cls, **params): grade.save() cls._emit_grade_calculated_event(grade) - cls._emit_openedx_persistent_subsection_grade_changed_event(grade, visible_blocks) + cls._emit_openedx_persistent_subsection_grade_changed_event(grade, visible_blocks, grading_policy_hash) return grade @classmethod - def bulk_create_grades(cls, grade_params_iter, user_id, course_key): + def bulk_create_grades(cls, grade_params_iter, user_id, course_key, grading_policy_hash=None): """ Bulk creation of grades. """ @@ -514,7 +514,7 @@ def bulk_create_grades(cls, grade_params_iter, user_id, course_key): grades = cls.objects.bulk_create(grades) for grade, visible_blocks in zip(grades, visible_blocks_list, strict=True): cls._emit_grade_calculated_event(grade) - cls._emit_openedx_persistent_subsection_grade_changed_event(grade, visible_blocks) + cls._emit_openedx_persistent_subsection_grade_changed_event(grade, visible_blocks, grading_policy_hash) return grades @classmethod @@ -545,21 +545,26 @@ def _emit_grade_calculated_event(grade): events.subsection_grade_calculated(grade) @staticmethod - def _emit_openedx_persistent_subsection_grade_changed_event(grade, visible_blocks): + def _emit_openedx_persistent_subsection_grade_changed_event(grade, visible_blocks, grading_policy_hash=None): """ When called emits an event when a persistent subsection grade is created or updated. + + ``grading_policy_hash`` should be supplied by callers that already have the course's + block structure loaded, so it can be reused instead of loading the course from the + modulestore (an extra query per event). When not provided, it is computed here. """ # .. event_implemented_name: PERSISTENT_SUBSECTION_GRADE_CHANGED # .. event_type: org.openedx.learning.course.persistent_subsection_grade.changed.v1 - try: - grading_policy_hash = GradesCourseData(user=None, course_key=grade.course_id).grading_policy_hash - except Exception: # pylint: disable=broad-except - grading_policy_hash = "" - log.debug( - "Unable to compute grading_policy_hash for course %s", - grade.course_id, - exc_info=True, - ) + if grading_policy_hash is None: + try: + grading_policy_hash = GradesCourseData(user=None, course_key=grade.course_id).grading_policy_hash + except Exception: # pylint: disable=broad-except + grading_policy_hash = "" + log.debug( + "Unable to compute grading_policy_hash for course %s", + grade.course_id, + exc_info=True, + ) xblocks_scoring_data = [ XBlockWithScoringData( diff --git a/lms/djangoapps/grades/subsection_grade.py b/lms/djangoapps/grades/subsection_grade.py index 5fab4fb5f116..fff912889d57 100644 --- a/lms/djangoapps/grades/subsection_grade.py +++ b/lms/djangoapps/grades/subsection_grade.py @@ -281,12 +281,17 @@ def __init__(self, subsection, course_structure, submissions_scores, csm_scores) super().__init__(subsection, all_total, graded_total) - def update_or_create_model(self, student, score_deleted=False, force_update_subsections=False): + def update_or_create_model( + self, student, score_deleted=False, force_update_subsections=False, grading_policy_hash=None + ): """ Saves or updates the subsection grade in a persisted model. """ if self._should_persist_per_attempted(score_deleted, force_update_subsections): - model = PersistentSubsectionGrade.update_or_create_grade(**self._persisted_model_params(student)) + model = PersistentSubsectionGrade.update_or_create_grade( + grading_policy_hash=grading_policy_hash, + **self._persisted_model_params(student), + ) if hasattr(model, 'override'): # When we're doing an update operation, the PersistentSubsectionGrade model @@ -299,7 +304,7 @@ def update_or_create_model(self, student, score_deleted=False, force_update_subs return model @classmethod - def bulk_create_models(cls, student, subsection_grades, course_key): + def bulk_create_models(cls, student, subsection_grades, course_key, grading_policy_hash=None): """ Saves the subsection grade in a persisted model. """ @@ -309,7 +314,9 @@ def bulk_create_models(cls, student, subsection_grades, course_key): if subsection_grade if subsection_grade._should_persist_per_attempted() # pylint: disable=protected-access ] - return PersistentSubsectionGrade.bulk_create_grades(params, student.id, course_key) + return PersistentSubsectionGrade.bulk_create_grades( + params, student.id, course_key, grading_policy_hash=grading_policy_hash + ) def _should_persist_per_attempted(self, score_deleted=False, force_update_subsections=False): """ diff --git a/lms/djangoapps/grades/subsection_grade_factory.py b/lms/djangoapps/grades/subsection_grade_factory.py index 6b9c75a7606f..67e636cfc656 100644 --- a/lms/djangoapps/grades/subsection_grade_factory.py +++ b/lms/djangoapps/grades/subsection_grade_factory.py @@ -57,7 +57,10 @@ def create(self, subsection, read_only=False, force_calculate=False): if read_only: self._unsaved_subsection_grades[subsection_grade.location] = subsection_grade else: - grade_model = subsection_grade.update_or_create_model(self.student) + grade_model = subsection_grade.update_or_create_model( + self.student, + grading_policy_hash=self.course_data.grading_policy_hash, + ) self._update_saved_subsection_grade(subsection.location, grade_model) return subsection_grade @@ -66,7 +69,8 @@ def bulk_create_unsaved(self): Bulk creates all the unsaved subsection_grades to this point. """ CreateSubsectionGrade.bulk_create_models( - self.student, list(self._unsaved_subsection_grades.values()), self.course_data.course_key + self.student, list(self._unsaved_subsection_grades.values()), self.course_data.course_key, + grading_policy_hash=self.course_data.grading_policy_hash, ) self._unsaved_subsection_grades.clear() @@ -100,7 +104,8 @@ def update(self, subsection, only_if_higher=None, score_deleted=False, force_upd grade_model = calculated_grade.update_or_create_model( self.student, score_deleted, - force_update_subsections + force_update_subsections, + grading_policy_hash=self.course_data.grading_policy_hash, ) self._update_saved_subsection_grade(subsection.location, grade_model)