Skip to content

Commit 77bfd23

Browse files
authored
Merge branch 'master' into chris/FAL-4262-partial-migration
2 parents 1a13d1f + 4afff6e commit 77bfd23

23 files changed

Lines changed: 1154 additions & 30 deletions

File tree

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)