From 56bff23dad2f175fce20f1d5dde86dc85a65cf0c Mon Sep 17 00:00:00 2001 From: Jaimos Skriletz Date: Thu, 19 Feb 2026 08:55:31 -0700 Subject: [PATCH] Initial work on new student grades page. This replaces the Grades page for students with a new layout designed for students. The old grades table is still available to instructors under "Student Progress". Being a grade page for students, instructors see the same info a student would (no hidden sets or grades are shown for instructors acting as a student). The only difference for an instructor is the student navigation menu is shown to switch which student to act as. The assignments are split into categories. Open assignments, reduced scoring assignments (if reduced scoring is enabled), recently closed assignments (if achievement items are enabled and these are assignments closed less than two days ago in which an extension item could be used on), and closed assignments. Currently assignments are all ordered alphabetically in each category (this could be changed by sorting the list, but has not been done at this time). The total grade, if configured to be shown, is shown at the top of the page for all sets that are past the open date. All open, reduced scoring, and recently closed assignments have their grade marked as either complete, the grade can no longer be improved due to no more attempts left or the student has answered all the questions correctly, or incomplete. This is so students can identify which assignments they can improve the grade or use an achievement item on to improve the grade if recently closed. Currently tests do not show this information (this could be added). Each assignment is a list item which shows the total score (for tests it shows the best test version score if the student can see the score). Then for assignments it shows a table which includes the total score and status for each problem in the set. For just in time, only top level problems are shown. For tests, each test version is shown, and then each test version has a table showing the score and status of each problem. This is only the initial work to create a new grades page, there is still work to do in terms of formatting of the page, what is shown, ordering of the page, and anything else that comes up during review of the new page. --- lib/WeBWorK/ContentGenerator/Grades.pm | 266 +++++++++++++++++- lib/WeBWorK/HTML/StudentNav.pm | 8 +- templates/ContentGenerator/Grades.html.ep | 9 +- .../Grades/grade_items.html.ep | 54 ++++ .../Grades/problem_table.html.ep | 32 +++ .../Grades/student_grades.html.ep | 53 ++++ .../Grades/version_list.html.ep | 32 +++ templates/HelpFiles/Grades.html.ep | 21 +- 8 files changed, 464 insertions(+), 11 deletions(-) create mode 100644 templates/ContentGenerator/Grades/grade_items.html.ep create mode 100644 templates/ContentGenerator/Grades/problem_table.html.ep create mode 100644 templates/ContentGenerator/Grades/student_grades.html.ep create mode 100644 templates/ContentGenerator/Grades/version_list.html.ep diff --git a/lib/WeBWorK/ContentGenerator/Grades.pm b/lib/WeBWorK/ContentGenerator/Grades.pm index 567dd25f08..62482492e0 100644 --- a/lib/WeBWorK/ContentGenerator/Grades.pm +++ b/lib/WeBWorK/ContentGenerator/Grades.pm @@ -8,17 +8,32 @@ WeBWorK::ContentGenerator::Grades - Display statistics by user. =cut use WeBWorK::Utils qw(wwRound); -use WeBWorK::Utils::DateTime qw(after); +use WeBWorK::Utils::DateTime qw(after before); use WeBWorK::Utils::JITAR qw(jitar_id_to_seq); -use WeBWorK::Utils::Sets qw(grade_set format_set_name_display); +use WeBWorK::Utils::Sets qw(grade_set format_set_name_display restricted_set_message); use WeBWorK::Utils::ProblemProcessing qw(compute_unreduced_score); +use WeBWorK::HTML::StudentNav qw(studentNav); use WeBWorK::Localize; +use constant TWO_DAYS => 172800; + sub initialize ($c) { $c->{studentID} = $c->param('effectiveUser') // $c->param('user'); return; } +sub nav ($c, $args) { + return '' unless $c->authz->hasPermissions($c->param('user'), 'become_student'); + + return $c->tag( + 'div', + class => 'row sticky-nav', + role => 'navigation', + 'aria-label' => 'student grades navigation', + studentNav($c, undef) + ); +} + sub scoring_info ($c) { my $db = $c->db; my $ce = $c->ce; @@ -450,4 +465,251 @@ sub displayStudentStats ($c, $studentID) { ); } +# Determine if the grade can be improved by testing if the unreduced score +# less than 1 and there are more attempts available. +sub can_improve_score ($c, $set, $problem_record) { + my $unreduced_score = compute_unreduced_score($c->ce, $problem_record, $set); + return $unreduced_score < 1 + && ($problem_record->max_attempts < 0 + || $problem_record->num_correct + $problem_record->num_incorrect < $problem_record->max_attempts); +} + +# Note, this is meant to be a student view. Instructors will see the same information +# as the student they are acting as. For an instructor to see hidden grades, they +# can use the student progress report in instructor tools. +sub displayStudentGrades ($c, $studentID) { + my $db = $c->db; + my $ce = $c->ce; + my $authz = $c->authz; + + my $studentRecord = $db->getUser($studentID); + unless ($studentRecord) { + $c->addbadmessage($c->maketext('Record for user [_1] not found.', $studentID)); + return ''; + } + my $effectiveUser = $studentRecord->user_id; + + my $courseName = $ce->{courseName}; + + # First get all merged sets for this user ordered by set_id. + my @sets = $db->getMergedSetsWhere({ user_id => $studentID }, 'set_id'); + # To be able to find the set objects later, make a handy hash of set ids to set objects. + my %setsByID = (map { $_->set_id => $_ } @sets); + + # Before going through the table generating loop, find all the set versions for the sets in our list. + my %setVersionsCount; + my @allSetIDs; + for my $set (@sets) { + # Don't show hidden sets. + next unless $set->visible; + + my $setID = $set->set_id; + + # FIXME: Here, as in many other locations, we assume that there is a one-to-one matching between versioned sets + # and gateways. We really should have two flags, $set->assignment_type and $set->versioned. I'm not adding + # that yet, however, so this will continue to use assignment_type. + if (defined $set->assignment_type && $set->assignment_type =~ /gateway/) { + # We have to have the merged set versions to know what each of their assignment types are + # (because proctoring can change this). + my @setVersions = + $db->getMergedSetVersionsWhere({ user_id => $studentID, set_id => { like => "$setID,v\%" } }); + + # Add the set versions to our list of sets. + $setsByID{ $_->set_id . ',v' . $_->version_id } = $_ for (@setVersions); + + # Flag the existence of set versions for this set. + $setVersionsCount{$setID} = scalar @setVersions; + + # Save the set names for display. + push(@allSetIDs, $setID); + push(@allSetIDs, map { $_->set_id . ',v' . $_->version_id } @setVersions); + } else { + push(@allSetIDs, $setID); + } + } + + # Set groups. + my (@notOpen, @open, @reduced, @recentClosed, @closed, %allItems); + + for my $setID (@allSetIDs) { + my $set = $setsByID{$setID}; + + # Determine if set is a test and if it is a test template or version. + my $setIsTest = defined $set->assignment_type && $set->assignment_type =~ /gateway/; + my $setIsVersioned = $setIsTest && !defined $setVersionsCount{$setID}; + my $setTemplateID = $setID =~ s/,v\d+$//r; + + # Initialize set item. Define link here. It will be adjusted for versioned tests later. + my $item = { + name => format_set_name_display($setTemplateID), + grade => 0, + grade_total => 0, + grade_total_right => 0, + is_test => $setIsTest, + link => $c->systemLink( + $c->url_for('problem_list', setID => $setID), + params => { effectiveUser => $effectiveUser } + ) + }; + $allItems{$setID} = $item; + + # Determine which group to put set in. Test versions are added to test template. + unless ($setIsVersioned) { + my $enable_reduced_scoring = + $ce->{pg}{ansEvalDefaults}{enableReducedScoring} + && $set->enable_reduced_scoring + && $set->reduced_scoring_date; + if (before($set->open_date)) { + push(@notOpen, $item); + $item->{message} = $c->maketext('Will open on [_1].', + $c->formatDateTime($set->open_date, $ce->{studentDateDisplayFormat})); + next; + } elsif (($enable_reduced_scoring && before($set->reduced_scoring_date)) || before($set->due_date)) { + push(@open, $item); + } elsif ($enable_reduced_scoring && before($set->due_date)) { + push(@reduced, $item); + } elsif ($ce->{achievementsEnabled} && $ce->{achievementItemsEnabled} && before($set->due_date + TWO_DAYS)) + { + push(@recentClosed, $item); + } else { + push(@closed, $item); + } + } + + # Tests need their link updated. Along with template sets need to add a version list. + # Also determines if grade and test problems should be shown. + if ($setIsTest) { + my $act_as_student_test_url = ''; + if ($set->assignment_type eq 'proctored_gateway') { + $act_as_student_test_url = $item->{link} =~ s/($courseName)\//$1\/proctored_test_mode\//r; + } else { + $act_as_student_test_url = $item->{link} =~ s/($courseName)\//$1\/test_mode\//r; + } + + # If this is a template gateway set, determine if there are any versions, then move on. + unless ($setIsVersioned) { + # Remove version from set url + $item->{link} =~ s/,v\d+//; + if ($setVersionsCount{$setID}) { + $item->{versions} = []; + # Hide score initially unless there is a version the score can be seen. + $item->{hide_score} = 1; + } else { + $item->{message} = $c->maketext('No versions of this test have been taken.'); + } + next; + } + + # This is a versioned test, add it to the appropriate template item. + push(@{ $allItems{$setTemplateID}{versions} }, $item); + $item->{name} = $c->maketext('Version [_1]', $set->version_id); + + # Only add link if the problems can be seen. + if ($set->hide_work eq 'N' + || ($set->hide_work eq 'BeforeAnswerDate' && time >= $set->answer_date)) + { + if ($set->assignment_type eq 'proctored_gateway') { + $item->{link} =~ s/($courseName)\//$1\/proctored_test_mode\//; + } else { + $item->{link} =~ s/($courseName)\//$1\/test_mode\//; + } + } else { + $item->{link} = ''; + } + + # If the set has hide_score set, then nothing left to do. + if (defined $set->hide_score && $set->hide_score eq 'Y' + || ($set->hide_score eq 'BeforeAnswerDate' && time < $set->answer_date)) + { + $item->{hide_score} = 1; + $item->{message} = $c->maketext('Display of scores for this test is not allowed.'); + next; + } + # This is a test version, and the scores can be shown, so also show score of template set. + $allItems{$setTemplateID}{hide_score} = 0; + } else { + # For a regular set, start out assuming it is complete until a problem says otherwise. + $item->{completed} = 1; + } + + my ($total_right, $total, $problem_scores, $problem_incorrect_attempts, $problem_records) = + grade_set($db, $set, $studentID, $setIsVersioned, 1); + $total_right = wwRound(2, $total_right); + + # Save set grades. + $item->{grade_total} = $total; + $item->{grade_total_right} = $total_right; + $item->{grade} = 100 * wwRound(2, $total ? $total_right / $total : 0); + + # Only show problem scores if allowed. + unless (defined $set->hide_score_by_problem && $set->hide_score_by_problem eq 'Y') { + $item->{problems} = []; + + # Create a direct link to the problems unless the set is a test, or there is a set + # restriction preventing the student from accessing the set problems. + my $noProblemLink = + $setIsTest + || restricted_set_message($c, $set, 'lti') + || restricted_set_message($c, $set, 'conditional') + || $authz->invalidIPAddress($set); + + for my $i (0 .. $#$problem_scores) { + my $score = $problem_scores->[$i]; + my $problem_id = $setIsVersioned ? $i + 1 : $problem_records->[$i]{problem_id}; + my $problem_link = + $noProblemLink + ? '' + : $c->systemLink($c->url_for('problem_detail', setID => $setID, problemID => $problem_id), + params => { effectiveUser => $effectiveUser }); + $score = 0 unless $score =~ /^\d+$/; + # For jitar sets we only display grades for top level problems. + if ($set->assignment_type eq 'jitar') { + my @seq = jitar_id_to_seq($problem_id); + if ($#seq == 0) { + push(@{ $item->{problems} }, { id => $seq[0], score => $score, link => $problem_link }); + $item->{completed} = 0 if $c->can_improve_score($set, $problem_records->[$i]); + } + } else { + push(@{ $item->{problems} }, { id => $problem_id, score => $score, link => $problem_link }); + $item->{completed} = 0 if !$setIsTest && $c->can_improve_score($set, $problem_records->[$i]); + } + } + } + + # If this is a test version, update template set to the best grade a student hand. + if ($setIsVersioned) { + # Compare the score to the template set and update as needed. + my $templateItem = $allItems{$setTemplateID}; + if ($item->{grade} > $templateItem->{grade}) { + for ('grade', 'grade_total', 'grade_total_right') { + $templateItem->{$_} = $item->{$_}; + } + } + } + } + + # Compute total course grade if requested. + my $courseTotal = 0; + my $totalRight = 0; + if ($ce->{showCourseHomeworkTotals}) { + for (@open, @reduced, @recentClosed, @closed) { + $courseTotal += $_->{grade_total}; + $totalRight += $_->{grade_total_right}; + } + } + + return $c->include( + 'ContentGenerator/Grades/student_grades', + effectiveUser => $effectiveUser, + fullName => join(' ', $studentRecord->first_name, $studentRecord->last_name), + notOpen => \@notOpen, + open => \@open, + reduced => \@reduced, + recentClosed => \@recentClosed, + closed => \@closed, + courseTotal => $courseTotal, + totalRight => $totalRight + ); +} + 1; diff --git a/lib/WeBWorK/HTML/StudentNav.pm b/lib/WeBWorK/HTML/StudentNav.pm index 7591712a8d..04374dca1d 100644 --- a/lib/WeBWorK/HTML/StudentNav.pm +++ b/lib/WeBWorK/HTML/StudentNav.pm @@ -15,10 +15,14 @@ sub studentNav ($c, $setID) { return '' unless $c->authz->hasPermissions($userID, 'become_student'); # Find all users for the given set (except the current user) sorted by last_name, then first_name, then user_id. + # If $setID is undefined, list all users except the current user instead. my @allUserRecords = $c->db->getUsersWhere( { - user_id => - [ map { $_->[0] } $c->db->listUserSetsWhere({ set_id => $setID, user_id => { '!=' => $userID } }) ] + user_id => [ + map { $_->[0] } $c->db->listUserSetsWhere( + { defined $setID ? (set_id => $setID) : (), user_id => { '!=' => $userID } } + ) + ] }, [qw/last_name first_name user_id/] ); diff --git a/templates/ContentGenerator/Grades.html.ep b/templates/ContentGenerator/Grades.html.ep index 4983bacbf0..3a8f9d2df5 100644 --- a/templates/ContentGenerator/Grades.html.ep +++ b/templates/ContentGenerator/Grades.html.ep @@ -1,2 +1,7 @@ -<%= $c->displayStudentStats($c->{studentID}) =%> -<%= $c->scoring_info =%> +<%= $c->displayStudentGrades($c->{studentID}) =%> +% +% my $scoring_info = $c->scoring_info; +% if ($scoring_info) { +

<%= maketext('Additional Grade Information') %>

+ <%= $scoring_info =%> +% } diff --git a/templates/ContentGenerator/Grades/grade_items.html.ep b/templates/ContentGenerator/Grades/grade_items.html.ep new file mode 100644 index 0000000000..13b3d6f5d8 --- /dev/null +++ b/templates/ContentGenerator/Grades/grade_items.html.ep @@ -0,0 +1,54 @@ + + diff --git a/templates/ContentGenerator/Grades/problem_table.html.ep b/templates/ContentGenerator/Grades/problem_table.html.ep new file mode 100644 index 0000000000..3d8a0ac1a1 --- /dev/null +++ b/templates/ContentGenerator/Grades/problem_table.html.ep @@ -0,0 +1,32 @@ +
+ + + + + + + + % for my $problem (@$problems) { + + % } + + + + % for my $problem (@$problems) { + + % } + + +
<%= maketext('Score') %> + <%= maketext('[_1] out of [_2]', $total_right, $total) %> +
<%= maketext('Problem') %> + % if ($problem->{link}) { + <%= link_to $problem->{id} => $problem->{link}, + class => "fw-bold", + 'aria-label' => maketext('[_1] problem [_2]', $set_name, $problem->{id}) %> + % } else { + <%= $problem->{id} %> + % } +
<%= maketext('Status') %><%= $problem->{score} %>%
+
+ diff --git a/templates/ContentGenerator/Grades/student_grades.html.ep b/templates/ContentGenerator/Grades/student_grades.html.ep new file mode 100644 index 0000000000..96e22928e2 --- /dev/null +++ b/templates/ContentGenerator/Grades/student_grades.html.ep @@ -0,0 +1,53 @@ +% use WeBWorK::Utils qw(wwRound); +% +% if ($ce->{showCourseHomeworkTotals}) { +

<%= maketext('Total Grade') %>

+ +% } +% +% if (@$open) { +

<%= maketext('Open Assignments') %>

+ <%= include('ContentGenerator/Grades/grade_items', showCompleted => 1, items => $open) %> +% } +% if (@$reduced) { +

<%= maketext('Reduced Scoring Assignments') %>

+ <%= include('ContentGenerator/Grades/grade_items', showCompleted => 1, items => $reduced) %> +% } +% if (@$recentClosed) { +

<%= maketext('Recently Closed Assignments') %>

+ <%= include('ContentGenerator/Grades/grade_items', showCompleted => 1, items => $recentClosed) %> +% } +% if (@$closed) { +

<%= maketext('Closed Assignments') %>

+ <%= include('ContentGenerator/Grades/grade_items', showCompleted => 0, items => $closed) %> +% } +% if (@$notOpen) { +

<%= maketext('Future Assignments') %>

+ +% } diff --git a/templates/ContentGenerator/Grades/version_list.html.ep b/templates/ContentGenerator/Grades/version_list.html.ep new file mode 100644 index 0000000000..465e29fa77 --- /dev/null +++ b/templates/ContentGenerator/Grades/version_list.html.ep @@ -0,0 +1,32 @@ +
+ +
diff --git a/templates/HelpFiles/Grades.html.ep b/templates/HelpFiles/Grades.html.ep index 3c13cc1c22..8aa3c39c9c 100644 --- a/templates/HelpFiles/Grades.html.ep +++ b/templates/HelpFiles/Grades.html.ep @@ -2,13 +2,24 @@ % title maketext('Grades Help'); %

- <%= maketext(q{This page shows the student's current grades for all sets they are assigned to. Only visible sets } - . 'are shown to the student, while invisible set names are italic when viewed as an instructor. Students can ' - . 'only see the per problem grades on open assignments.') =%> + <%= maketext(q{This page shows the student's current grade for all assignments they are assigned to. This page } + . 'only shows assignments and grades visible to the student. To view all grades, visit the "Student Progress" ' + . q{page for the student. The student navigation menu at the top can be used to change which student's grades } + . 'to view') =%>

- <%= maketext('The total grade row at the bottom shows the total score and percent average over all open ' - . 'assignments. The total grade row can be shown/hidden under general course configuration settings.') =%> + <%= maketext('The total grade at the top shows the total score and percent average over all open assignments. ' + . 'The total grade can be shown/hidden under general course configuration settings.') =%> +

+

+ <%= maketext('The grades are divided into open, reduced scoring, recently closed, closed, and future assignments. ' + . 'No grades are shown for future assignments, just their open date. Closed assignments are only put into the ' + . 'recently closed category if achievements and achievement items are enabled for assignment extensions, and ' + . 'the assignment has been closed for less than 48 hours, making it eligible for the longest extension item. ' + . 'Open, reduced scoring, and recently closed assignments are marked as either "complete" or "not complete". ' + . 'Complete assignments are ones in which either the student has answered all problems correctly, or all ' + . 'attempts have been used up. If an assignment is not complete a student can improve their grade, and the ' + . 'colored marking is to help a student quickly know which assignments they can improve their grade on.') %>

<%== maketext(