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
4 changes: 4 additions & 0 deletions cms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,10 @@ def make_lms_template_path(settings):
'openedx_tagging.core.tagging.apps.TaggingConfig',
'openedx.core.djangoapps.content_tagging',

# Assessment Criteria
"openedx_learning.apps.assessment_criteria.apps.AssessmentCriteriaConfig",


# Search
'openedx.core.djangoapps.content.search',

Expand Down
8 changes: 8 additions & 0 deletions cms/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,14 @@
path('api/content_tagging/', include(('openedx.core.djangoapps.content_tagging.urls', 'content_tagging'))),
]

# Assessment Criteria
urlpatterns += [
path('api/assessment_criteria/', include(
('openedx_learning.apps.assessment_criteria.urls', 'oel_assessment_criteria'),
namespace='oel_assessment_criteria'
)),
]

# Authoring-api specific API docs (using drf-spectacular and openapi-v3).
# This is separate from and in addition to the full studio swagger documentation already existing at /api-docs.
# Custom settings are provided in SPECTACULAR_SETTINGS as environment variables
Expand Down
62 changes: 60 additions & 2 deletions lms/djangoapps/grades/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,16 @@

from django.apps import apps
from django.db import models, IntegrityError, transaction
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 django.utils.timezone import now
from lazy import lazy
Expand All @@ -27,6 +35,7 @@
from simple_history.models import HistoricalRecords

from lms.djangoapps.courseware.fields import UnsignedBigIntAutoField
from lms.djangoapps.grades.course_data import CourseData as GradesCourseData
from lms.djangoapps.grades import events # lint-amnesty, pylint: disable=unused-import
from openedx.core.lib.cache_utils import get_cache
from lms.djangoapps.grades.signals.signals import (
Expand Down Expand Up @@ -479,6 +488,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
Expand All @@ -501,6 +511,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
Expand Down Expand Up @@ -530,6 +541,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}"
Expand Down
142 changes: 139 additions & 3 deletions lms/djangoapps/grades/tests/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,31 @@
CourseData,
CoursePassingStatusData,
PersistentCourseGradeData,
PersistentSubsectionGradeData,
UserData,
UserPersonalData
UserPersonalData,
XBlockWithScoringData,
)
from openedx_events.learning.signals import (
CCX_COURSE_PASSING_STATUS_UPDATED,
COURSE_PASSING_STATUS_UPDATED,
PERSISTENT_GRADE_SUMMARY_CHANGED
PERSISTENT_GRADE_SUMMARY_CHANGED,
PERSISTENT_SUBSECTION_GRADE_CHANGED,
)
from openedx_events.tests.utils import OpenEdxEventsTestMixin
from opaque_keys.edx.locator import BlockUsageLocator

from common.djangoapps.student.tests.factories import AdminFactory, UserFactory
from lms.djangoapps.ccx.models import CustomCourseForEdX
from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
from lms.djangoapps.grades.models import PersistentCourseGrade
from lms.djangoapps.grades.models import (
BlockRecord,
BlockRecordList,
PersistentCourseGrade,
PersistentSubsectionGrade,
)
from lms.djangoapps.grades.tests.utils import mock_passing_grade
from lms.djangoapps.grades.transformer import GradesTransformer
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from common.test.utils import assert_dict_contains_subset
Expand Down Expand Up @@ -113,6 +123,132 @@ def test_persistent_grade_event_emitted(self):
)


class PersistentSubsectionGradeEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin):
"""
Tests for the Open edX Events associated with the persistent subsection grade process.

This class guarantees that the following events are sent during the user updates their grade, with
the exact Data Attributes as the event definition stated:

- PERSISTENT_SUBSECTION_GRADE_CHANGED: sent after the user updates or creates the grade.
"""
ENABLED_OPENEDX_EVENTS = [
"org.openedx.learning.course.persistent_subsection_grade.changed.v1",
]

@classmethod
def setUpClass(cls):
"""
Set up class method for the Test class.

This method starts manually events isolation. Explanation here:
openedx/core/djangoapps/user_authn/views/tests/test_events.py#L44
"""
super().setUpClass()
cls.start_events_isolation()

def setUp(self): # pylint: disable=arguments-differ
super().setUp()
self.course = CourseFactory.create()
self.user = UserFactory.create()
self.subsection_usage_key = BlockUsageLocator(
course_key=self.course.id,
block_type='sequential',
block_id='subsection_12345',
)
self.problem_locator_a = BlockUsageLocator(
course_key=self.course.id,
block_type='problem',
block_id='problem_abc',
)
self.problem_locator_b = BlockUsageLocator(
course_key=self.course.id,
block_type='problem',
block_id='problem_def',
)
self.record_a = BlockRecord(locator=self.problem_locator_a, weight=1, raw_possible=10, graded=False)
self.record_b = BlockRecord(locator=self.problem_locator_b, weight=1, raw_possible=10, graded=True)
self.block_records = BlockRecordList([self.record_a, self.record_b], self.course.id)
self.params = {
"user_id": self.user.id,
"usage_key": self.subsection_usage_key,
"course_version": self.course.number,
"subtree_edited_timestamp": now(),
"earned_all": 6.0,
"possible_all": 12.0,
"earned_graded": 6.0,
"possible_graded": 8.0,
"visible_blocks": self.block_records,
"first_attempted": now(),
}
self.receiver_called = False

def _event_receiver_side_effect(self, **kwargs): # pylint: disable=unused-argument
"""
Used show that the Open edX Event was called by the Django signal handler.
"""
self.receiver_called = True

def test_persistent_subsection_grade_event_emitted(self):
"""
Test whether the persistent subsection grade updated event is sent after the user updates creates or
updates their grade.

Expected result:
- PERSISTENT_SUBSECTION_GRADE_CHANGED is sent and received by the mocked receiver.
- The arguments that the receiver gets are the arguments sent by the event
except the metadata generated on the fly.
"""
event_receiver = mock.Mock(side_effect=self._event_receiver_side_effect)

PERSISTENT_SUBSECTION_GRADE_CHANGED.connect(event_receiver)
grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
self.assertTrue(self.receiver_called)

grading_policy_hash = GradesTransformer.grading_policy_hash(self.course)
visible_blocks = [
XBlockWithScoringData(
usage_key=self.record_a.locator,
block_type=self.record_a.locator.block_type,
graded=self.record_a.graded,
raw_possible=self.record_a.raw_possible,
weight=self.record_a.weight,
),
XBlockWithScoringData(
usage_key=self.record_b.locator,
block_type=self.record_b.locator.block_type,
graded=self.record_b.graded,
raw_possible=self.record_b.raw_possible,
weight=self.record_b.weight,
),
]

assert_dict_contains_subset(
self,
{
"signal": PERSISTENT_SUBSECTION_GRADE_CHANGED,
"sender": None,
"grade": PersistentSubsectionGradeData(
user_id=self.params["user_id"],
course=CourseData(
course_key=self.course.id,
),
subsection_edited_timestamp=self.params["subtree_edited_timestamp"],
grading_policy_hash=grading_policy_hash,
usage_key=self.subsection_usage_key,
weighted_graded_earned=self.params["earned_graded"],
weighted_graded_possible=self.params["possible_graded"],
weighted_total_earned=self.params["earned_all"],
weighted_total_possible=self.params["possible_all"],
first_attempted=self.params["first_attempted"],
visible_blocks=visible_blocks,
visible_blocks_hash=str(grade.visible_blocks_id),
)
},
event_receiver.call_args.kwargs,
)


class CoursePassingStatusEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin):
"""
Tests for Open edX passing status update event.
Expand Down
6 changes: 6 additions & 0 deletions openedx/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2701,6 +2701,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
Expand Down
Loading