diff --git a/cms/envs/common.py b/cms/envs/common.py index f0371952f833..f3a09305d742 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -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', diff --git a/cms/urls.py b/cms/urls.py index 048339bc9fe9..0a782d423666 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -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 diff --git a/lms/djangoapps/grades/models.py b/lms/djangoapps/grades/models.py index 38cfed502c68..38d4f30724bc 100644 --- a/lms/djangoapps/grades/models.py +++ b/lms/djangoapps/grades/models.py @@ -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 @@ -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 ( @@ -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 @@ -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 @@ -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}" diff --git a/lms/djangoapps/grades/tests/test_events.py b/lms/djangoapps/grades/tests/test_events.py index 9ef9abfb59a3..945d08cc7e01 100644 --- a/lms/djangoapps/grades/tests/test_events.py +++ b/lms/djangoapps/grades/tests/test_events.py @@ -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 @@ -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. diff --git a/openedx/envs/common.py b/openedx/envs/common.py index 5d7c105025ed..968afd37ac35 100644 --- a/openedx/envs/common.py +++ b/openedx/envs/common.py @@ -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