diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 09e9aa7cecf0..59a9551b772b 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -629,78 +629,141 @@ def get_course_assignments(course_key, user, include_access=False): # lint-amne subsection_key, title, url, due, contains_gated_content, complete, past_due, assignment_type, None, first_component_block_id )) + assignments.extend(get_ora_blocks_as_assignments(block_data, subsection_key)) + return assignments + + +def get_ora_blocks_as_assignments(block_data, subsection_key): + """ + Given a subsection key, navigate through descendents and find open response assessments. + For each graded ORA, return a list of "Assignment" tuples that map to the individual steps + of the ORA + """ + ora_assignments = [] + descendents = block_data.get_children(subsection_key) + while descendents: + descendent = descendents.pop() + descendents.extend(block_data.get_children(descendent)) + if block_data.get_xblock_field(descendent, 'category', None) == 'openassessment': + ora_assignments.extend(get_ora_as_assignments(block_data, descendent)) + return ora_assignments + + +def get_ora_as_assignments(block_data, ora_block): + """ + Given an individual ORA, return the list of individual ORA steps as Assignment tuples + """ + graded = block_data.get_xblock_field(ora_block, 'graded', False) + has_score = block_data.get_xblock_field(ora_block, 'has_score', False) + weight = block_data.get_xblock_field(ora_block, 'weight', 1) + + if not (graded and has_score and (weight is None or weight > 0)): + return [] - # Load all dates for ORA blocks as separate assignments - descendents = block_data.get_children(subsection_key) - while descendents: - descendent = descendents.pop() - descendents.extend(block_data.get_children(descendent)) - if block_data.get_xblock_field(descendent, 'category', None) == 'openassessment': - graded = block_data.get_xblock_field(descendent, 'graded', False) - has_score = block_data.get_xblock_field(descendent, 'has_score', False) - weight = block_data.get_xblock_field(descendent, 'weight', 1) - if not (graded and has_score and (weight is None or weight > 0)): - continue - - all_assessments = [{ - 'name': 'submission', - 'due': block_data.get_xblock_field(descendent, 'submission_due'), - 'start': block_data.get_xblock_field(descendent, 'submission_start'), - 'required': True - }] - - valid_assessments = block_data.get_xblock_field(descendent, 'valid_assessments') - if valid_assessments: - all_assessments.extend(valid_assessments) - - assignment_type = block_data.get_xblock_field(descendent, 'format', None) - complete = is_block_structure_complete_for_assignments(block_data, descendent) - - block_title = block_data.get_xblock_field(descendent, 'title', _('Open Response Assessment')) - - for assessment in all_assessments: - due = parse_date(assessment.get('due')).replace(tzinfo=pytz.UTC) if assessment.get('due') else None # lint-amnesty, pylint: disable=line-too-long - if due is None: - continue - - assessment_name = assessment.get('name') - if assessment_name is None: - continue - - if assessment_name == 'self-assessment': - assessment_type = _("Self Assessment") - elif assessment_name == 'peer-assessment': - assessment_type = _("Peer Assessment") - elif assessment_name == 'staff-assessment': - assessment_type = _("Staff Assessment") - elif assessment_name == 'submission': - assessment_type = _("Submission") - else: - assessment_type = assessment_name - title = f"{block_title} ({assessment_type})" - url = '' - start = parse_date(assessment.get('start')).replace(tzinfo=pytz.UTC) if assessment.get('start') else None # lint-amnesty, pylint: disable=line-too-long - assignment_released = not start or start < now - if assignment_released: - url = reverse('jump_to', args=[course_key, descendent]) - - past_due = not complete and due and due < now - first_component_block_id = str(descendent) - assignments.append(_Assignment( - descendent, - title, - url, - due, - False, - complete, - past_due, - assignment_type, - _("Open Response Assessment due dates are set by your instructor and can't be shifted."), - first_component_block_id, - )) + complete = is_block_structure_complete_for_assignments(block_data, ora_block) + + # Put all ora 'steps' (response, peer, self, etc) into a single list in a common format + all_assessments = [{ + 'name': 'submission', + 'due': block_data.get_xblock_field(ora_block, 'submission_due'), + 'start': block_data.get_xblock_field(ora_block, 'submission_start'), + 'required': True + }] + valid_assessments = block_data.get_xblock_field(ora_block, 'valid_assessments') + if valid_assessments: + all_assessments.extend(valid_assessments) + + # Loop through all steps and construct Assignment tuples from them + assignments = [] + for assessment in all_assessments: + assignment = _ora_assessment_to_assignment( + block_data, + ora_block, + complete, + assessment + ) + if assignment is not None: + assignments.append(assignment) return assignments +def _ora_assessment_to_assignment( + block_data, + ora_block, + complete, + assessment +): + """ + Create an assignment from an ORA assessment dict + """ + date_config_type = block_data.get_xblock_field(ora_block, 'date_config_type', 'manual') + assignment_type = block_data.get_xblock_field(ora_block, 'format', None) + block_title = block_data.get_xblock_field(ora_block, 'title', _('Open Response Assessment')) + course_key = block_data.root_block_usage_key + + # Steps with no "due" date, like staff or training, should not show up here + assessment_step_due = assessment.get('start') + if assessment_step_due is None: + return None + + if date_config_type == 'subsection': + assessment_start = block_data.get_xblock_field(ora_block, 'start') + assessment_due = block_data.get_xblock_field(ora_block, 'due') + extra_info = None + elif date_config_type == 'course_end': + assessment_start = None + assessment_due = block_data.get_xblock_field(course_key, 'end') + extra_info = None + else: + assessment_start, assessment_due = None, None + if assessment.get('start'): + assessment_start = parse_date(assessment.get('start')).replace(tzinfo=pytz.UTC) + if assessment.get('due'): + assessment_due = parse_date(assessment.get('due')).replace(tzinfo=pytz.UTC) + extra_info = _( + "This Open Response Assessment's due dates are set by your instructor and can't be shifted." + ) + + if assessment_due is None: + return None + + assessment_name = assessment.get('name') + if assessment_name is None: + return None + + if assessment_name == 'self-assessment': + assessment_type = _("Self Assessment") + elif assessment_name == 'peer-assessment': + assessment_type = _("Peer Assessment") + elif assessment_name == 'staff-assessment': + assessment_type = _("Staff Assessment") + elif assessment_name == 'submission': + assessment_type = _("Submission") + else: + assessment_type = assessment_name + title = f"{block_title} ({assessment_type})" + url = '' + now = datetime.now(pytz.UTC) + assignment_released = not assessment_start or assessment_start < now + if assignment_released: + url = reverse('jump_to', args=[course_key, ora_block]) + + past_due = not complete and assessment_due and assessment_due < now + first_component_block_id = str(ora_block) + return _Assignment( + ora_block, + title, + url, + assessment_due, + False, + complete, + past_due, + assignment_type, + extra_info, + first_component_block_id, + ) + + def get_first_component_of_block(block_key, block_data): """ This function returns the first leaf block of a section(block_key) diff --git a/lms/djangoapps/courseware/tests/test_courses.py b/lms/djangoapps/courseware/tests/test_courses.py index 166a8ccbdbe1..804f9037791f 100644 --- a/lms/djangoapps/courseware/tests/test_courses.py +++ b/lms/djangoapps/courseware/tests/test_courses.py @@ -17,6 +17,7 @@ from django.test.client import RequestFactory from django.test.utils import override_settings from django.urls import reverse +from freezegun import freeze_time from opaque_keys.edx.keys import CourseKey from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import _get_modulestore_branch_setting, modulestore @@ -480,3 +481,221 @@ def test_completion_does_not_treat_unreleased_as_complete(self): assignments = get_course_assignments(course.location.context_key, self.user, None) assert len(assignments) == 1 assert not assignments[0].complete + + +@ddt.ddt +class TestGetCourseAssignmentsORA(CompletionWaffleTestMixin, ModuleStoreTestCase): + """ Tests for ora-related behavior in get_course_assignments """ + TODAY = datetime.datetime(2023, 8, 2, 12, 23, 45, tzinfo=pytz.UTC) + + def setUp(self): + super().setUp() + self.freezer = freeze_time(self.TODAY) + self.freezer.start() + self.addCleanup(self.freezer.stop) + + def _date(self, t): + """ Helper to easily generate sequential days """ + return datetime.timedelta(days=t) + self.TODAY + + # pylint: disable=attribute-defined-outside-init + def _setup_course( + self, + course_dates=None, + subsection_dates=None, + ora_dates=None, + date_config_type="manual", + additional_rubric_assessments=None + ): + """ + Setup a course with one section, subsection, unit, and ORA + + With no arguments, the timeline of due dates is: + T | Date + -------- + -1 | Course Starts + 0 | Current frozen time + 1 | Subsection, submission, and self-assessment open + 2 | submission is due + 4 | self-assessment is due and peer assessment opens + 5 | peer assessment is due + 6 | subsection is due + 10 | course ends + """ + course_dates = course_dates or (self._date(-1), self._date(10)) + subsection_dates = subsection_dates or (self._date(1), self._date(6)) + ora_dates = ora_dates or { + 'response': (self._date(1), self._date(2)), + 'self': (self._date(1), self._date(4)), + 'peer': (self._date(4), self._date(5)) + } + + self.course = CourseFactory(start=course_dates[0], end=course_dates[1]) + self.section = BlockFactory(parent=self.course, category='chapter') + self.subsection = BlockFactory( + parent=self.section, + category='sequential', + graded=True, + start=subsection_dates[0], + due=subsection_dates[1], + ) + vertical = BlockFactory(parent=self.subsection, category='vertical') + + rubric_assessments = [ + { + 'name': 'peer-assessment', + 'must_be_graded_by': 3, + 'must_grade': 5, + 'start': ora_dates['peer'][0].isoformat(), + 'due': ora_dates['peer'][1].isoformat(), + }, + { + 'name': 'self-assessment', + 'start': ora_dates['self'][0].isoformat(), + 'due': ora_dates['self'][1].isoformat(), + } + ] + if additional_rubric_assessments: + rubric_assessments.extend(additional_rubric_assessments) + + self.openassessment = BlockFactory( + parent=vertical, + category='openassessment', + rubric_assessments=rubric_assessments, + submission_start=ora_dates['response'][0].isoformat(), + submission_due=ora_dates['response'][1].isoformat(), + date_config_type=date_config_type + ) + + self.course_end = course_dates[1] + self.subsection_due = subsection_dates[1] + self.submission_due = ora_dates['response'][1] + self.peer_due = ora_dates['peer'][1] + self.self_due = ora_dates['self'][1] + + def assert_ora_course_assignments( + self, + assignments, + expected_date_submission, + expected_date_peer, + expected_date_self + ): + """ + Helper to assert that + - there are four date blocks + - The first one is for the subsection and the next three are the ora steps + - the steps have the expected due dates + """ + assert len(assignments) == 4 + + assert assignments[0].block_key == self.subsection.location + assert assignments[1].block_key == self.openassessment.location + assert assignments[2].block_key == self.openassessment.location + assert assignments[3].block_key == self.openassessment.location + + assert 'Submission' in assignments[1].title + assert 'Peer' in assignments[2].title + assert 'Self' in assignments[3].title + + assert assignments[1].date == expected_date_submission + assert assignments[2].date == expected_date_peer + assert assignments[3].date == expected_date_self + + def test_ora_date_config__manual(self): + """ + When manual config is set, the dates for ora setps should be the step + due dates + """ + self._setup_course() + self.assert_ora_course_assignments( + get_course_assignments(self.course.location.context_key, self.user, None), + self.submission_due, + self.peer_due, + self.self_due + ) + + def test_ora_date_config__subsection(self): + """ + When subsection config is set, the dates for ora steps should all be the subsection due date + """ + self._setup_course(date_config_type='subsection') + self.assert_ora_course_assignments( + get_course_assignments(self.course.location.context_key, self.user, None), + self.subsection_due, + self.subsection_due, + self.subsection_due, + ) + + def test_ora_date_config__course_end(self): + """ + When manual config is set, the dates for ora steps should all be the course end date + """ + self._setup_course(date_config_type='course_end') + self.assert_ora_course_assignments( + get_course_assignments(self.course.location.context_key, self.user, None), + self.course_end, + self.course_end, + self.course_end, + ) + + def test_course_end_none(self): + """ + If the course has no end date defined and if the ora date config + is set to course end, don't include due dates for the ORA assignment in the due dates + """ + self._setup_course( + course_dates=(self._date(-1), None), + date_config_type='course_end' + ) + assignments = get_course_assignments(self.course.location.context_key, self.user, None) + assert len(assignments) == 1 + assert assignments[0].block_key == self.subsection.location + + def test_subsection_none(self): + """ + If the subsection has no due date defined and if the ora date config + is set to subsection, don't include due dates for the ORA assignment in the due dates + """ + self._setup_course( + subsection_dates=(self._date(1), None), + date_config_type='subsection' + ) + # Add another subsection with a due date, because the first subsection won't show up + # without one + subsection_2 = BlockFactory( + parent=self.section, + category='sequential', + graded=True, + start=self._date(2), + due=self._date(3), + ) + assignments = get_course_assignments(self.course.location.context_key, self.user, None) + assert len(assignments) == 1 + assert assignments[0].block_key == subsection_2.location + + @ddt.data('manual', 'subsection', 'course_end') + def test_ora_steps_with_no_due_date(self, config_type): + additional_assessments = [ + { + 'name': 'assessment_that_is_never_due', + 'some_setting': 'whatever', + 'another_setting': 'meh', + }, + { + 'name': 'another_ssessment_that_is_never_due', + 'favorite_fruit': 'pear', + 'favorite_color': 'green', + } + ] + self._setup_course( + additional_rubric_assessments=additional_assessments, + date_config_type=config_type, + ) + + # There are no dates for these other steps + assignments = get_course_assignments(self.course.location.context_key, self.user, None) + assert len(assignments) == 4 + assert assignments[0].block_key == self.subsection.location + assert 'Submission' in assignments[1].title + assert 'Peer' in assignments[2].title + assert 'Self' in assignments[3].title diff --git a/lms/djangoapps/courseware/transformers.py b/lms/djangoapps/courseware/transformers.py index 31248c7d23a5..2577a9c42331 100644 --- a/lms/djangoapps/courseware/transformers.py +++ b/lms/djangoapps/courseware/transformers.py @@ -12,7 +12,7 @@ class OpenAssessmentDateTransformer(FilteringTransformerMixin, BlockStructureTra """ BlockTransformer to collect all fields related to dates for openassessment problems. """ - WRITE_VERSION = 1 + WRITE_VERSION = 2 READ_VERSION = 1 @classmethod @@ -37,6 +37,7 @@ def collect(cls, block_structure): 'graded', 'format', 'has_score', + 'date_config_type', ) def transform_block_filters(self, usage_info, block_structure): diff --git a/requirements/constraints.txt b/requirements/constraints.txt index e52b297ecd4d..eb95e550532a 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -132,4 +132,4 @@ click==8.1.6 redis==4.6.0 # holding off ora date config feature -ora2==5.2.4 +ora2==5.3.0 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 0c1ba85d50a1..be5fc5e4058e 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -783,7 +783,7 @@ openedx-mongodbproxy==0.2.0 # via -r requirements/edx/kernel.in optimizely-sdk==4.1.1 # via -r requirements/edx/bundled.in -ora2==5.2.4 +ora2==5.3.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/bundled.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index e9a1794f3ed3..cad94cd57781 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1323,7 +1323,7 @@ optimizely-sdk==4.1.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -ora2==5.2.4 +ora2==5.3.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 7d6d0d5f4766..52aa57d5370f 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -924,7 +924,7 @@ openedx-mongodbproxy==0.2.0 # via -r requirements/edx/base.txt optimizely-sdk==4.1.1 # via -r requirements/edx/base.txt -ora2==5.2.4 +ora2==5.3.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index dbf5092a9dbe..642e20b75282 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -994,7 +994,7 @@ openedx-mongodbproxy==0.2.0 # via -r requirements/edx/base.txt optimizely-sdk==4.1.1 # via -r requirements/edx/base.txt -ora2==5.2.4 +ora2==5.3.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt