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
This commit is contained in:
Muhammad Anas
2025-10-22 19:15:42 +05:00
committed by GitHub
parent bf8ffe4cf7
commit 4afff6ef5c
11 changed files with 429 additions and 12 deletions

View File

@@ -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.') %>
</p>
<label class="label">
<input class="input input-radio" name="show-correctness" type="radio" value="never_but_include_grade" aria-describedby="never_show_correctness_but_include_grade_description" />
<%- gettext('Never show individual assessment results, but show overall assessment results after due date') %>
</label>
<p class='field-message' id='never_show_correctness_description'>
<%- 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.') %>
</p>
</div>
</div>
</form>

View File

@@ -2,14 +2,226 @@
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
User = get_user_model()
@dataclass
class _AssignmentBucket:
"""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 01). 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
num_total: int
last_grade_publish_date: datetime
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, num_total: int, now: datetime):
"""Create a bucket prefilled with placeholder (empty) entries."""
return cls(
assignment_type=assignment_type,
num_total=num_total,
last_grade_publish_date=now,
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 subsections score and visibility, replacing a placeholder if space remains."""
if self.assignments_created < self.num_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 = {}
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:
"""Get or create a score bucket for the given assignment type."""
bucket = self.buckets.get(assignment_type)
if bucket is None:
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
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)
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
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_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,
'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_away_from_zero(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()
def calculate_progress_for_learner_in_course(course_key: CourseKey, user: User) -> dict:
"""
Calculate a given learner's progress in the specified course run.

View File

@@ -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()
@@ -127,6 +128,20 @@ 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
@@ -146,3 +161,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()

View File

@@ -6,7 +6,80 @@ from unittest.mock import patch
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 @@ class ProgressApiTests(TestCase):
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']

View File

@@ -282,8 +282,8 @@ class ProgressTabTestViews(BaseCourseHomeTests):
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 @@ class ProgressTabTestViews(BaseCourseHomeTests):
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

View File

@@ -13,8 +13,11 @@ from rest_framework.permissions import IsAuthenticated
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
@@ -99,6 +102,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
@@ -175,6 +179,18 @@ class ProgressTabView(RetrieveAPIView):
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)
@@ -245,6 +261,16 @@ class ProgressTabView(RetrieveAPIView):
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 = self._visible_section_scores(course_grade)
data = {
'access_expiration': access_expiration,
'certificate_data': get_cert_data(student, course, enrollment_mode, course_grade),
@@ -255,12 +281,14 @@ class ProgressTabView(RetrieveAPIView):
'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

View File

@@ -1781,6 +1781,14 @@ class ProgressPageShowCorrectnessTests(ProgressPageBaseTests):
(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 @@ class ProgressPageShowCorrectnessTests(ProgressPageBaseTests):
(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 @@ class ProgressPageShowCorrectnessTests(ProgressPageBaseTests):
(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)

View File

@@ -5,8 +5,8 @@ SubsectionGrade Class
from abc import ABCMeta
from collections import OrderedDict
from datetime import datetime, timezone
from logging import getLogger
from lazy import lazy
from lms.djangoapps.grades.models import BlockRecord, PersistentSubsectionGrade
@@ -59,6 +59,13 @@ class SubsectionGradeBase(metaclass=ABCMeta):
"""
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 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)
@property

View File

@@ -16,6 +16,7 @@ from lms.djangoapps.certificates.data import CertificateStatuses
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 @@ username = get_enterprise_learner_generic_name(request) or student.username
<h4 class="hd hd-4">
%if hide_url:
<p class="d-inline">${section.display_name}
%if (total > 0 or earned > 0) and section.show_grades(staff_access):
%if (total > 0 or earned > 0) and ShowCorrectness.correctness_available(section.show_correctness, section.due, staff_access):
<span class="sr">
${_("{earned} of {total} possible points").format(earned='{:.3n}'.format(float(earned)), total='{:.3n}'.format(float(total)))}
</span>
@@ -189,14 +190,14 @@ username = get_enterprise_learner_generic_name(request) or student.username
%else:
<a href="${reverse('courseware_subsection', kwargs=dict(course_id=str(course.id), section=chapter['url_name'], subsection=section.url_name))}">
${ section.display_name}
%if (total > 0 or earned > 0) and section.show_grades(staff_access):
%if (total > 0 or earned > 0) and ShowCorrectness.correctness_available(section.show_correctness, section.due, staff_access):
<span class="sr">
${_("{earned} of {total} possible points").format(earned='{:.3n}'.format(float(earned)), total='{:.3n}'.format(float(total)))}
</span>
%endif
</a>
%endif
%if (total > 0 or earned > 0) and section.show_grades(staff_access):
%if (total > 0 or earned > 0) and ShowCorrectness.correctness_available(section.show_correctness, section.due, staff_access):
<span> ${"({0:.3n}/{1:.3n}) {2}".format( float(earned), float(total), percentageString )}</span>
%endif
</h4>
@@ -219,7 +220,7 @@ username = get_enterprise_learner_generic_name(request) or student.username
%endif
</p>
%if len(section.problem_scores.values()) > 0:
%if section.show_grades(staff_access):
%if ShowCorrectness.correctness_available(section.show_correctness, section.due, staff_access):
<dl class="scores">
<dt class="hd hd-6">${ _("Problem Scores: ") if section.graded else _("Practice Scores: ")}</dt>
%for score in section.problem_scores.values():

View File

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

View File

@@ -493,3 +493,11 @@ class ShowCorrectnessTest(unittest.TestCase):
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)