Skip to content

Commit 4afff6e

Browse files
authored
feat: shift progress calculation to backend, add never_but_include_grade (#37399)
This commit migrates the data calculation logic for the GradeSummary table, which was previously in the frontend-app-learning. This commit also introduces a new visibility option for assignment scores: “Never show individual assessment results, but show overall assessment results after the due date.” With this option, learners cannot see question-level correctness or scores at any time. However, once the due date has passed, they can view their overall score in the total grades section on the Progress page. These two changes are coupled with each other because it compromises the integrity of this data to do the score hiding logic on the front end. The corresponding frontend PR is: openedx/frontend-app-learning#1797
1 parent bf8ffe4 commit 4afff6e

File tree

11 files changed

+429
-12
lines changed

11 files changed

+429
-12
lines changed

cms/templates/js/show-correctness-editor.underscore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@
3535
<% } %>
3636
<%- gettext('If the subsection does not have a due date, learners always see their scores when they submit answers to assessments.') %>
3737
</p>
38+
<label class="label">
39+
<input class="input input-radio" name="show-correctness" type="radio" value="never_but_include_grade" aria-describedby="never_show_correctness_but_include_grade_description" />
40+
<%- gettext('Never show individual assessment results, but show overall assessment results after due date') %>
41+
</label>
42+
<p class='field-message' id='never_show_correctness_description'>
43+
<%- 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.') %>
44+
</p>
3845
</div>
3946
</div>
4047
</form>

lms/djangoapps/course_home_api/progress/api.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,226 @@
22
Python APIs exposed for the progress tracking functionality of the course home API.
33
"""
44

5+
from __future__ import annotations
6+
57
from django.contrib.auth import get_user_model
68
from opaque_keys.edx.keys import CourseKey
9+
from openedx.core.lib.grade_utils import round_away_from_zero
10+
from xmodule.graders import ShowCorrectness
11+
from datetime import datetime, timezone
712

813
from lms.djangoapps.courseware.courses import get_course_blocks_completion_summary
14+
from dataclasses import dataclass, field
915

1016
User = get_user_model()
1117

1218

19+
@dataclass
20+
class _AssignmentBucket:
21+
"""Holds scores and visibility info for one assignment type.
22+
23+
Attributes:
24+
assignment_type: Full assignment type name from the grading policy (for example, "Homework").
25+
num_total: The total number of assignments expected to contribute to the grade before any
26+
drop-lowest rules are applied.
27+
last_grade_publish_date: The most recent date when grades for all assignments of assignment_type
28+
are released and included in the final grade.
29+
scores: Per-subsection fractional scores (each value is ``earned / possible`` and falls in
30+
the range 0–1). While awaiting published content we pad the list with zero placeholders
31+
so that its length always matches ``num_total`` until real scores replace them.
32+
visibilities: Mirrors ``scores`` index-for-index and records whether each subsection's
33+
correctness feedback is visible to the learner (``True``), hidden (``False``), or not
34+
yet populated (``None`` when the entry is a placeholder).
35+
included: Tracks whether each subsection currently counts toward the learner's grade as
36+
determined by ``SubsectionGrade.show_grades``. Values follow the same convention as
37+
``visibilities`` (``True`` / ``False`` / ``None`` placeholders).
38+
assignments_created: Count of real subsections inserted into the bucket so far. Once this
39+
reaches ``num_total``, all placeholder entries have been replaced with actual data.
40+
"""
41+
assignment_type: str
42+
num_total: int
43+
last_grade_publish_date: datetime
44+
scores: list[float] = field(default_factory=list)
45+
visibilities: list[bool | None] = field(default_factory=list)
46+
included: list[bool | None] = field(default_factory=list)
47+
assignments_created: int = 0
48+
49+
@classmethod
50+
def with_placeholders(cls, assignment_type: str, num_total: int, now: datetime):
51+
"""Create a bucket prefilled with placeholder (empty) entries."""
52+
return cls(
53+
assignment_type=assignment_type,
54+
num_total=num_total,
55+
last_grade_publish_date=now,
56+
scores=[0] * num_total,
57+
visibilities=[None] * num_total,
58+
included=[None] * num_total,
59+
)
60+
61+
def add_subsection(self, score: float, is_visible: bool, is_included: bool):
62+
"""Add a subsection’s score and visibility, replacing a placeholder if space remains."""
63+
if self.assignments_created < self.num_total:
64+
if self.scores:
65+
self.scores.pop(0)
66+
if self.visibilities:
67+
self.visibilities.pop(0)
68+
if self.included:
69+
self.included.pop(0)
70+
self.scores.append(score)
71+
self.visibilities.append(is_visible)
72+
self.included.append(is_included)
73+
self.assignments_created += 1
74+
75+
def drop_lowest(self, num_droppable: int):
76+
"""Remove the lowest scoring subsections, up to the provided num_droppable."""
77+
while num_droppable > 0 and self.scores:
78+
idx = self.scores.index(min(self.scores))
79+
self.scores.pop(idx)
80+
self.visibilities.pop(idx)
81+
self.included.pop(idx)
82+
num_droppable -= 1
83+
84+
def hidden_state(self) -> str:
85+
"""Return whether kept scores are all, some, or none hidden."""
86+
if not self.visibilities:
87+
return 'none'
88+
all_hidden = all(v is False for v in self.visibilities)
89+
some_hidden = any(v is False for v in self.visibilities)
90+
if all_hidden:
91+
return 'all'
92+
if some_hidden:
93+
return 'some'
94+
return 'none'
95+
96+
def averages(self) -> tuple[float, float]:
97+
"""Compute visible and included averages over kept scores.
98+
99+
Visible average uses only grades with visibility flag True in numerator; denominator is total
100+
number of kept scores (mirrors legacy behavior). Included average uses only scores that are
101+
marked included (show_grades True) in numerator with same denominator.
102+
103+
Returns:
104+
(earned_visible, earned_all) tuple of floats (0-1 each).
105+
"""
106+
if not self.scores:
107+
return 0.0, 0.0
108+
visible_scores = [s for i, s in enumerate(self.scores) if self.visibilities[i]]
109+
included_scores = [s for i, s in enumerate(self.scores) if self.included[i]]
110+
earned_visible = (sum(visible_scores) / len(self.scores)) if self.scores else 0.0
111+
earned_all = (sum(included_scores) / len(self.scores)) if self.scores else 0.0
112+
return earned_visible, earned_all
113+
114+
115+
class _AssignmentTypeGradeAggregator:
116+
"""Collects and aggregates subsection grades by assignment type."""
117+
118+
def __init__(self, course_grade, grading_policy: dict, has_staff_access: bool):
119+
"""Initialize with course grades, grading policy, and staff access flag."""
120+
self.course_grade = course_grade
121+
self.grading_policy = grading_policy
122+
self.has_staff_access = has_staff_access
123+
self.now = datetime.now(timezone.utc)
124+
self.policy_map = self._build_policy_map()
125+
self.buckets: dict[str, _AssignmentBucket] = {}
126+
127+
def _build_policy_map(self) -> dict:
128+
"""Convert grading policy into a lookup of assignment type → policy info."""
129+
policy_map = {}
130+
for policy in self.grading_policy.get('GRADER', []):
131+
policy_map[policy.get('type')] = {
132+
'weight': policy.get('weight', 0.0),
133+
'short_label': policy.get('short_label', ''),
134+
'num_droppable': policy.get('drop_count', 0),
135+
'num_total': policy.get('min_count', 0),
136+
}
137+
return policy_map
138+
139+
def _bucket_for(self, assignment_type: str) -> _AssignmentBucket:
140+
"""Get or create a score bucket for the given assignment type."""
141+
bucket = self.buckets.get(assignment_type)
142+
if bucket is None:
143+
num_total = self.policy_map.get(assignment_type, {}).get('num_total', 0) or 0
144+
bucket = _AssignmentBucket.with_placeholders(assignment_type, num_total, self.now)
145+
self.buckets[assignment_type] = bucket
146+
return bucket
147+
148+
def collect(self):
149+
"""Gather subsection grades into their respective assignment buckets."""
150+
for chapter in self.course_grade.chapter_grades.values():
151+
for subsection_grade in chapter.get('sections', []):
152+
if not getattr(subsection_grade, 'graded', False):
153+
continue
154+
assignment_type = getattr(subsection_grade, 'format', '') or ''
155+
if not assignment_type:
156+
continue
157+
graded_total = getattr(subsection_grade, 'graded_total', None)
158+
earned = getattr(graded_total, 'earned', 0.0) if graded_total else 0.0
159+
possible = getattr(graded_total, 'possible', 0.0) if graded_total else 0.0
160+
earned = 0.0 if earned is None else earned
161+
possible = 0.0 if possible is None else possible
162+
score = (earned / possible) if possible else 0.0
163+
is_visible = ShowCorrectness.correctness_available(
164+
subsection_grade.show_correctness, subsection_grade.due, self.has_staff_access
165+
)
166+
is_included = subsection_grade.show_grades(self.has_staff_access)
167+
bucket = self._bucket_for(assignment_type)
168+
bucket.add_subsection(score, is_visible, is_included)
169+
visibilities_with_due_dates = [ShowCorrectness.PAST_DUE, ShowCorrectness.NEVER_BUT_INCLUDE_GRADE]
170+
if subsection_grade.show_correctness in visibilities_with_due_dates:
171+
if subsection_grade.due and subsection_grade.due > bucket.last_grade_publish_date:
172+
bucket.last_grade_publish_date = subsection_grade.due
173+
174+
def build_results(self) -> dict:
175+
"""Apply drops, compute averages, and return aggregated results and total grade."""
176+
final_grades = 0.0
177+
rows = []
178+
for assignment_type, bucket in self.buckets.items():
179+
policy = self.policy_map.get(assignment_type, {})
180+
bucket.drop_lowest(policy.get('num_droppable', 0))
181+
earned_visible, earned_all = bucket.averages()
182+
weight = policy.get('weight', 0.0)
183+
short_label = policy.get('short_label', '')
184+
row = {
185+
'type': assignment_type,
186+
'weight': weight,
187+
'average_grade': round_away_from_zero(earned_visible, 4),
188+
'weighted_grade': round_away_from_zero(earned_visible * weight, 4),
189+
'short_label': short_label,
190+
'num_droppable': policy.get('num_droppable', 0),
191+
'last_grade_publish_date': bucket.last_grade_publish_date,
192+
'has_hidden_contribution': bucket.hidden_state(),
193+
}
194+
final_grades += earned_all * weight
195+
rows.append(row)
196+
rows.sort(key=lambda r: r['weight'])
197+
return {'results': rows, 'final_grades': round_away_from_zero(final_grades, 4)}
198+
199+
def run(self) -> dict:
200+
"""Execute full pipeline (collect + aggregate) returning final payload."""
201+
self.collect()
202+
return self.build_results()
203+
204+
205+
def aggregate_assignment_type_grade_summary(
206+
course_grade,
207+
grading_policy: dict,
208+
has_staff_access: bool = False,
209+
) -> dict:
210+
"""
211+
Aggregate subsection grades by assignment type and return summary data.
212+
Args:
213+
course_grade: CourseGrade object containing chapter and subsection grades.
214+
grading_policy: Dictionary representing the course's grading policy.
215+
has_staff_access: Boolean indicating if the user has staff access to view all grades.
216+
Returns:
217+
Dictionary with keys:
218+
results: list of per-assignment-type summary dicts
219+
final_grades: overall weighted contribution (float, 4 decimal rounding)
220+
"""
221+
aggregator = _AssignmentTypeGradeAggregator(course_grade, grading_policy, has_staff_access)
222+
return aggregator.run()
223+
224+
13225
def calculate_progress_for_learner_in_course(course_key: CourseKey, user: User) -> dict:
14226
"""
15227
Calculate a given learner's progress in the specified course run.

lms/djangoapps/course_home_api/progress/serializers.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class SubsectionScoresSerializer(ReadOnlySerializer):
2626
assignment_type = serializers.CharField(source='format')
2727
block_key = serializers.SerializerMethodField()
2828
display_name = serializers.CharField()
29+
due = serializers.DateTimeField(allow_null=True)
2930
has_graded_assignment = serializers.BooleanField(source='graded')
3031
override = serializers.SerializerMethodField()
3132
learner_has_access = serializers.SerializerMethodField()
@@ -127,6 +128,20 @@ class VerificationDataSerializer(ReadOnlySerializer):
127128
status_date = serializers.DateTimeField()
128129

129130

131+
class AssignmentTypeScoresSerializer(ReadOnlySerializer):
132+
"""
133+
Serializer for aggregated scores per assignment type.
134+
"""
135+
type = serializers.CharField()
136+
weight = serializers.FloatField()
137+
average_grade = serializers.FloatField()
138+
weighted_grade = serializers.FloatField()
139+
last_grade_publish_date = serializers.DateTimeField()
140+
has_hidden_contribution = serializers.CharField()
141+
short_label = serializers.CharField()
142+
num_droppable = serializers.IntegerField()
143+
144+
130145
class ProgressTabSerializer(VerifiedModeSerializer):
131146
"""
132147
Serializer for progress tab
@@ -146,3 +161,5 @@ class ProgressTabSerializer(VerifiedModeSerializer):
146161
user_has_passing_grade = serializers.BooleanField()
147162
verification_data = VerificationDataSerializer()
148163
disable_progress_graph = serializers.BooleanField()
164+
assignment_type_grade_summary = AssignmentTypeScoresSerializer(many=True)
165+
final_grades = serializers.FloatField()

lms/djangoapps/course_home_api/progress/tests/test_api.py

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,80 @@
66

77
from django.test import TestCase
88

9-
from lms.djangoapps.course_home_api.progress.api import calculate_progress_for_learner_in_course
9+
from lms.djangoapps.course_home_api.progress.api import (
10+
calculate_progress_for_learner_in_course,
11+
aggregate_assignment_type_grade_summary,
12+
)
13+
from xmodule.graders import ShowCorrectness
14+
from datetime import datetime, timedelta, timezone
15+
from types import SimpleNamespace
16+
17+
18+
def _make_subsection(fmt, earned, possible, show_corr, *, due_delta_days=None):
19+
"""Build a lightweight subsection object for testing aggregation scenarios."""
20+
graded_total = SimpleNamespace(earned=earned, possible=possible)
21+
due = None
22+
if due_delta_days is not None:
23+
due = datetime.now(timezone.utc) + timedelta(days=due_delta_days)
24+
return SimpleNamespace(
25+
graded=True,
26+
format=fmt,
27+
graded_total=graded_total,
28+
show_correctness=show_corr,
29+
due=due,
30+
show_grades=lambda staff: True,
31+
)
32+
33+
34+
_AGGREGATION_SCENARIOS = [
35+
(
36+
'all_visible_always',
37+
{'type': 'Homework', 'weight': 1.0, 'drop_count': 0, 'min_count': 2, 'short_label': 'HW'},
38+
[
39+
_make_subsection('Homework', 1, 1, ShowCorrectness.ALWAYS),
40+
_make_subsection('Homework', 0.5, 1, ShowCorrectness.ALWAYS),
41+
],
42+
{'avg': 0.75, 'weighted': 0.75, 'hidden': 'none', 'final': 0.75},
43+
),
44+
(
45+
'some_hidden_never_but_include',
46+
{'type': 'Exam', 'weight': 1.0, 'drop_count': 0, 'min_count': 2, 'short_label': 'EX'},
47+
[
48+
_make_subsection('Exam', 1, 1, ShowCorrectness.ALWAYS),
49+
_make_subsection('Exam', 0.5, 1, ShowCorrectness.NEVER_BUT_INCLUDE_GRADE),
50+
],
51+
{'avg': 0.5, 'weighted': 0.5, 'hidden': 'some', 'final': 0.75},
52+
),
53+
(
54+
'all_hidden_never_but_include',
55+
{'type': 'Quiz', 'weight': 1.0, 'drop_count': 0, 'min_count': 2, 'short_label': 'QZ'},
56+
[
57+
_make_subsection('Quiz', 0.4, 1, ShowCorrectness.NEVER_BUT_INCLUDE_GRADE),
58+
_make_subsection('Quiz', 0.6, 1, ShowCorrectness.NEVER_BUT_INCLUDE_GRADE),
59+
],
60+
{'avg': 0.0, 'weighted': 0.0, 'hidden': 'all', 'final': 0.5},
61+
),
62+
(
63+
'past_due_mixed_visibility',
64+
{'type': 'Lab', 'weight': 1.0, 'drop_count': 0, 'min_count': 2, 'short_label': 'LB'},
65+
[
66+
_make_subsection('Lab', 0.8, 1, ShowCorrectness.PAST_DUE, due_delta_days=-1),
67+
_make_subsection('Lab', 0.2, 1, ShowCorrectness.PAST_DUE, due_delta_days=+3),
68+
],
69+
{'avg': 0.4, 'weighted': 0.4, 'hidden': 'some', 'final': 0.5},
70+
),
71+
(
72+
'drop_lowest_keeps_high_scores',
73+
{'type': 'Project', 'weight': 1.0, 'drop_count': 2, 'min_count': 4, 'short_label': 'PR'},
74+
[
75+
_make_subsection('Project', 1, 1, ShowCorrectness.ALWAYS),
76+
_make_subsection('Project', 1, 1, ShowCorrectness.ALWAYS),
77+
_make_subsection('Project', 0, 1, ShowCorrectness.ALWAYS),
78+
_make_subsection('Project', 0, 1, ShowCorrectness.ALWAYS),
79+
],
80+
{'avg': 1.0, 'weighted': 1.0, 'hidden': 'none', 'final': 1.0},
81+
),
82+
]
1083

1184

1285
class ProgressApiTests(TestCase):
@@ -73,3 +146,37 @@ def test_calculate_progress_for_learner_in_course_summary_empty(self, mock_get_s
73146

74147
results = calculate_progress_for_learner_in_course("some_course", "some_user")
75148
assert not results
149+
150+
def test_aggregate_assignment_type_grade_summary_scenarios(self):
151+
"""
152+
A test to verify functionality of aggregate_assignment_type_grade_summary.
153+
1. Test visibility modes (always, never but include grade, past due)
154+
2. Test drop-lowest behavior
155+
3. Test weighting behavior
156+
4. Test final grade calculation
157+
5. Test average grade calculation
158+
6. Test weighted grade calculation
159+
7. Test has_hidden_contribution calculation
160+
"""
161+
162+
for case_name, policy, subsections, expected in _AGGREGATION_SCENARIOS:
163+
with self.subTest(case_name=case_name):
164+
course_grade = SimpleNamespace(chapter_grades={'chapter': {'sections': subsections}})
165+
grading_policy = {'GRADER': [policy]}
166+
167+
result = aggregate_assignment_type_grade_summary(
168+
course_grade,
169+
grading_policy,
170+
has_staff_access=False,
171+
)
172+
173+
assert 'results' in result and 'final_grades' in result
174+
assert result['final_grades'] == expected['final']
175+
assert len(result['results']) == 1
176+
177+
row = result['results'][0]
178+
assert row['type'] == policy['type'], case_name
179+
assert row['average_grade'] == expected['avg']
180+
assert row['weighted_grade'] == expected['weighted']
181+
assert row['has_hidden_contribution'] == expected['hidden']
182+
assert row['num_droppable'] == policy['drop_count']

0 commit comments

Comments
 (0)