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 @@
%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 @@