From ffc7a965f9aedd87b67db150b8d02ffe2b05a5af Mon Sep 17 00:00:00 2001 From: Muhammad Anas Date: Mon, 29 Sep 2025 23:13:39 +0500 Subject: [PATCH 1/9] feat: add never_but_include_grade visibility option --- cms/templates/js/show-correctness-editor.underscore | 7 +++++++ lms/djangoapps/course_home_api/progress/serializers.py | 1 + lms/djangoapps/course_home_api/progress/views.py | 1 + lms/djangoapps/grades/subsection_grade.py | 8 +++++++- lms/templates/courseware/progress.html | 8 ++++---- xmodule/graders.py | 3 ++- 6 files changed, 22 insertions(+), 6 deletions(-) diff --git a/cms/templates/js/show-correctness-editor.underscore b/cms/templates/js/show-correctness-editor.underscore index 3db6c3c27a5c..1b0dd896747a 100644 --- a/cms/templates/js/show-correctness-editor.underscore +++ b/cms/templates/js/show-correctness-editor.underscore @@ -35,6 +35,13 @@ <% } %> <%- gettext('If the subsection does not have a due date, learners always see their scores when they submit answers to assessments.') %>

+ +

+ <%- gettext('Learners do not see question-level correctness or scores before or after the due date. However, once the due date passes, they can see their overall score for the subsection on the Progress page.') %> +

diff --git a/lms/djangoapps/course_home_api/progress/serializers.py b/lms/djangoapps/course_home_api/progress/serializers.py index 6bdc204434af..e6d62124469f 100644 --- a/lms/djangoapps/course_home_api/progress/serializers.py +++ b/lms/djangoapps/course_home_api/progress/serializers.py @@ -26,6 +26,7 @@ class SubsectionScoresSerializer(ReadOnlySerializer): assignment_type = serializers.CharField(source='format') block_key = serializers.SerializerMethodField() display_name = serializers.CharField() + due = serializers.DateTimeField(allow_null=True) has_graded_assignment = serializers.BooleanField(source='graded') override = serializers.SerializerMethodField() learner_has_access = serializers.SerializerMethodField() diff --git a/lms/djangoapps/course_home_api/progress/views.py b/lms/djangoapps/course_home_api/progress/views.py index 3783c19061dc..ada926af1a3a 100644 --- a/lms/djangoapps/course_home_api/progress/views.py +++ b/lms/djangoapps/course_home_api/progress/views.py @@ -99,6 +99,7 @@ class ProgressTabView(RetrieveAPIView): assignment_type: (str) the format, if any, of the Subsection (Homework, Exam, etc) block_key: (str) the key of the given subsection block display_name: (str) a str of what the name of the Subsection is for displaying on the site + due: (str or None) the due date of the subsection in ISO 8601 format, or None if no due date is set has_graded_assignment: (bool) whether or not the Subsection is a graded assignment learner_has_access: (bool) whether the learner has access to the subsection (could be FBE gated) num_points_earned: (int) the amount of points the user has earned for the given subsection diff --git a/lms/djangoapps/grades/subsection_grade.py b/lms/djangoapps/grades/subsection_grade.py index 4ce0a1f3a463..a825760cea71 100644 --- a/lms/djangoapps/grades/subsection_grade.py +++ b/lms/djangoapps/grades/subsection_grade.py @@ -5,8 +5,9 @@ from abc import ABCMeta from collections import OrderedDict +from datetime import datetime from logging import getLogger - +from pytz import UTC from lazy import lazy from lms.djangoapps.grades.models import BlockRecord, PersistentSubsectionGrade @@ -59,6 +60,11 @@ def show_grades(self, has_staff_access): """ Returns whether subsection scores are currently available to users with or without staff access. """ + if self.show_correctness == ShowCorrectness.NEVER_BUT_INCLUDE_GRADE: + # For NEVER_BUT_INCLUDE_GRADE, show_grades returns True if the due date has passed, + # but correctness_available returns False. + return (self.due is None or + self.due < datetime.now(UTC)) return ShowCorrectness.correctness_available(self.show_correctness, self.due, has_staff_access) @property diff --git a/lms/templates/courseware/progress.html b/lms/templates/courseware/progress.html index 711fad895427..ee404f08c4d2 100644 --- a/lms/templates/courseware/progress.html +++ b/lms/templates/courseware/progress.html @@ -180,7 +180,7 @@

${ chapter['display_name']}

%if hide_url:

${section.display_name} - %if (total > 0 or earned > 0) and section.show_grades(staff_access): + %if (total > 0 or earned > 0) and section.show_grades(staff_access) and not section.show_correctness == 'never_but_include_grade': ${_("{earned} of {total} possible points").format(earned='{:.3n}'.format(float(earned)), total='{:.3n}'.format(float(total)))} @@ -189,14 +189,14 @@

%else: ${ section.display_name} - %if (total > 0 or earned > 0) and section.show_grades(staff_access): + %if (total > 0 or earned > 0) and section.show_grades(staff_access) and not section.show_correctness == 'never_but_include_grade': ${_("{earned} of {total} possible points").format(earned='{:.3n}'.format(float(earned)), total='{:.3n}'.format(float(total)))} %endif %endif - %if (total > 0 or earned > 0) and section.show_grades(staff_access): + %if (total > 0 or earned > 0) and section.show_grades(staff_access) and not section.show_correctness == 'never_but_include_grade': ${"({0:.3n}/{1:.3n}) {2}".format( float(earned), float(total), percentageString )} %endif

@@ -219,7 +219,7 @@

%endif

%if len(section.problem_scores.values()) > 0: - %if section.show_grades(staff_access): + %if section.show_grades(staff_access) and not section.show_correctness == 'never_but_include_grade':
${ _("Problem Scores: ") if section.graded else _("Practice Scores: ")}
%for score in section.problem_scores.values(): diff --git a/xmodule/graders.py b/xmodule/graders.py index 001113882438..4e825a7f4f10 100644 --- a/xmodule/graders.py +++ b/xmodule/graders.py @@ -485,13 +485,14 @@ class ShowCorrectness: ALWAYS = "always" PAST_DUE = "past_due" NEVER = "never" + NEVER_BUT_INCLUDE_GRADE = "never_but_include_grade" @classmethod def correctness_available(cls, show_correctness='', due_date=None, has_staff_access=False): """ Returns whether correctness is available now, for the given attributes. """ - if show_correctness == cls.NEVER: + if show_correctness == cls.NEVER or show_correctness == cls.NEVER_BUT_INCLUDE_GRADE: return False elif has_staff_access: # This is after the 'never' check because course staff can see correctness From 5ae9c5a9d2fc6ce767bcfe4693c4ebcedd89b02a Mon Sep 17 00:00:00 2001 From: Muhammad Anas Date: Tue, 30 Sep 2025 11:40:14 +0500 Subject: [PATCH 2/9] fix: lint issues --- xmodule/graders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xmodule/graders.py b/xmodule/graders.py index 4e825a7f4f10..34f7e61654b4 100644 --- a/xmodule/graders.py +++ b/xmodule/graders.py @@ -492,7 +492,7 @@ def correctness_available(cls, show_correctness='', due_date=None, has_staff_acc """ Returns whether correctness is available now, for the given attributes. """ - if show_correctness == cls.NEVER or show_correctness == cls.NEVER_BUT_INCLUDE_GRADE: + if show_correctness in (cls.NEVER, cls.NEVER_BUT_INCLUDE_GRADE): return False elif has_staff_access: # This is after the 'never' check because course staff can see correctness From 795b17ed55c761c4c4f9f983aff73758ea3b75fd Mon Sep 17 00:00:00 2001 From: Muhammad Anas Date: Wed, 1 Oct 2025 14:39:19 +0500 Subject: [PATCH 3/9] test: add tests --- .../progress/tests/test_views.py | 10 ++++--- lms/djangoapps/courseware/tests/test_views.py | 27 ++++++++++++++++++- xmodule/tests/test_graders.py | 8 ++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/lms/djangoapps/course_home_api/progress/tests/test_views.py b/lms/djangoapps/course_home_api/progress/tests/test_views.py index d13ebec29c21..8012e11675f1 100644 --- a/lms/djangoapps/course_home_api/progress/tests/test_views.py +++ b/lms/djangoapps/course_home_api/progress/tests/test_views.py @@ -282,8 +282,8 @@ def test_url_hidden_if_subsection_hide_after_due(self): assert hide_after_due_subsection['url'] is None @ddt.data( - (True, 0.7), # midterm and final are visible to staff - (False, 0.3), # just the midterm is visible to learners + (True, 0.72), # lab, midterm and final are visible to staff + (False, 0.32), # Only lab and midterm is visible to learners ) @ddt.unpack def test_course_grade_considers_subsection_grade_visibility(self, is_staff, expected_percent): @@ -301,14 +301,18 @@ def test_course_grade_considers_subsection_grade_visibility(self, is_staff, expe never = self.add_subsection_with_problem(format='Homework', show_correctness='never') always = self.add_subsection_with_problem(format='Midterm Exam', show_correctness='always') past_due = self.add_subsection_with_problem(format='Final Exam', show_correctness='past_due', due=tomorrow) + never_but_show_grade = self.add_subsection_with_problem( + format='Lab', show_correctness='never_but_include_grade' + ) answer_problem(self.course, get_mock_request(self.user), never) answer_problem(self.course, get_mock_request(self.user), always) answer_problem(self.course, get_mock_request(self.user), past_due) + answer_problem(self.course, get_mock_request(self.user), never_but_show_grade) # First, confirm the grade in the database - it should never change based on user state. # This is midterm and final and a single problem added together. - assert CourseGradeFactory().read(self.user, self.course).percent == 0.72 + assert CourseGradeFactory().read(self.user, self.course).percent == 0.73 response = self.client.get(self.url) assert response.status_code == 200 diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 4e3d1be9bddc..2c3ece3133a5 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -1781,6 +1781,14 @@ def assert_progress_page_show_grades(self, response, show_correctness, due_date, (ShowCorrectness.PAST_DUE, TODAY, True), (ShowCorrectness.PAST_DUE, TOMORROW, False), (ShowCorrectness.PAST_DUE, TOMORROW, True), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, None, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, None, True), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, YESTERDAY, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, YESTERDAY, True), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TODAY, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TODAY, True), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TOMORROW, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TOMORROW, True), ) @ddt.unpack def test_progress_page_no_problem_scores(self, show_correctness, due_date_name, graded): @@ -1821,6 +1829,14 @@ def test_progress_page_no_problem_scores(self, show_correctness, due_date_name, (ShowCorrectness.PAST_DUE, TODAY, True, True), (ShowCorrectness.PAST_DUE, TOMORROW, False, False), (ShowCorrectness.PAST_DUE, TOMORROW, True, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, None, False, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, None, True, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, YESTERDAY, False, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, YESTERDAY, True, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TODAY, False, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TODAY, True, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TOMORROW, False, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TOMORROW, True, False), ) @ddt.unpack def test_progress_page_hide_scores_from_learner(self, show_correctness, due_date_name, graded, show_grades): @@ -1873,11 +1889,20 @@ def test_progress_page_hide_scores_from_learner(self, show_correctness, due_date (ShowCorrectness.PAST_DUE, TODAY, True, True), (ShowCorrectness.PAST_DUE, TOMORROW, False, True), (ShowCorrectness.PAST_DUE, TOMORROW, True, True), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, None, False, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, None, True, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, YESTERDAY, False, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, YESTERDAY, True, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TODAY, False, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TODAY, True, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TOMORROW, False, False), + (ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, TOMORROW, True, False), ) @ddt.unpack def test_progress_page_hide_scores_from_staff(self, show_correctness, due_date_name, graded, show_grades): """ - Test that problem scores are hidden from staff viewing a learner's progress page only if show_correctness=never. + Test that problem scores are hidden from staff viewing a learner's progress page only if show_correctness is + never or never_but_include_grade. """ due_date = self.DATES[due_date_name] self.setup_course(show_correctness=show_correctness, due_date=due_date, graded=graded) diff --git a/xmodule/tests/test_graders.py b/xmodule/tests/test_graders.py index 5e2444a05533..b80004c913a3 100644 --- a/xmodule/tests/test_graders.py +++ b/xmodule/tests/test_graders.py @@ -493,3 +493,11 @@ def test_show_correctness_past_due(self, due_date_str, has_staff_access, expecte due_date = getattr(self, due_date_str) assert ShowCorrectness.correctness_available(ShowCorrectness.PAST_DUE, due_date, has_staff_access) ==\ expected_result + + @ddt.data(True, False) + def test_show_correctness_never_but_include_grade(self, has_staff_access): + """ + Test that show_correctness="never_but_include_grade" hides correctness from learners and course staff. + """ + assert not ShowCorrectness.correctness_available(show_correctness=ShowCorrectness.NEVER_BUT_INCLUDE_GRADE, + has_staff_access=has_staff_access) From 12ddf93bf5cb9bf9a2b77698cc97cbd928b6a503 Mon Sep 17 00:00:00 2001 From: Muhammad Anas Date: Wed, 1 Oct 2025 16:44:31 +0500 Subject: [PATCH 4/9] temp: empty commit to run the shell check again From 330dacca294b7c7f6462e67eeb0c0982a0d3a530 Mon Sep 17 00:00:00 2001 From: Muhammad Anas Date: Wed, 15 Oct 2025 17:49:31 +0500 Subject: [PATCH 5/9] feat: added progress page data calculation logic --- .../course_home_api/progress/api.py | 193 +++++++++++++++++- .../course_home_api/progress/serializers.py | 15 ++ .../progress/tests/test_api.py | 109 +++++++++- .../course_home_api/progress/views.py | 28 ++- lms/djangoapps/grades/subsection_grade.py | 5 +- lms/templates/courseware/progress.html | 9 +- 6 files changed, 349 insertions(+), 10 deletions(-) diff --git a/lms/djangoapps/course_home_api/progress/api.py b/lms/djangoapps/course_home_api/progress/api.py index b2a8634c59f4..e830c98f0a67 100644 --- a/lms/djangoapps/course_home_api/progress/api.py +++ b/lms/djangoapps/course_home_api/progress/api.py @@ -4,11 +4,202 @@ from django.contrib.auth import get_user_model from opaque_keys.edx.keys import CourseKey +from typing import Any +from xmodule.graders import ShowCorrectness +from datetime import datetime, timezone from lms.djangoapps.courseware.courses import get_course_blocks_completion_summary +from dataclasses import dataclass, field +from typing import Optional, List, Dict, Tuple -User = get_user_model() +@dataclass +class _AssignmentBucket: + """Holds scores and visibility info for one assignment type.""" + assignment_type: str + expected_total: int + last_grade_publish_date: datetime + scores: List[float] = field(default_factory=list) + visibilities: List[Optional[bool]] = field(default_factory=list) + included: List[Optional[bool]] = field(default_factory=list) + assignments_created: int = 0 + + @classmethod + def with_placeholders(cls, assignment_type: str, expected_total: int, now: datetime): + """Create a bucket prefilled with placeholder (empty) entries.""" + return cls( + assignment_type=assignment_type, + expected_total=expected_total, + last_grade_publish_date=now, + scores=[0] * expected_total, + visibilities=[None] * expected_total, + included=[None] * expected_total, + ) + + def add_subsection(self, score: float, is_visible: bool, is_included: bool): + """Add a subsection’s score and visibility, replacing a placeholder if space remains.""" + if self.assignments_created < self.expected_total: + if self.scores: + self.scores.pop(0) + if self.visibilities: + self.visibilities.pop(0) + if self.included: + self.included.pop(0) + self.scores.append(score) + self.visibilities.append(is_visible) + self.included.append(is_included) + self.assignments_created += 1 + + def drop_lowest(self, num_droppable: int): + """Remove the lowest scoring subsections, up to the provided num_droppable.""" + while num_droppable > 0 and self.scores: + idx = self.scores.index(min(self.scores)) + self.scores.pop(idx) + self.visibilities.pop(idx) + self.included.pop(idx) + num_droppable -= 1 + + def hidden_state(self) -> str: + """Return whether kept scores are all, some, or none hidden.""" + if not self.visibilities: + return 'none' + all_hidden = all(v is False for v in self.visibilities) + some_hidden = any(v is False for v in self.visibilities) + if all_hidden: + return 'all' + if some_hidden: + return 'some' + return 'none' + + def averages(self) -> Tuple[float, float]: + """Compute visible and included averages over kept scores. + + Visible average uses only grades with visibility flag True in numerator; denominator is total + number of kept scores (mirrors legacy behavior). Included average uses only scores that are + marked included (show_grades True) in numerator with same denominator. + + Returns: + (earned_visible, earned_all) tuple of floats (0-1 each). + """ + if not self.scores: + return 0.0, 0.0 + visible_scores = [s for i, s in enumerate(self.scores) if self.visibilities[i]] + included_scores = [s for i, s in enumerate(self.scores) if self.included[i]] + earned_visible = (sum(visible_scores) / len(self.scores)) if self.scores else 0.0 + earned_all = (sum(included_scores) / len(self.scores)) if self.scores else 0.0 + return earned_visible, earned_all + +class _AssignmentTypeGradeAggregator: + """Collects and aggregates subsection grades by assignment type.""" + + def __init__(self, course_grade, grading_policy: dict, has_staff_access: bool): + """Initialize with course grades, grading policy, and staff access flag.""" + self.course_grade = course_grade + self.grading_policy = grading_policy + self.has_staff_access = has_staff_access + self.now = datetime.now(timezone.utc) + self.policy_map = self._build_policy_map() + self.buckets: Dict[str, _AssignmentBucket] = {} + + def _build_policy_map(self) -> dict: + """Convert grading policy into a lookup of assignment type → policy info.""" + policy_map = {} + try: + for policy in self.grading_policy.get('GRADER', []): + policy_map[policy.get('type')] = { + 'weight': policy.get('weight', 0.0), + 'short_label': policy.get('short_label', ''), + 'num_droppable': policy.get('drop_count', 0), + 'num_total': policy.get('min_count', 0), + } + except Exception: + policy_map = {} + return policy_map + + def _bucket_for(self, assignment_type: str) -> _AssignmentBucket: + """Get or create a score bucket for the given assignment type.""" + bucket = self.buckets.get(assignment_type) + if bucket is None: + expected = self.policy_map.get(assignment_type, {}).get('num_total', 0) or 0 + bucket = _AssignmentBucket.with_placeholders(assignment_type, expected, self.now) + self.buckets[assignment_type] = bucket + return bucket + + def collect(self): + """Gather subsection grades into their respective assignment buckets.""" + for chapter in self.course_grade.chapter_grades.values(): + for subsection_grade in chapter.get('sections', []): + if not getattr(subsection_grade, 'graded', False): + continue + assignment_type = getattr(subsection_grade, 'format', '') or '' + if not assignment_type: + continue + graded_total = getattr(subsection_grade, 'graded_total', None) + earned = getattr(graded_total, 'earned', 0.0) if graded_total else 0.0 + possible = getattr(graded_total, 'possible', 0.0) if graded_total else 0.0 + earned = 0.0 if earned is None else earned + possible = 0.0 if possible is None else possible + score = (earned / possible) if possible else 0.0 + is_visible = ShowCorrectness.correctness_available( + subsection_grade.show_correctness, subsection_grade.due, self.has_staff_access + ) + is_included = subsection_grade.show_grades(self.has_staff_access) + bucket = self._bucket_for(assignment_type) + bucket.add_subsection(score, is_visible, is_included) + if subsection_grade.show_correctness in [ShowCorrectness.PAST_DUE, ShowCorrectness.NEVER_BUT_INCLUDE_GRADE]: + if subsection_grade.due and subsection_grade.due > bucket.last_grade_publish_date: + bucket.last_grade_publish_date = subsection_grade.due + + def build_results(self) -> dict: + """Apply drops, compute averages, and return aggregated results and total grade.""" + final_grades = 0.0 + rows = [] + for assignment_type, bucket in self.buckets.items(): + policy = self.policy_map.get(assignment_type, {}) + bucket.drop_lowest(policy.get('num_droppable', 0)) + earned_visible, earned_all = bucket.averages() + weight = policy.get('weight', 0.0) + short_label = policy.get('short_label', '') + row = { + 'type': assignment_type, + 'weight': weight, + 'average_grade': round(earned_visible, 4), + 'weighted_grade': round(earned_visible * weight, 4), + 'short_label': short_label, + 'num_droppable': policy.get('num_droppable', 0), + 'last_grade_publish_date': bucket.last_grade_publish_date, + 'has_hidden_contribution': bucket.hidden_state(), + } + final_grades += earned_all * weight + rows.append(row) + rows.sort(key=lambda r: r['weight']) + return {'results': rows, 'final_grades': round(final_grades, 4)} + + def run(self) -> dict: + """Execute full pipeline (collect + aggregate) returning final payload.""" + self.collect() + return self.build_results() + +def aggregate_assignment_type_grade_summary( + course_grade, + grading_policy: dict, + has_staff_access: bool = False, +) -> dict: + """ + Aggregate subsection grades by assignment type and return summary data. + Args: + course_grade: CourseGrade object containing chapter and subsection grades. + grading_policy: Dictionary representing the course's grading policy. + has_staff_access: Boolean indicating if the user has staff access to view all grades. + Returns: + Dictionary with keys: + results: list of per-assignment-type summary dicts + final_grades: overall weighted contribution (float, 4 decimal rounding) + """ + aggregator = _AssignmentTypeGradeAggregator(course_grade, grading_policy, has_staff_access) + return aggregator.run() + +User = get_user_model() def calculate_progress_for_learner_in_course(course_key: CourseKey, user: User) -> dict: """ diff --git a/lms/djangoapps/course_home_api/progress/serializers.py b/lms/djangoapps/course_home_api/progress/serializers.py index e6d62124469f..677f7f3f8556 100644 --- a/lms/djangoapps/course_home_api/progress/serializers.py +++ b/lms/djangoapps/course_home_api/progress/serializers.py @@ -128,6 +128,19 @@ class VerificationDataSerializer(ReadOnlySerializer): status_date = serializers.DateTimeField() +class AssignmentTypeScoresSerializer(ReadOnlySerializer): + """ + Serializer for aggregated scores per assignment type. + """ + type = serializers.CharField() + weight = serializers.FloatField() + average_grade = serializers.FloatField() + weighted_grade = serializers.FloatField() + last_grade_publish_date = serializers.DateTimeField() + has_hidden_contribution = serializers.CharField() + short_label = serializers.CharField() + num_droppable = serializers.IntegerField() + class ProgressTabSerializer(VerifiedModeSerializer): """ Serializer for progress tab @@ -147,3 +160,5 @@ class ProgressTabSerializer(VerifiedModeSerializer): user_has_passing_grade = serializers.BooleanField() verification_data = VerificationDataSerializer() disable_progress_graph = serializers.BooleanField() + assignment_type_grade_summary = AssignmentTypeScoresSerializer(many=True) + final_grades = serializers.FloatField() diff --git a/lms/djangoapps/course_home_api/progress/tests/test_api.py b/lms/djangoapps/course_home_api/progress/tests/test_api.py index 30d8d9059eaa..51e7dd68286e 100644 --- a/lms/djangoapps/course_home_api/progress/tests/test_api.py +++ b/lms/djangoapps/course_home_api/progress/tests/test_api.py @@ -6,7 +6,80 @@ from django.test import TestCase -from lms.djangoapps.course_home_api.progress.api import calculate_progress_for_learner_in_course +from lms.djangoapps.course_home_api.progress.api import ( + calculate_progress_for_learner_in_course, + aggregate_assignment_type_grade_summary, +) +from xmodule.graders import ShowCorrectness +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + + +def _make_subsection(fmt, earned, possible, show_corr, *, due_delta_days=None): + """Build a lightweight subsection object for testing aggregation scenarios.""" + graded_total = SimpleNamespace(earned=earned, possible=possible) + due = None + if due_delta_days is not None: + due = datetime.now(timezone.utc) + timedelta(days=due_delta_days) + return SimpleNamespace( + graded=True, + format=fmt, + graded_total=graded_total, + show_correctness=show_corr, + due=due, + show_grades=lambda staff: True, + ) + + +_AGGREGATION_SCENARIOS = [ + ( + 'all_visible_always', + {'type': 'Homework', 'weight': 1.0, 'drop_count': 0, 'min_count': 2, 'short_label': 'HW'}, + [ + _make_subsection('Homework', 1, 1, ShowCorrectness.ALWAYS), + _make_subsection('Homework', 0.5, 1, ShowCorrectness.ALWAYS), + ], + {'avg': 0.75, 'weighted': 0.75, 'hidden': 'none', 'final': 0.75}, + ), + ( + 'some_hidden_never_but_include', + {'type': 'Exam', 'weight': 1.0, 'drop_count': 0, 'min_count': 2, 'short_label': 'EX'}, + [ + _make_subsection('Exam', 1, 1, ShowCorrectness.ALWAYS), + _make_subsection('Exam', 0.5, 1, ShowCorrectness.NEVER_BUT_INCLUDE_GRADE), + ], + {'avg': 0.5, 'weighted': 0.5, 'hidden': 'some', 'final': 0.75}, + ), + ( + 'all_hidden_never_but_include', + {'type': 'Quiz', 'weight': 1.0, 'drop_count': 0, 'min_count': 2, 'short_label': 'QZ'}, + [ + _make_subsection('Quiz', 0.4, 1, ShowCorrectness.NEVER_BUT_INCLUDE_GRADE), + _make_subsection('Quiz', 0.6, 1, ShowCorrectness.NEVER_BUT_INCLUDE_GRADE), + ], + {'avg': 0.0, 'weighted': 0.0, 'hidden': 'all', 'final': 0.5}, + ), + ( + 'past_due_mixed_visibility', + {'type': 'Lab', 'weight': 1.0, 'drop_count': 0, 'min_count': 2, 'short_label': 'LB'}, + [ + _make_subsection('Lab', 0.8, 1, ShowCorrectness.PAST_DUE, due_delta_days=-1), + _make_subsection('Lab', 0.2, 1, ShowCorrectness.PAST_DUE, due_delta_days=+3), + ], + {'avg': 0.4, 'weighted': 0.4, 'hidden': 'some', 'final': 0.5}, + ), + ( + 'drop_lowest_keeps_high_scores', + {'type': 'Project', 'weight': 1.0, 'drop_count': 2, 'min_count': 4, 'short_label': 'PR'}, + [ + _make_subsection('Project', 1, 1, ShowCorrectness.ALWAYS), + _make_subsection('Project', 1, 1, ShowCorrectness.ALWAYS), + _make_subsection('Project', 0, 1, ShowCorrectness.ALWAYS), + _make_subsection('Project', 0, 1, ShowCorrectness.ALWAYS), + ], + {'avg': 1.0, 'weighted': 1.0, 'hidden': 'none', 'final': 1.0}, + ), +] class ProgressApiTests(TestCase): @@ -73,3 +146,37 @@ def test_calculate_progress_for_learner_in_course_summary_empty(self, mock_get_s results = calculate_progress_for_learner_in_course("some_course", "some_user") assert not results + + def test_aggregate_assignment_type_grade_summary_scenarios(self): + """ + A test to verify functionality of aggregate_assignment_type_grade_summary. + 1. Test visibility modes (always, never but include grade, past due) + 2. Test drop-lowest behavior + 3. Test weighting behavior + 4. Test final grade calculation + 5. Test average grade calculation + 6. Test weighted grade calculation + 7. Test has_hidden_contribution calculation + """ + + for case_name, policy, subsections, expected in _AGGREGATION_SCENARIOS: + with self.subTest(case_name=case_name): + course_grade = SimpleNamespace(chapter_grades={'chapter': {'sections': subsections}}) + grading_policy = {'GRADER': [policy]} + + result = aggregate_assignment_type_grade_summary( + course_grade, + grading_policy, + has_staff_access=False, + ) + + assert 'results' in result and 'final_grades' in result + assert result['final_grades'] == expected['final'] + assert len(result['results']) == 1 + + row = result['results'][0] + assert row['type'] == policy['type'], case_name + assert row['average_grade'] == expected['avg'] + assert row['weighted_grade'] == expected['weighted'] + assert row['has_hidden_contribution'] == expected['hidden'] + assert row['num_droppable'] == policy['drop_count'] diff --git a/lms/djangoapps/course_home_api/progress/views.py b/lms/djangoapps/course_home_api/progress/views.py index ada926af1a3a..78674a2e34b9 100644 --- a/lms/djangoapps/course_home_api/progress/views.py +++ b/lms/djangoapps/course_home_api/progress/views.py @@ -2,6 +2,7 @@ Progress Tab Views """ +import copy from django.contrib.auth import get_user_model from django.http.response import Http404 from edx_django_utils import monitoring as monitoring_utils @@ -13,8 +14,11 @@ from rest_framework.response import Response from xmodule.modulestore.django import modulestore +from xmodule.graders import ShowCorrectness from common.djangoapps.student.models import CourseEnrollment from lms.djangoapps.course_home_api.progress.serializers import ProgressTabSerializer +from lms.djangoapps.course_home_api.progress.api import aggregate_assignment_type_grade_summary + from lms.djangoapps.course_home_api.toggles import course_home_mfe_progress_tab_is_active from lms.djangoapps.courseware.access import has_access, has_ccx_coach_role from lms.djangoapps.course_blocks.api import get_course_blocks @@ -246,6 +250,26 @@ def get(self, request, *args, **kwargs): access_expiration = get_access_expiration_data(request.user, course_overview) + # Aggregations delegated to helper functions for reuse and testability + assignment_type_grade_summary = aggregate_assignment_type_grade_summary( + course_grade, + grading_policy, + has_staff_access=is_staff, + ) + + # Filter out section scores to only have those that are visible to the user + section_scores = [ + {**chapter, "sections": list(chapter["sections"])} + for chapter in course_grade.chapter_grades.values() + ] + for chapter in section_scores: + filtered_sections = [ + subsection + for subsection in chapter["sections"] + if getattr(subsection, "show_correctness", None) != ShowCorrectness.NEVER_BUT_INCLUDE_GRADE + ] + chapter["sections"] = filtered_sections + data = { 'access_expiration': access_expiration, 'certificate_data': get_cert_data(student, course, enrollment_mode, course_grade), @@ -256,12 +280,14 @@ def get(self, request, *args, **kwargs): 'enrollment_mode': enrollment_mode, 'grading_policy': grading_policy, 'has_scheduled_content': has_scheduled_content, - 'section_scores': list(course_grade.chapter_grades.values()), + 'section_scores': section_scores, 'studio_url': get_studio_url(course, 'settings/grading'), 'username': username, 'user_has_passing_grade': user_has_passing_grade, 'verification_data': verification_data, 'disable_progress_graph': disable_progress_graph, + 'assignment_type_grade_summary': assignment_type_grade_summary["results"], + 'final_grades': assignment_type_grade_summary["final_grades"], } context = self.get_serializer_context() context['staff_access'] = is_staff diff --git a/lms/djangoapps/grades/subsection_grade.py b/lms/djangoapps/grades/subsection_grade.py index a825760cea71..54974cd5d5e0 100644 --- a/lms/djangoapps/grades/subsection_grade.py +++ b/lms/djangoapps/grades/subsection_grade.py @@ -5,9 +5,8 @@ from abc import ABCMeta from collections import OrderedDict -from datetime import datetime +from datetime import datetime, timezone from logging import getLogger -from pytz import UTC from lazy import lazy from lms.djangoapps.grades.models import BlockRecord, PersistentSubsectionGrade @@ -64,7 +63,7 @@ def show_grades(self, has_staff_access): # For NEVER_BUT_INCLUDE_GRADE, show_grades returns True if the due date has passed, # but correctness_available returns False. return (self.due is None or - self.due < datetime.now(UTC)) + self.due < datetime.now(timezone.utc)) return ShowCorrectness.correctness_available(self.show_correctness, self.due, has_staff_access) @property diff --git a/lms/templates/courseware/progress.html b/lms/templates/courseware/progress.html index ee404f08c4d2..3ee4044fcbbf 100644 --- a/lms/templates/courseware/progress.html +++ b/lms/templates/courseware/progress.html @@ -16,6 +16,7 @@ from lms.djangoapps.grades.api import constants as grades_constants from openedx.core.djangolib.markup import HTML, Text from openedx.features.enterprise_support.utils import get_enterprise_learner_generic_name +from xmodule.graders import ShowCorrectness %> <% @@ -180,7 +181,7 @@

${ chapter['display_name']}

%if hide_url:

${section.display_name} - %if (total > 0 or earned > 0) and section.show_grades(staff_access) and not section.show_correctness == 'never_but_include_grade': + %if (total > 0 or earned > 0) and ShowCorrectness.correctness_available(section.show_correctness, section.due, staff_access): ${_("{earned} of {total} possible points").format(earned='{:.3n}'.format(float(earned)), total='{:.3n}'.format(float(total)))} @@ -189,14 +190,14 @@

%else: ${ section.display_name} - %if (total > 0 or earned > 0) and section.show_grades(staff_access) and not section.show_correctness == 'never_but_include_grade': + %if (total > 0 or earned > 0) and ShowCorrectness.correctness_available(section.show_correctness, section.due, staff_access): ${_("{earned} of {total} possible points").format(earned='{:.3n}'.format(float(earned)), total='{:.3n}'.format(float(total)))} %endif %endif - %if (total > 0 or earned > 0) and section.show_grades(staff_access) and not section.show_correctness == 'never_but_include_grade': + %if (total > 0 or earned > 0) and ShowCorrectness.correctness_available(section.show_correctness, section.due, staff_access): ${"({0:.3n}/{1:.3n}) {2}".format( float(earned), float(total), percentageString )} %endif

@@ -219,7 +220,7 @@

%endif

%if len(section.problem_scores.values()) > 0: - %if section.show_grades(staff_access) and not section.show_correctness == 'never_but_include_grade': + %if ShowCorrectness.correctness_available(section.show_correctness, section.due, staff_access):
${ _("Problem Scores: ") if section.graded else _("Practice Scores: ")}
%for score in section.problem_scores.values(): From 6731351b2456e313ba612625ab533f9e89b0c019 Mon Sep 17 00:00:00 2001 From: Muhammad Anas Date: Wed, 15 Oct 2025 19:09:46 +0500 Subject: [PATCH 6/9] fix: lint issues --- .../course_home_api/progress/api.py | 21 +++++++--------- .../course_home_api/progress/views.py | 25 ++++++++++--------- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/lms/djangoapps/course_home_api/progress/api.py b/lms/djangoapps/course_home_api/progress/api.py index e830c98f0a67..56690cca2264 100644 --- a/lms/djangoapps/course_home_api/progress/api.py +++ b/lms/djangoapps/course_home_api/progress/api.py @@ -4,7 +4,6 @@ from django.contrib.auth import get_user_model from opaque_keys.edx.keys import CourseKey -from typing import Any from xmodule.graders import ShowCorrectness from datetime import datetime, timezone @@ -104,16 +103,13 @@ def __init__(self, course_grade, grading_policy: dict, has_staff_access: bool): def _build_policy_map(self) -> dict: """Convert grading policy into a lookup of assignment type → policy info.""" policy_map = {} - try: - for policy in self.grading_policy.get('GRADER', []): - policy_map[policy.get('type')] = { - 'weight': policy.get('weight', 0.0), - 'short_label': policy.get('short_label', ''), - 'num_droppable': policy.get('drop_count', 0), - 'num_total': policy.get('min_count', 0), - } - except Exception: - policy_map = {} + for policy in self.grading_policy.get('GRADER', []): + policy_map[policy.get('type')] = { + 'weight': policy.get('weight', 0.0), + 'short_label': policy.get('short_label', ''), + 'num_droppable': policy.get('drop_count', 0), + 'num_total': policy.get('min_count', 0), + } return policy_map def _bucket_for(self, assignment_type: str) -> _AssignmentBucket: @@ -146,7 +142,8 @@ def collect(self): is_included = subsection_grade.show_grades(self.has_staff_access) bucket = self._bucket_for(assignment_type) bucket.add_subsection(score, is_visible, is_included) - if subsection_grade.show_correctness in [ShowCorrectness.PAST_DUE, ShowCorrectness.NEVER_BUT_INCLUDE_GRADE]: + visibilities_with_due_dates = [ShowCorrectness.PAST_DUE, ShowCorrectness.NEVER_BUT_INCLUDE_GRADE] + if subsection_grade.show_correctness in visibilities_with_due_dates: if subsection_grade.due and subsection_grade.due > bucket.last_grade_publish_date: bucket.last_grade_publish_date = subsection_grade.due diff --git a/lms/djangoapps/course_home_api/progress/views.py b/lms/djangoapps/course_home_api/progress/views.py index 78674a2e34b9..54e71df48cc5 100644 --- a/lms/djangoapps/course_home_api/progress/views.py +++ b/lms/djangoapps/course_home_api/progress/views.py @@ -2,7 +2,6 @@ Progress Tab Views """ -import copy from django.contrib.auth import get_user_model from django.http.response import Http404 from edx_django_utils import monitoring as monitoring_utils @@ -180,6 +179,18 @@ def _get_student_user(self, request, course_key, student_id, is_staff): except User.DoesNotExist as exc: raise Http404 from exc + def _visible_section_scores(self, course_grade): + """Return only those chapter/section scores that are visible to the learner.""" + visible_chapters = [] + for chapter in course_grade.chapter_grades.values(): + filtered_sections = [ + subsection + for subsection in chapter["sections"] + if getattr(subsection, "show_correctness", None) != ShowCorrectness.NEVER_BUT_INCLUDE_GRADE + ] + visible_chapters.append({**chapter, "sections": filtered_sections}) + return visible_chapters + def get(self, request, *args, **kwargs): course_key_string = kwargs.get('course_key_string') course_key = CourseKey.from_string(course_key_string) @@ -258,17 +269,7 @@ def get(self, request, *args, **kwargs): ) # Filter out section scores to only have those that are visible to the user - section_scores = [ - {**chapter, "sections": list(chapter["sections"])} - for chapter in course_grade.chapter_grades.values() - ] - for chapter in section_scores: - filtered_sections = [ - subsection - for subsection in chapter["sections"] - if getattr(subsection, "show_correctness", None) != ShowCorrectness.NEVER_BUT_INCLUDE_GRADE - ] - chapter["sections"] = filtered_sections + section_scores = self._visible_section_scores(course_grade) data = { 'access_expiration': access_expiration, From 10f083d592674f05493028230e227860c883db3e Mon Sep 17 00:00:00 2001 From: Muhammad Anas Date: Wed, 15 Oct 2025 19:20:52 +0500 Subject: [PATCH 7/9] fix: pycodestyle issues --- lms/djangoapps/course_home_api/progress/api.py | 4 ++++ lms/djangoapps/course_home_api/progress/serializers.py | 1 + 2 files changed, 5 insertions(+) diff --git a/lms/djangoapps/course_home_api/progress/api.py b/lms/djangoapps/course_home_api/progress/api.py index 56690cca2264..c3bd1b8b178b 100644 --- a/lms/djangoapps/course_home_api/progress/api.py +++ b/lms/djangoapps/course_home_api/progress/api.py @@ -88,6 +88,7 @@ def averages(self) -> Tuple[float, float]: earned_all = (sum(included_scores) / len(self.scores)) if self.scores else 0.0 return earned_visible, earned_all + class _AssignmentTypeGradeAggregator: """Collects and aggregates subsection grades by assignment type.""" @@ -177,6 +178,7 @@ def run(self) -> dict: self.collect() return self.build_results() + def aggregate_assignment_type_grade_summary( course_grade, grading_policy: dict, @@ -196,8 +198,10 @@ def aggregate_assignment_type_grade_summary( aggregator = _AssignmentTypeGradeAggregator(course_grade, grading_policy, has_staff_access) return aggregator.run() + User = get_user_model() + def calculate_progress_for_learner_in_course(course_key: CourseKey, user: User) -> dict: """ Calculate a given learner's progress in the specified course run. diff --git a/lms/djangoapps/course_home_api/progress/serializers.py b/lms/djangoapps/course_home_api/progress/serializers.py index 677f7f3f8556..c48660a41c6a 100644 --- a/lms/djangoapps/course_home_api/progress/serializers.py +++ b/lms/djangoapps/course_home_api/progress/serializers.py @@ -141,6 +141,7 @@ class AssignmentTypeScoresSerializer(ReadOnlySerializer): short_label = serializers.CharField() num_droppable = serializers.IntegerField() + class ProgressTabSerializer(VerifiedModeSerializer): """ Serializer for progress tab From 796a65876f06b83d8fcbf76cacfd4f83542ad038 Mon Sep 17 00:00:00 2001 From: Muhammad Anas Date: Thu, 16 Oct 2025 12:27:25 +0500 Subject: [PATCH 8/9] docs: improved comment --- lms/djangoapps/grades/subsection_grade.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/grades/subsection_grade.py b/lms/djangoapps/grades/subsection_grade.py index 54974cd5d5e0..b0c98497b823 100644 --- a/lms/djangoapps/grades/subsection_grade.py +++ b/lms/djangoapps/grades/subsection_grade.py @@ -60,8 +60,10 @@ def show_grades(self, has_staff_access): Returns whether subsection scores are currently available to users with or without staff access. """ if self.show_correctness == ShowCorrectness.NEVER_BUT_INCLUDE_GRADE: + # show_grades fn is used to determine if the grade should be included in final calculation. # For NEVER_BUT_INCLUDE_GRADE, show_grades returns True if the due date has passed, - # but correctness_available returns False. + # but correctness_available always returns False as we do not want to show correctness + # of problems to the users. return (self.due is None or self.due < datetime.now(timezone.utc)) return ShowCorrectness.correctness_available(self.show_correctness, self.due, has_staff_access) From 039fc518fb04274e68716ee642f6f633f07a33ac Mon Sep 17 00:00:00 2001 From: Muhammad Anas Date: Mon, 20 Oct 2025 20:57:13 +0500 Subject: [PATCH 9/9] fix: issues --- .../course_home_api/progress/api.py | 64 ++++++++++++------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/lms/djangoapps/course_home_api/progress/api.py b/lms/djangoapps/course_home_api/progress/api.py index c3bd1b8b178b..f89ecd3d2596 100644 --- a/lms/djangoapps/course_home_api/progress/api.py +++ b/lms/djangoapps/course_home_api/progress/api.py @@ -2,42 +2,65 @@ Python APIs exposed for the progress tracking functionality of the course home API. """ +from __future__ import annotations + from django.contrib.auth import get_user_model from opaque_keys.edx.keys import CourseKey +from openedx.core.lib.grade_utils import round_away_from_zero from xmodule.graders import ShowCorrectness from datetime import datetime, timezone from lms.djangoapps.courseware.courses import get_course_blocks_completion_summary from dataclasses import dataclass, field -from typing import Optional, List, Dict, Tuple + +User = get_user_model() @dataclass class _AssignmentBucket: - """Holds scores and visibility info for one assignment type.""" + """Holds scores and visibility info for one assignment type. + + Attributes: + assignment_type: Full assignment type name from the grading policy (for example, "Homework"). + num_total: The total number of assignments expected to contribute to the grade before any + drop-lowest rules are applied. + last_grade_publish_date: The most recent date when grades for all assignments of assignment_type + are released and included in the final grade. + scores: Per-subsection fractional scores (each value is ``earned / possible`` and falls in + the range 0–1). While awaiting published content we pad the list with zero placeholders + so that its length always matches ``num_total`` until real scores replace them. + visibilities: Mirrors ``scores`` index-for-index and records whether each subsection's + correctness feedback is visible to the learner (``True``), hidden (``False``), or not + yet populated (``None`` when the entry is a placeholder). + included: Tracks whether each subsection currently counts toward the learner's grade as + determined by ``SubsectionGrade.show_grades``. Values follow the same convention as + ``visibilities`` (``True`` / ``False`` / ``None`` placeholders). + assignments_created: Count of real subsections inserted into the bucket so far. Once this + reaches ``num_total``, all placeholder entries have been replaced with actual data. + """ assignment_type: str - expected_total: int + num_total: int last_grade_publish_date: datetime - scores: List[float] = field(default_factory=list) - visibilities: List[Optional[bool]] = field(default_factory=list) - included: List[Optional[bool]] = field(default_factory=list) + scores: list[float] = field(default_factory=list) + visibilities: list[bool | None] = field(default_factory=list) + included: list[bool | None] = field(default_factory=list) assignments_created: int = 0 @classmethod - def with_placeholders(cls, assignment_type: str, expected_total: int, now: datetime): + def with_placeholders(cls, assignment_type: str, num_total: int, now: datetime): """Create a bucket prefilled with placeholder (empty) entries.""" return cls( assignment_type=assignment_type, - expected_total=expected_total, + num_total=num_total, last_grade_publish_date=now, - scores=[0] * expected_total, - visibilities=[None] * expected_total, - included=[None] * expected_total, + scores=[0] * num_total, + visibilities=[None] * num_total, + included=[None] * num_total, ) def add_subsection(self, score: float, is_visible: bool, is_included: bool): """Add a subsection’s score and visibility, replacing a placeholder if space remains.""" - if self.assignments_created < self.expected_total: + if self.assignments_created < self.num_total: if self.scores: self.scores.pop(0) if self.visibilities: @@ -70,7 +93,7 @@ def hidden_state(self) -> str: return 'some' return 'none' - def averages(self) -> Tuple[float, float]: + def averages(self) -> tuple[float, float]: """Compute visible and included averages over kept scores. Visible average uses only grades with visibility flag True in numerator; denominator is total @@ -99,7 +122,7 @@ def __init__(self, course_grade, grading_policy: dict, has_staff_access: bool): self.has_staff_access = has_staff_access self.now = datetime.now(timezone.utc) self.policy_map = self._build_policy_map() - self.buckets: Dict[str, _AssignmentBucket] = {} + self.buckets: dict[str, _AssignmentBucket] = {} def _build_policy_map(self) -> dict: """Convert grading policy into a lookup of assignment type → policy info.""" @@ -117,8 +140,8 @@ def _bucket_for(self, assignment_type: str) -> _AssignmentBucket: """Get or create a score bucket for the given assignment type.""" bucket = self.buckets.get(assignment_type) if bucket is None: - expected = self.policy_map.get(assignment_type, {}).get('num_total', 0) or 0 - bucket = _AssignmentBucket.with_placeholders(assignment_type, expected, self.now) + num_total = self.policy_map.get(assignment_type, {}).get('num_total', 0) or 0 + bucket = _AssignmentBucket.with_placeholders(assignment_type, num_total, self.now) self.buckets[assignment_type] = bucket return bucket @@ -161,8 +184,8 @@ def build_results(self) -> dict: row = { 'type': assignment_type, 'weight': weight, - 'average_grade': round(earned_visible, 4), - 'weighted_grade': round(earned_visible * weight, 4), + 'average_grade': round_away_from_zero(earned_visible, 4), + 'weighted_grade': round_away_from_zero(earned_visible * weight, 4), 'short_label': short_label, 'num_droppable': policy.get('num_droppable', 0), 'last_grade_publish_date': bucket.last_grade_publish_date, @@ -171,7 +194,7 @@ def build_results(self) -> dict: final_grades += earned_all * weight rows.append(row) rows.sort(key=lambda r: r['weight']) - return {'results': rows, 'final_grades': round(final_grades, 4)} + return {'results': rows, 'final_grades': round_away_from_zero(final_grades, 4)} def run(self) -> dict: """Execute full pipeline (collect + aggregate) returning final payload.""" @@ -199,9 +222,6 @@ def aggregate_assignment_type_grade_summary( return aggregator.run() -User = get_user_model() - - def calculate_progress_for_learner_in_course(course_key: CourseKey, user: User) -> dict: """ Calculate a given learner's progress in the specified course run.