Skip to content
Draft
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
7 changes: 0 additions & 7 deletions lms/djangoapps/grades/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,6 @@ def update_or_create_grade(cls, **params):
grade.first_attempted = first_attempted
grade.save()

cls._emit_grade_calculated_event(grade)
return grade

@classmethod
Expand All @@ -498,8 +497,6 @@ def bulk_create_grades(cls, grade_params_iter, user_id, course_key):

grades = [PersistentSubsectionGrade(**params) for params in grade_params_iter]
grades = cls.objects.bulk_create(grades)
for grade in grades:
cls._emit_grade_calculated_event(grade)
return grades

@classmethod
Expand All @@ -525,10 +522,6 @@ def _prepare_params_visible_blocks_id(cls, params):
params['visible_blocks_id'] = params['visible_blocks'].hash_value
del params['visible_blocks']

@staticmethod
def _emit_grade_calculated_event(grade):
events.subsection_grade_calculated(grade)

@classmethod
def _cache_key(cls, course_id):
return f"subsection_grades_cache.{course_id}"
Expand Down
24 changes: 24 additions & 0 deletions lms/djangoapps/grades/rest_api/v1/tests/test_gradebook_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from collections import OrderedDict, namedtuple
from contextlib import contextmanager
from datetime import datetime
from decimal import Decimal
from unittest.mock import MagicMock, patch

import ddt
Expand All @@ -17,6 +18,7 @@
from edx_toggles.toggles.testutils import override_waffle_flag
from freezegun import freeze_time
from opaque_keys.edx.locator import BlockUsageLocator
from openedx_learning.api import GradedObjectScore
from pytz import UTC
from rest_framework import status
from rest_framework.response import Response
Expand Down Expand Up @@ -1721,6 +1723,28 @@ def test_subsection_does_not_exist(self, login_method):
assert status.HTTP_422_UNPROCESSABLE_ENTITY == resp.status_code
assert expected_data == resp.data

@override_settings(ENABLE_COMPETENCY_MASTERY_TRACKING=True)
def test_new_subsection_grade_records_competency_status(self):
subsection = self.subsections[self.chapter_1.location][0]
score = GradedObjectScore(object_id=str(subsection.location), fraction=Decimal('0.5'))
post_data = [{
'user_id': self.student.id,
'usage_id': str(subsection.location),
'grade': {'earned_graded_override': 1, 'possible_graded_override': 2},
}]

with override_waffle_flag(self.waffle_flag, active=True):
self.login_staff()
# The fixture subsections have no scorable children, so a score needs this patch.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Related to my question on graded_object_score(): patching this hides the fact that the real gradebook path for a never-attempted learner records nothing. Once we've settled what the behavior should be, please have this test go through the real method (a scorable child in the fixture, or a mocked attempted score), so that it asserts the answer rather than bypassing the question.

with patch(
'lms.djangoapps.grades.subsection_grade.CreateSubsectionGrade.graded_object_score',
return_value=score,
), patch('lms.djangoapps.grades.subsection_grade.record_graded_object_statuses') as mock_record:
resp = self.client.post(self.get_url(), data=json.dumps(post_data), content_type='application/json')

assert status.HTTP_202_ACCEPTED == resp.status_code
mock_record.assert_any_call(user_id=self.student.id, scores=[score])

@ddt.data('login_staff', 'login_course_staff', 'login_course_admin')
def test_override_is_created(self, login_method):
"""
Expand Down
73 changes: 68 additions & 5 deletions lms/djangoapps/grades/subsection_grade.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@
from abc import ABCMeta
from collections import OrderedDict
from datetime import datetime, timezone
from decimal import Decimal
from logging import getLogger

from django.conf import settings
from django.db import transaction
from lazy import lazy
from openedx_learning.api import GradedObjectScore, record_graded_object_statuses
from xblock.scorable import ShowCorrectness

from lms.djangoapps.grades import events
from lms.djangoapps.grades.models import BlockRecord, PersistentSubsectionGrade
from lms.djangoapps.grades.scores import compute_percent, get_score, possibly_scored
from xmodule import block_metadata_utils, graders # pylint: disable=wrong-import-order
Expand All @@ -19,6 +24,29 @@
log = getLogger(__name__)


def _enqueue_competency_rollup(user_id, object_ids):
"""
Queues the competency rollup for the scored objects once their grade transaction has committed.
"""
# Imported here because tasks.py imports this module through the subsection grade factory.
from lms.djangoapps.grades.tasks import roll_up_competency_statuses_for_user

roll_up_competency_statuses_for_user.apply_async(kwargs={"user_id": user_id, "object_ids": object_ids})
Comment on lines +27 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please catch the publish failure here and log the user ID and object IDs at error level, so that the log line is something support can act on. Django's own robust handling logs "Error calling CreateSubsectionGrade.update_or_create_model.. in on_commit() during transaction" plus the traceback (docs; Claude pulled the message text from the Django 5.2.17 source), which says that a rollup was lost, but not whose. A task that fails after it's been picked up gets persisted in FailedTask by LoggedPersistOnFailureTask, kwargs and all (edx-celeryutils), but a task that never made it to the broker leaves nothing, and the only recovery is the operator recompute in openedx/openedx-core#774, which needs to know which learner to recompute. I'm fine with "log and move on" as the policy (the rollup only ever raises a status, so a lost one is stale rather than wrong); I just want the message to carry the IDs. Maybe something like:

    try:
        roll_up_competency_statuses_for_user.apply_async(kwargs={"user_id": user_id, "object_ids": object_ids})
    except Exception:  # pylint: disable=broad-except
        log.exception(
            "Could not queue competency rollup for user %s and objects %s; recompute manually.",
            user_id, object_ids,
        )

With that in place robust=True becomes a belt-and-suspenders thing, which is fine to leave.



def _record_competency_status(student, grades):
"""
Records competency status inside the caller's transaction and returns the object ids to roll up, or an empty
list when tracking is off, nothing is scored, or no rows changed.
"""
if not settings.ENABLE_COMPETENCY_MASTERY_TRACKING:
return []
scores = [score for score in (grade.graded_object_score() for grade in grades) if score]
if not scores or not record_graded_object_statuses(user_id=student.id, scores=scores):
return []
return [score.object_id for score in scores]


class SubsectionGradeBase(metaclass=ABCMeta): # noqa: B024
"""
Abstract base class for Subsection Grades.
Expand Down Expand Up @@ -285,7 +313,10 @@ def update_or_create_model(self, student, score_deleted=False, force_update_subs
"""
Saves or updates the subsection grade in a persisted model.
"""
if self._should_persist_per_attempted(score_deleted, force_update_subsections):
if not self._should_persist_per_attempted(score_deleted, force_update_subsections):
return None

with transaction.atomic():
model = PersistentSubsectionGrade.update_or_create_grade(**self._persisted_model_params(student))

if hasattr(model, 'override'):
Expand All @@ -296,20 +327,52 @@ def update_or_create_model(self, student, score_deleted=False, force_update_subs
self.all_total = self._aggregated_score_from_model(model, is_graded=False)
self.graded_total = self._aggregated_score_from_model(model, is_graded=True)

return model
object_ids = _record_competency_status(student, [self])
if object_ids:
# robust: a broker failure after the grade committed must not skip the event or fail the save.
transaction.on_commit(lambda: _enqueue_competency_rollup(student.id, object_ids), robust=True)

events.subsection_grade_calculated(model)
return model

@classmethod
def bulk_create_models(cls, student, subsection_grades, course_key):
"""
Saves the subsection grade in a persisted model.
"""
params = [
subsection_grade._persisted_model_params(student) # pylint: disable=protected-access
persisted_grades = [
subsection_grade
for subsection_grade in subsection_grades
if subsection_grade
if subsection_grade._should_persist_per_attempted() # pylint: disable=protected-access
]
return PersistentSubsectionGrade.bulk_create_grades(params, student.id, course_key)
params = [
subsection_grade._persisted_model_params(student) # pylint: disable=protected-access
for subsection_grade in persisted_grades
]
if not persisted_grades:
return None

with transaction.atomic():
grades = PersistentSubsectionGrade.bulk_create_grades(params, student.id, course_key)
object_ids = _record_competency_status(student, persisted_grades)
if object_ids:
# robust: a broker failure after the grades committed must not skip the events or fail the save.
transaction.on_commit(lambda: _enqueue_competency_rollup(student.id, object_ids), robust=True)

for grade in grades or []:
events.subsection_grade_calculated(grade)
return grades

def graded_object_score(self):
"""
Returns the graded fraction as a score, or None when nothing was attempted or nothing could be earned.
"""
if not self.attempted_graded or not self.graded_total.possible:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Question: what should happen for a grade that staff enter through the gradebook for a learner who never attempted the subsection? Reading the code, first_attempted on the row stays None in that case (update_or_create_override() doesn't touch it, and _aggregated_score_from_model() copies it from the row), so attempted_graded is False here and no status gets recorded, even though the override does count toward the learner's course grade. openedx/openedx-core#701 only says to skip unattempted objects, and its staff-correction scenario starts from a learner who scored below the threshold, so I'm not sure whether "staff entered a score" was meant to count as attempted. It seems like exactly the case CBE folks will hit early (an instructor recording an observed demonstration), so I'd like to know what the intended behavior is before this goes out. If it should count, I think the change is small (treat an override with earned_graded_override set as attempted), but I'm not sure that would reflect the product intent. @mgwozdz-unicon, do you know?

return None
# An override can push earned outside [0, possible]; core rejects fractions outside [0, 1].
fraction = min(max(Decimal(str(self.percent_graded)), Decimal(0)), Decimal(1))
return GradedObjectScore(object_id=str(self.location), fraction=fraction)

def _should_persist_per_attempted(self, score_deleted=False, force_update_subsections=False):
"""
Expand Down
24 changes: 24 additions & 0 deletions lms/djangoapps/grades/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
)
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locator import CourseLocator
from openedx_learning.api import roll_up_competency_statuses
from submissions import api as sub_api

from common.djangoapps.student.models import CourseEnrollment
Expand Down Expand Up @@ -185,6 +186,29 @@ def recalculate_subsection_grade_v3(self, **kwargs):
_recalculate_subsection_grade(self, **kwargs)


@shared_task(
bind=True,
base=LoggedPersistOnFailureTask,
time_limit=SUBSECTION_GRADE_TIMEOUT_SECONDS,
max_retries=2,
default_retry_delay=RETRY_DELAY_SECONDS
)
def roll_up_competency_statuses_for_user(self, **kwargs):
"""
Rolls up a learner's competency statuses for the graded objects whose criteria were just updated.

Keyword Arguments:
user_id (int): id of applicable User object
object_ids (list of string): usage keys of the graded objects that changed
"""
try:
roll_up_competency_statuses(user_id=kwargs['user_id'], object_ids=kwargs['object_ids'])
except Exception as exc:
if not isinstance(exc, KNOWN_RETRY_ERRORS):
raise
raise self.retry(kwargs=kwargs, exc=exc) from exc


def _recalculate_subsection_grade(self, **kwargs):
"""
Updates a saved subsection grade.
Expand Down
22 changes: 22 additions & 0 deletions lms/djangoapps/grades/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
""" Tests calling the grades api directly """


from decimal import Decimal
from unittest.mock import patch

import ddt
from django.test import override_settings
from openedx_learning.api import GradedObjectScore

from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.tests.factories import UserFactory
from lms.djangoapps.grades import api
from lms.djangoapps.grades.models import (
PersistentCourseGrade,
PersistentSubsectionGrade,
PersistentSubsectionGradeOverride,
)
from lms.djangoapps.grades.tests.utils import mock_get_score
from xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase, # pylint: disable=wrong-import-order
)
Expand Down Expand Up @@ -67,6 +72,23 @@ def tearDown(self):
super().tearDown()
PersistentSubsectionGradeOverride.objects.all().delete()

@override_settings(ENABLE_COMPETENCY_MASTERY_TRACKING=True)
def test_override_without_existing_grade_records_competency_status(self):
CourseEnrollment.enroll(self.user, self.course.id)
new_subsection = BlockFactory.create(parent=self.course, category="sequential", display_name="New Subsection")
BlockFactory.create(parent=new_subsection, category="problem", display_name="New Problem")
score = GradedObjectScore(object_id=str(new_subsection.location), fraction=Decimal('0.5'))

with mock_get_score(1, 2), patch(
'lms.djangoapps.grades.subsection_grade.record_graded_object_statuses'
) as mock_record:
api.override_subsection_grade(
self.user.id, self.course.id, new_subsection.location, overrider=self.overriding_user,
earned_graded=1.0,
)

mock_record.assert_called_once_with(user_id=self.user.id, scores=[score])

@ddt.data(0.0, None, 3.0)
def test_override_subsection_grade(self, earned_graded):
api.override_subsection_grade(
Expand Down
6 changes: 3 additions & 3 deletions lms/djangoapps/grades/tests/test_course_grade_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def _assert_section_order(course_grade):
with self.assertNumQueries(3), mock_get_score(1, 2):
_assert_read(expected_pass=False, expected_percent=0) # start off with grade of 0

num_queries = 44
num_queries = 48
with self.assertNumQueries(num_queries), mock_get_score(1, 2):
grade_factory.update(self.request.user, self.course, force_update_subsections=True)

Expand All @@ -87,14 +87,14 @@ def _assert_section_order(course_grade):
with self.assertNumQueries(3):
_assert_read(expected_pass=True, expected_percent=0.5) # NOT updated to grade of .25

num_queries = 20
num_queries = 24
with self.assertNumQueries(num_queries), mock_get_score(2, 2):
grade_factory.update(self.request.user, self.course, force_update_subsections=True)

with self.assertNumQueries(3):
_assert_read(expected_pass=True, expected_percent=1.0) # updated to grade of 1.0

num_queries = 30
num_queries = 34
with self.assertNumQueries(num_queries), mock_get_score(0, 0): # the subsection now is worth zero
grade_factory.update(self.request.user, self.course, force_update_subsections=True)

Expand Down
34 changes: 0 additions & 34 deletions lms/djangoapps/grades/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,16 +286,6 @@ def test_unattempted_save_does_not_remove_attempt(self):
assert isinstance(grade.first_attempted, datetime)
assert grade.earned_all == 6.0

def test_update_or_create_event(self):
with patch('lms.djangoapps.grades.events.tracker') as tracker_mock:
grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
self._assert_tracker_emitted_event(tracker_mock, grade)

def test_create_event(self):
with patch('lms.djangoapps.grades.events.tracker') as tracker_mock:
grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
self._assert_tracker_emitted_event(tracker_mock, grade)

def test_grade_override(self):
"""
Creating a subsection grade override should NOT change the score values
Expand All @@ -322,30 +312,6 @@ def test_grade_override(self):
assert grade.possible_all == override.possible_all_override
assert grade.possible_graded == override.possible_graded_override

def _assert_tracker_emitted_event(self, tracker_mock, grade):
"""
Helper function to ensure that the mocked event tracker
was called with the expected info based on the passed grade.
"""
tracker_mock.emit.assert_called_with(
'edx.grades.subsection.grade_calculated',
{
'user_id': str(grade.user_id),
'course_id': str(grade.course_id),
'block_id': str(grade.usage_key),
'course_version': str(grade.course_version),
'weighted_total_earned': grade.earned_all,
'weighted_total_possible': grade.possible_all,
'weighted_graded_earned': grade.earned_graded,
'weighted_graded_possible': grade.possible_graded,
'first_attempted': str(grade.first_attempted),
'subtree_edited_timestamp': str(grade.subtree_edited_timestamp),
'event_transaction_id': str(get_event_transaction_id()),
'event_transaction_type': str(get_event_transaction_type()),
'visible_blocks_hash': str(grade.visible_blocks_id),
}
)

def test_clear_subsection_grade(self):
PersistentSubsectionGrade.update_or_create_grade(**self.params)
deleted = PersistentSubsectionGrade.delete_subsection_grades_for_learner(
Expand Down
Loading
Loading