Skip to content

Commit b2a781d

Browse files
feat: Assessment Criteria POC
1 parent 9bf7a72 commit b2a781d

5 files changed

Lines changed: 217 additions & 5 deletions

File tree

cms/envs/common.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,10 @@ def make_lms_template_path(settings):
852852
'openedx_tagging.core.tagging.apps.TaggingConfig',
853853
'openedx.core.djangoapps.content_tagging',
854854

855+
# Assessment Criteria
856+
"openedx_learning.apps.assessment_criteria.apps.AssessmentCriteriaConfig",
857+
858+
855859
# Search
856860
'openedx.core.djangoapps.content.search',
857861

cms/urls.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,14 @@
360360
path('api/content_tagging/', include(('openedx.core.djangoapps.content_tagging.urls', 'content_tagging'))),
361361
]
362362

363+
# Assessment Criteria
364+
urlpatterns += [
365+
path('api/assessment_criteria/', include(
366+
('openedx_learning.apps.assessment_criteria.urls', 'oel_assessment_criteria'),
367+
namespace='oel_assessment_criteria'
368+
)),
369+
]
370+
363371
# Authoring-api specific API docs (using drf-spectacular and openapi-v3).
364372
# This is separate from and in addition to the full studio swagger documentation already existing at /api-docs.
365373
# Custom settings are provided in SPECTACULAR_SETTINGS as environment variables

lms/djangoapps/grades/models.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,16 @@
1616

1717
from django.apps import apps
1818
from django.db import models, IntegrityError, transaction
19-
from openedx_events.learning.data import CourseData, PersistentCourseGradeData
20-
from openedx_events.learning.signals import PERSISTENT_GRADE_SUMMARY_CHANGED
19+
from openedx_events.learning.data import (
20+
CourseData,
21+
PersistentCourseGradeData,
22+
PersistentSubsectionGradeData,
23+
XBlockWithScoringData,
24+
)
25+
from openedx_events.learning.signals import (
26+
PERSISTENT_GRADE_SUMMARY_CHANGED,
27+
PERSISTENT_SUBSECTION_GRADE_CHANGED,
28+
)
2129

2230
from django.utils.timezone import now
2331
from lazy import lazy
@@ -27,6 +35,7 @@
2735
from simple_history.models import HistoricalRecords
2836

2937
from lms.djangoapps.courseware.fields import UnsignedBigIntAutoField
38+
from lms.djangoapps.grades.course_data import CourseData as GradesCourseData
3039
from lms.djangoapps.grades import events # lint-amnesty, pylint: disable=unused-import
3140
from openedx.core.lib.cache_utils import get_cache
3241
from lms.djangoapps.grades.signals.signals import (
@@ -479,6 +488,7 @@ def update_or_create_grade(cls, **params):
479488
grade.save()
480489

481490
cls._emit_grade_calculated_event(grade)
491+
cls._emit_openedx_persistent_subsection_grade_changed_event(grade)
482492
return grade
483493

484494
@classmethod
@@ -501,6 +511,7 @@ def bulk_create_grades(cls, grade_params_iter, user_id, course_key):
501511
grades = cls.objects.bulk_create(grades)
502512
for grade in grades:
503513
cls._emit_grade_calculated_event(grade)
514+
cls._emit_openedx_persistent_subsection_grade_changed_event(grade)
504515
return grades
505516

506517
@classmethod
@@ -530,6 +541,53 @@ def _prepare_params_visible_blocks_id(cls, params):
530541
def _emit_grade_calculated_event(grade):
531542
events.subsection_grade_calculated(grade)
532543

544+
@staticmethod
545+
def _emit_openedx_persistent_subsection_grade_changed_event(grade):
546+
"""
547+
When called emits an event when a persistent subsection grade is created or updated.
548+
"""
549+
# .. event_implemented_name: PERSISTENT_SUBSECTION_GRADE_CHANGED
550+
# .. event_type: org.openedx.learning.course.persistent_subsection_grade.changed.v1
551+
try:
552+
grading_policy_hash = GradesCourseData(user=None, course_key=grade.course_id).grading_policy_hash
553+
except Exception: # pylint: disable=broad-except
554+
grading_policy_hash = ""
555+
log.debug(
556+
"Unable to compute grading_policy_hash for course %s",
557+
grade.course_id,
558+
exc_info=True,
559+
)
560+
561+
visible_blocks = [
562+
XBlockWithScoringData(
563+
usage_key=block.locator,
564+
block_type=block.locator.block_type,
565+
graded=block.graded,
566+
raw_possible=block.raw_possible,
567+
weight=block.weight,
568+
)
569+
for block in grade.visible_blocks.blocks
570+
]
571+
572+
PERSISTENT_SUBSECTION_GRADE_CHANGED.send_event(
573+
grade=PersistentSubsectionGradeData(
574+
user_id=grade.user_id,
575+
course=CourseData(
576+
course_key=grade.course_id,
577+
),
578+
subsection_edited_timestamp=grade.subtree_edited_timestamp,
579+
grading_policy_hash=grading_policy_hash,
580+
usage_key=grade.usage_key,
581+
weighted_graded_earned=grade.earned_graded,
582+
weighted_graded_possible=grade.possible_graded,
583+
weighted_total_earned=grade.earned_all,
584+
weighted_total_possible=grade.possible_all,
585+
first_attempted=grade.first_attempted,
586+
visible_blocks=visible_blocks,
587+
visible_blocks_hash=str(grade.visible_blocks_id),
588+
)
589+
)
590+
533591
@classmethod
534592
def _cache_key(cls, course_id):
535593
return f"subsection_grades_cache.{course_id}"

lms/djangoapps/grades/tests/test_events.py

Lines changed: 139 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,31 @@
1212
CourseData,
1313
CoursePassingStatusData,
1414
PersistentCourseGradeData,
15+
PersistentSubsectionGradeData,
1516
UserData,
16-
UserPersonalData
17+
UserPersonalData,
18+
XBlockWithScoringData,
1719
)
1820
from openedx_events.learning.signals import (
1921
CCX_COURSE_PASSING_STATUS_UPDATED,
2022
COURSE_PASSING_STATUS_UPDATED,
21-
PERSISTENT_GRADE_SUMMARY_CHANGED
23+
PERSISTENT_GRADE_SUMMARY_CHANGED,
24+
PERSISTENT_SUBSECTION_GRADE_CHANGED,
2225
)
2326
from openedx_events.tests.utils import OpenEdxEventsTestMixin
27+
from opaque_keys.edx.locator import BlockUsageLocator
2428

2529
from common.djangoapps.student.tests.factories import AdminFactory, UserFactory
2630
from lms.djangoapps.ccx.models import CustomCourseForEdX
2731
from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
28-
from lms.djangoapps.grades.models import PersistentCourseGrade
32+
from lms.djangoapps.grades.models import (
33+
BlockRecord,
34+
BlockRecordList,
35+
PersistentCourseGrade,
36+
PersistentSubsectionGrade,
37+
)
2938
from lms.djangoapps.grades.tests.utils import mock_passing_grade
39+
from lms.djangoapps.grades.transformer import GradesTransformer
3040
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
3141
from xmodule.modulestore.tests.factories import CourseFactory
3242
from common.test.utils import assert_dict_contains_subset
@@ -113,6 +123,132 @@ def test_persistent_grade_event_emitted(self):
113123
)
114124

115125

126+
class PersistentSubsectionGradeEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin):
127+
"""
128+
Tests for the Open edX Events associated with the persistent subsection grade process.
129+
130+
This class guarantees that the following events are sent during the user updates their grade, with
131+
the exact Data Attributes as the event definition stated:
132+
133+
- PERSISTENT_SUBSECTION_GRADE_CHANGED: sent after the user updates or creates the grade.
134+
"""
135+
ENABLED_OPENEDX_EVENTS = [
136+
"org.openedx.learning.course.persistent_subsection_grade.changed.v1",
137+
]
138+
139+
@classmethod
140+
def setUpClass(cls):
141+
"""
142+
Set up class method for the Test class.
143+
144+
This method starts manually events isolation. Explanation here:
145+
openedx/core/djangoapps/user_authn/views/tests/test_events.py#L44
146+
"""
147+
super().setUpClass()
148+
cls.start_events_isolation()
149+
150+
def setUp(self): # pylint: disable=arguments-differ
151+
super().setUp()
152+
self.course = CourseFactory.create()
153+
self.user = UserFactory.create()
154+
self.subsection_usage_key = BlockUsageLocator(
155+
course_key=self.course.id,
156+
block_type='sequential',
157+
block_id='subsection_12345',
158+
)
159+
self.problem_locator_a = BlockUsageLocator(
160+
course_key=self.course.id,
161+
block_type='problem',
162+
block_id='problem_abc',
163+
)
164+
self.problem_locator_b = BlockUsageLocator(
165+
course_key=self.course.id,
166+
block_type='problem',
167+
block_id='problem_def',
168+
)
169+
self.record_a = BlockRecord(locator=self.problem_locator_a, weight=1, raw_possible=10, graded=False)
170+
self.record_b = BlockRecord(locator=self.problem_locator_b, weight=1, raw_possible=10, graded=True)
171+
self.block_records = BlockRecordList([self.record_a, self.record_b], self.course.id)
172+
self.params = {
173+
"user_id": self.user.id,
174+
"usage_key": self.subsection_usage_key,
175+
"course_version": self.course.number,
176+
"subtree_edited_timestamp": now(),
177+
"earned_all": 6.0,
178+
"possible_all": 12.0,
179+
"earned_graded": 6.0,
180+
"possible_graded": 8.0,
181+
"visible_blocks": self.block_records,
182+
"first_attempted": now(),
183+
}
184+
self.receiver_called = False
185+
186+
def _event_receiver_side_effect(self, **kwargs): # pylint: disable=unused-argument
187+
"""
188+
Used show that the Open edX Event was called by the Django signal handler.
189+
"""
190+
self.receiver_called = True
191+
192+
def test_persistent_subsection_grade_event_emitted(self):
193+
"""
194+
Test whether the persistent subsection grade updated event is sent after the user updates creates or
195+
updates their grade.
196+
197+
Expected result:
198+
- PERSISTENT_SUBSECTION_GRADE_CHANGED is sent and received by the mocked receiver.
199+
- The arguments that the receiver gets are the arguments sent by the event
200+
except the metadata generated on the fly.
201+
"""
202+
event_receiver = mock.Mock(side_effect=self._event_receiver_side_effect)
203+
204+
PERSISTENT_SUBSECTION_GRADE_CHANGED.connect(event_receiver)
205+
grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
206+
self.assertTrue(self.receiver_called)
207+
208+
grading_policy_hash = GradesTransformer.grading_policy_hash(self.course)
209+
visible_blocks = [
210+
XBlockWithScoringData(
211+
usage_key=self.record_a.locator,
212+
block_type=self.record_a.locator.block_type,
213+
graded=self.record_a.graded,
214+
raw_possible=self.record_a.raw_possible,
215+
weight=self.record_a.weight,
216+
),
217+
XBlockWithScoringData(
218+
usage_key=self.record_b.locator,
219+
block_type=self.record_b.locator.block_type,
220+
graded=self.record_b.graded,
221+
raw_possible=self.record_b.raw_possible,
222+
weight=self.record_b.weight,
223+
),
224+
]
225+
226+
assert_dict_contains_subset(
227+
self,
228+
{
229+
"signal": PERSISTENT_SUBSECTION_GRADE_CHANGED,
230+
"sender": None,
231+
"grade": PersistentSubsectionGradeData(
232+
user_id=self.params["user_id"],
233+
course=CourseData(
234+
course_key=self.course.id,
235+
),
236+
subsection_edited_timestamp=self.params["subtree_edited_timestamp"],
237+
grading_policy_hash=grading_policy_hash,
238+
usage_key=self.subsection_usage_key,
239+
weighted_graded_earned=self.params["earned_graded"],
240+
weighted_graded_possible=self.params["possible_graded"],
241+
weighted_total_earned=self.params["earned_all"],
242+
weighted_total_possible=self.params["possible_all"],
243+
first_attempted=self.params["first_attempted"],
244+
visible_blocks=visible_blocks,
245+
visible_blocks_hash=str(grade.visible_blocks_id),
246+
)
247+
},
248+
event_receiver.call_args.kwargs,
249+
)
250+
251+
116252
class CoursePassingStatusEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin):
117253
"""
118254
Tests for Open edX passing status update event.

openedx/envs/common.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2701,6 +2701,12 @@ def should_send_learning_badge_events(settings):
27012701
"enabled": Derived(should_send_learning_badge_events),
27022702
},
27032703
},
2704+
"org.openedx.learning.course.persistent_subsection_grade.changed.v1": {
2705+
"learning-subsection-grade": {
2706+
"event_key_field": "grade.course.course_key",
2707+
"enabled": True,
2708+
},
2709+
},
27042710
}
27052711

27062712
### event tracking

0 commit comments

Comments
 (0)