From 28f4762aac9d42a3c20b9bcbe9199271cd2e0571 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), and closed assignments. Assignments are ordered using the same `byUrgency` logic as the ProblemSets page. 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. Each assignment is a list item which shows the total score. For tests the best test version score is shown if the student can see the score. Then 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, only the best test version is shown. The old Grades page code is moved into StudentProgress.pm where it is still used. --- htdocs/js/Grades/grades.scss | 42 ++ lib/WeBWorK/ContentGenerator/Grades.pm | 451 ++++++++---------- .../Instructor/StudentProgress.pm | 339 ++++++++++++- lib/WeBWorK/HTML/StudentNav.pm | 8 +- templates/ContentGenerator/Grades.html.ep | 15 +- .../Grades/grade_items.html.ep | 37 ++ .../Grades/problem_table.html.ep | 59 +++ .../Grades/student_grades.html.ep | 52 ++ .../StudentProgress/student_progress.html.ep | 2 +- .../StudentProgress}/student_stats.html.ep | 0 templates/HelpFiles/Grades.html.ep | 18 +- 11 files changed, 754 insertions(+), 269 deletions(-) create mode 100644 htdocs/js/Grades/grades.scss 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 rename templates/ContentGenerator/{Grades => Instructor/StudentProgress}/student_stats.html.ep (100%) diff --git a/htdocs/js/Grades/grades.scss b/htdocs/js/Grades/grades.scss new file mode 100644 index 0000000000..ddfe724c90 --- /dev/null +++ b/htdocs/js/Grades/grades.scss @@ -0,0 +1,42 @@ +.display-grade { + width: 3.5rem; + border: var(--bs-border-color) solid 2px; + border-radius: 15px; + text-align: center; + padding: 0.75rem 0.25rem; + font-size: 1rem; + font-weight: 800; +} + +.grade-item { + display: grid; + width: 100%; + align-items: center; + grid-template-columns: 5rem 1fr; +} + +.grade-item-percent { + grid-row: 1 / 3; + grid-column: 1 / 2; +} + +.grade-item-title { + grid-row: 1 / 2; + grid-column: 2 / 3; +} + +.grade-item-scores { + grid-row: 2 / 3; + grid-column: 2 / 3; +} + +.grade-total { + border: var(--bs-border-color) solid var(--bs-border-width); + padding: 0.5rem 1rem; + border-radius: var(--bs-border-radius); +} + +.grade-total-info { + grid-row: 1 / 3; + grid-column: 2 / 3; +} diff --git a/lib/WeBWorK/ContentGenerator/Grades.pm b/lib/WeBWorK/ContentGenerator/Grades.pm index 567dd25f08..d27552ea0c 100644 --- a/lib/WeBWorK/ContentGenerator/Grades.pm +++ b/lib/WeBWorK/ContentGenerator/Grades.pm @@ -8,10 +8,11 @@ WeBWorK::ContentGenerator::Grades - Display statistics by user. =cut use WeBWorK::Utils qw(wwRound); -use WeBWorK::Utils::DateTime qw(after); +use WeBWorK::Utils::DateTime qw(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; sub initialize ($c) { @@ -19,6 +20,18 @@ sub initialize ($c) { 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; @@ -122,16 +135,21 @@ sub scoring_info ($c) { return $output->join(''); } -sub displayStudentStats ($c, $studentID) { +# 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) { my $db = $c->db; my $ce = $c->ce; my $authz = $c->authz; + my $studentID = $c->{studentID}; 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}; @@ -144,10 +162,10 @@ sub displayStudentStats ($c, $studentID) { my %setVersionsCount; my @allSetIDs; for my $set (@sets) { - # Don't show hidden sets unless user has appropriate permissions. - next unless ($set->visible || $authz->hasPermissions($c->param('user'), 'view_hidden_sets')); + # Don't show hidden sets. + next unless $set->visible; - my $setID = $set->set_id(); + 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 @@ -167,287 +185,210 @@ sub displayStudentStats ($c, $studentID) { # Save the set names for display. push(@allSetIDs, $setID); push(@allSetIDs, map { $_->set_id . ',v' . $_->version_id } @setVersions); - } else { push(@allSetIDs, $setID); } } - my $fullName = join(' ', $studentRecord->first_name, $studentRecord->last_name); - my $effectiveUser = $studentRecord->user_id(); - - my $max_problems = 0; - my $courseTotal = 0; - my $courseTotalRight = 0; + # Set groups. + my (@notOpen, @open, @reduced, @closed, %allItems); for my $setID (@allSetIDs) { - my $set = $db->getGlobalSet($setID); - my $num_of_problems; - # For jitar sets we only display grades for top level problems, so we need to count how many there are. - if ($set && $set->assignment_type() eq 'jitar') { - my @problemIDs = $db->listGlobalProblems($setID); - for my $problemID (@problemIDs) { - my @seq = jitar_id_to_seq($problemID); - $num_of_problems++ if ($#seq == 0); + 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, + record => $set, + 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); + } else { + push(@closed, $item); } - } else { - # For other sets we just count the number of problems. - $num_of_problems = $db->countGlobalProblems($setID); } - $max_problems = - $set && after($set->open_date) && $max_problems < $num_of_problems ? $num_of_problems : $max_problems; - } - - # Variables to help compute gateway scores. - my $numGatewayVersions = 0; - my $bestGatewayScore = 0; - my $rows = $c->c; - for my $setID (@allSetIDs) { - my $act_as_student_set_url = - $c->systemLink($c->url_for('problem_list', setID => $setID), params => { effectiveUser => $effectiveUser }); - my $set = $setsByID{$setID}; - - # Determine if set is a test and create the test url. - my $setIsVersioned = 0; - my $act_as_student_test_url = ''; - if (defined $set->assignment_type && $set->assignment_type =~ /gateway/) { - $setIsVersioned = 1; + # 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 = $act_as_student_set_url =~ s/($courseName)\//$1\/proctored_test_mode\//r; + $act_as_student_test_url = $item->{link} =~ s/($courseName)\//$1\/proctored_test_mode\//r; } else { - $act_as_student_test_url = $act_as_student_set_url =~ s/($courseName)\//$1\/test_mode\//r; + $act_as_student_test_url = $item->{link} =~ s/($courseName)\//$1\/test_mode\//r; } - # Remove version from set url - $act_as_student_set_url =~ s/,v\d+//; - } - # Format set name based on set visibility. - my $setName = $c->tag( - 'span', - class => $set->visible ? 'font-visible' : 'font-hidden', - format_set_name_display($setID =~ s/,v\d+$//r) - ); - - # If the set is a template gateway set and there are no versions, we acknowledge that the set exists and the - # student hasn't attempted it. Otherwise, we skip it and let the versions speak for themselves. - if (defined $setVersionsCount{$setID}) { - next if $setVersionsCount{$setID}; - push @$rows, - $c->tag( - 'tr', - $c->c( - $c->tag( - 'th', - dir => 'ltr', - (after($set->open_date) || $authz->hasPermissions($c->param('user'), 'view_unopened_sets')) - ? $c->link_to($setName => $act_as_student_set_url) - : $setName - ), - $c->tag( - 'td', - colspan => $max_problems + 3, - $c->tag( - 'em', - after($set->open_date) ? $c->maketext('No versions of this test have been taken.') - : $c->maketext( - 'Will open on [_1].', - $c->formatDateTime($set->open_date, $ce->{studentDateDisplayFormat}) - ) - ) - ) - )->join('') - ); - next; - } + # 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+//; + $item->{version_count} = $setVersionsCount{$setID}; + if ($item->{version_count}) { + # Hide score initially unless there is a version the score can be seen. + $item->{hide_score} = 1; + $item->{message} = $c->maketext('Display of scores for this test is not allowed.'); + } else { + $item->{message} = $c->maketext('No versions of this test have been taken.'); + } + next; + } - # If the set has hide_score set, then we need to skip printing the score as well. - if ( - defined $set->assignment_type - && $set->assignment_type =~ /gateway/ - && defined $set->hide_score - && ( - !$authz->hasPermissions($c->param('user'), 'view_hidden_work') - && ($set->hide_score eq 'Y' || ($set->hide_score eq 'BeforeAnswerDate' && time < $set->answer_date)) - ) - ) - { - # Add a link to the test version if the problems can be seen. - my $thisSetName = - $c->link_to($setName => $act_as_student_set_url) . ' (' - . ( - ( - $set->hide_work eq 'N' - || ($set->hide_work eq 'BeforeAnswerDate' && time >= $set->answer_date) - || $authz->hasPermissions($c->param('user'), 'view_unopened_sets') - ) - ? $c->link_to($c->maketext('version [_1]', $set->version_id) => $act_as_student_test_url) - : $c->maketext('version [_1]', $set->version_id) - ) . ')'; - push( - @$rows, - $c->tag( - 'tr', - $c->c( - $c->tag( - 'th', - dir => 'ltr', - sub {$thisSetName} - ), - $c->tag( - 'td', - colspan => $max_problems + 3, - $c->tag('em', $c->maketext('Display of scores for this test is not allowed.')) - ) - )->join('') - ) - ); - next; + # 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)) + { + next; + } + # This is a test version, and the scores can be shown, so also show score of template set. + $allItems{$setTemplateID}{message} = '' if $allItems{$setTemplateID}{hide_score}; + $allItems{$setTemplateID}{hide_score} = 0; } - my ($totalRight, $total, $problem_scores, $problem_incorrect_attempts, $problem_records) = + my ($total_right, $total, $problem_scores, $problem_incorrect_attempts, $problem_records) = grade_set($db, $set, $studentID, $setIsVersioned, 1); - $totalRight = wwRound(2, $totalRight); - - my @html_prob_scores; + $total_right = wwRound(2, $total_right); - my $show_problem_scores = 1; + # Save set grades. + $item->{grade_total} = $total; + $item->{grade_total_right} = $total_right; + $item->{grade} = 100 * wwRound(2, $total ? $total_right / $total : 0); + $item->{problems} = []; - if (defined $set->hide_score_by_problem - && !$authz->hasPermissions($c->param('user'), 'view_hidden_work') - && $set->hide_score_by_problem eq 'Y') - { - $show_problem_scores = 0; - } - - for my $i (0 .. $max_problems - 1) { - my $score = defined $problem_scores->[$i] && $show_problem_scores ? $problem_scores->[$i] : ''; - my $is_correct = $score =~ /^\d+$/ && compute_unreduced_score($ce, $problem_records->[$i], $set) == 1; - push( - @html_prob_scores, - $c->tag( - 'td', - class => 'problem-data', - $c->c( - $c->tag( - 'span', - class => $is_correct ? 'correct' : $score eq ' . ' ? 'unattempted' : '', - $c->b($score) - ), - $c->tag('br'), - (defined $problem_incorrect_attempts->[$i] && $show_problem_scores) - ? $problem_incorrect_attempts->[$i] - : $c->b(' ') - )->join('') - ) - ); - } - - # Get percentage correct. - my $totalRightPercent = 100 * wwRound(2, $total ? $totalRight / $total : 0); - my $class = ''; - if ($totalRightPercent == 0) { - $class = 'unattempted'; - } elsif ($totalRightPercent == 100) { - $class = 'correct'; + # Only show problem scores if allowed. + if ($setIsTest && defined $set->hide_score_by_problem && $set->hide_score_by_problem eq 'Y') { + $item->{message} = $c->maketext('Display of problem scores for this test is not allowed.'); + } else { + # 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 }); + } + } else { + push(@{ $item->{problems} }, { id => $problem_id, score => $score, link => $problem_link }); + } + } } - # If its a gateway set, then in order to mimic the scoring done in Scoring Tools we need to use the best score a - # student had. Otherwise we just add the set to the running course total. + # If this is a test version, update template set to the best grade a student hand. if ($setIsVersioned) { - $setID =~ /(.+),v(\d+)$/; - my $gatewayName = $1; - my $currentVersion = $2; - - # If we are just starting a new gateway then set variables to look for the max. - if ($currentVersion == 1) { - $numGatewayVersions = $db->countSetVersions($studentID, $gatewayName); - } - - if ($totalRight > $bestGatewayScore) { - $bestGatewayScore = $totalRight; - } - - # If its the last version then add the max to the course totals and reset variables; - if ($currentVersion == $numGatewayVersions) { - if (after($set->open_date())) { - $courseTotal += $total; - $courseTotalRight += $bestGatewayScore; + # Compare the score to the template set and update as needed. + my $templateItem = $allItems{$setTemplateID}; + $templateItem->{version_id} = $set->version_id; + $templateItem->{version_link} = $item->{link}; + if ($templateItem->{grade} == 0 || $item->{grade} > $templateItem->{grade}) { + for ('grade', 'grade_total', 'grade_total_right', 'problems', 'message') { + $templateItem->{$_} = $item->{$_}; } - $bestGatewayScore = 0; - } - } else { - if (after($set->open_date())) { - $courseTotal += $total; - $courseTotalRight += $totalRight; } } + } - # Only show scores for open sets, and don't link to non open sets. - if (after($set->open_date) || $authz->hasPermissions($c->param('user'), 'view_unopened_sets')) { - # Set the set name and link. If a test, don't link to the version unless the problems can be seen. - my $thisSetName = $setIsVersioned - ? $c->link_to($setName => $act_as_student_set_url) . ' (' - . ( - ( - $set->hide_work eq 'N' - || ($set->hide_work eq 'BeforeAnswerDate' && time >= $set->answer_date) - || $authz->hasPermissions($c->param('user'), 'view_unopened_sets') - ) - ? $c->link_to($c->maketext('version [_1]', $set->version_id) => $act_as_student_test_url) - : $c->maketext('version [_1]', $set->version_id) - ) - . ')' - : $c->link_to($setName => $act_as_student_set_url); - push @$rows, $c->tag( - 'tr', - $c->c( - $c->tag( - 'th', - scope => 'row', - dir => 'ltr', - sub {$thisSetName} - ), - $c->tag('td', $c->tag('span', class => $class, $totalRightPercent . '%')), - $c->tag('td', sprintf('%0.2f', $totalRight)), # score - $c->tag('td', $total), # out of - @html_prob_scores # problems - )->join('') - ); - } else { - push @$rows, - $c->tag( - 'tr', - $c->c( - $c->tag( - 'th', - dir => 'ltr', - $setName - ), - $c->tag( - 'td', - colspan => $max_problems + 3, - $c->tag( - 'em', - $c->maketext( - 'Will open on [_1].', - $c->formatDateTime($set->open_date, $ce->{studentDateDisplayFormat}) - ) - ) - ) - )->join('') - ); + # Compute total course grade if requested. + my $courseTotal = 0; + my $totalRight = 0; + if ($ce->{showCourseHomeworkTotals}) { + for (@open, @reduced, @closed) { + $courseTotal += $_->{grade_total}; + $totalRight += $_->{grade_total_right}; } } + @notOpen = sort byUrgency @notOpen; + @open = sort byUrgency @open; + @reduced = sort byUrgency @reduced; + @closed = sort byUrgency @closed; + return $c->include( - 'ContentGenerator/Grades/student_stats', - fullName => $fullName, - max_problems => $max_problems, - rows => $rows->join(''), - courseTotal => $courseTotal, - courseTotalRight => $courseTotalRight + 'ContentGenerator/Grades/student_grades', + effectiveUser => $effectiveUser, + fullName => join(' ', $studentRecord->first_name, $studentRecord->last_name), + notOpen => \@notOpen, + open => \@open, + reduced => \@reduced, + closed => \@closed, + courseTotal => $courseTotal, + totalRight => $totalRight ); } +# Taken from ProblemSets.pm, and adjusted for location of set record. +sub byUrgency ($type) { + my $mytime = time; + my $A = $a->{record}; + my $B = $b->{record}; + my @a_parts = + $mytime >= $A->answer_date ? (3, $A->answer_date, $A->due_date) + : $mytime >= $A->due_date ? (2, $A->answer_date, $A->due_date) + : $mytime < $A->open_date ? (1, $A->open_date, $A->due_date) + : (0, $A->due_date, $A->open_date); + my @b_parts = + $mytime >= $B->answer_date ? (3, $B->answer_date, $B->due_date) + : $mytime >= $B->due_date ? (2, $B->answer_date, $B->due_date) + : $mytime < $B->open_date ? (1, $B->open_date, $B->due_date) + : (0, $B->due_date, $B->open_date); + while (@a_parts) { + if (my $returnIt = (shift @a_parts) <=> (shift @b_parts)) { return $returnIt; } + } + return $A->set_id cmp $B->set_id; +} + 1; diff --git a/lib/WeBWorK/ContentGenerator/Instructor/StudentProgress.pm b/lib/WeBWorK/ContentGenerator/Instructor/StudentProgress.pm index 0871948c44..c6bdb2d265 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/StudentProgress.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/StudentProgress.pm @@ -7,10 +7,12 @@ WeBWorK::ContentGenerator::Instructor::StudentProgress - Display Student Progres =cut -use WeBWorK::Utils qw(wwRound); -use WeBWorK::Utils::FilterRecords qw(getFiltersForClass filterRecords); -use WeBWorK::Utils::JITAR qw(jitar_id_to_seq); -use WeBWorK::Utils::Sets qw(grade_set list_set_versions format_set_name_display); +use WeBWorK::Utils qw(wwRound); +use WeBWorK::Utils::DateTime qw(after); +use WeBWorK::Utils::FilterRecords qw(getFiltersForClass filterRecords); +use WeBWorK::Utils::JITAR qw(jitar_id_to_seq); +use WeBWorK::Utils::Sets qw(grade_set list_set_versions format_set_name_display); +use WeBWorK::Utils::ProblemProcessing qw(compute_unreduced_score); sub initialize ($c) { my $db = $c->db; @@ -278,4 +280,333 @@ sub displaySets ($c) { ); } +sub displayStudentStats ($c) { + my $db = $c->db; + my $ce = $c->ce; + my $authz = $c->authz; + + my $studentID = $c->{studentID}; + my $studentRecord = $db->getUser($studentID); + unless ($studentRecord) { + $c->addbadmessage($c->maketext('Record for user [_1] not found.', $studentID)); + return ''; + } + + 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 unless user has appropriate permissions. + next unless ($set->visible || $authz->hasPermissions($c->param('user'), 'view_hidden_sets')); + + 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); + } + } + + my $fullName = join(' ', $studentRecord->first_name, $studentRecord->last_name); + my $effectiveUser = $studentRecord->user_id(); + + my $max_problems = 0; + my $courseTotal = 0; + my $courseTotalRight = 0; + + for my $setID (@allSetIDs) { + my $set = $db->getGlobalSet($setID); + my $num_of_problems; + # For jitar sets we only display grades for top level problems, so we need to count how many there are. + if ($set && $set->assignment_type() eq 'jitar') { + my @problemIDs = $db->listGlobalProblems($setID); + for my $problemID (@problemIDs) { + my @seq = jitar_id_to_seq($problemID); + $num_of_problems++ if ($#seq == 0); + } + } else { + # For other sets we just count the number of problems. + $num_of_problems = $db->countGlobalProblems($setID); + } + $max_problems = + $set && after($set->open_date) && $max_problems < $num_of_problems ? $num_of_problems : $max_problems; + } + + # Variables to help compute gateway scores. + my $numGatewayVersions = 0; + my $bestGatewayScore = 0; + + my $rows = $c->c; + for my $setID (@allSetIDs) { + my $act_as_student_set_url = + $c->systemLink($c->url_for('problem_list', setID => $setID), params => { effectiveUser => $effectiveUser }); + my $set = $setsByID{$setID}; + + # Determine if set is a test and create the test url. + my $setIsVersioned = 0; + my $act_as_student_test_url = ''; + if (defined $set->assignment_type && $set->assignment_type =~ /gateway/) { + $setIsVersioned = 1; + if ($set->assignment_type eq 'proctored_gateway') { + $act_as_student_test_url = $act_as_student_set_url =~ s/($courseName)\//$1\/proctored_test_mode\//r; + } else { + $act_as_student_test_url = $act_as_student_set_url =~ s/($courseName)\//$1\/test_mode\//r; + } + # Remove version from set url + $act_as_student_set_url =~ s/,v\d+//; + } + + # Format set name based on set visibility. + my $setName = $c->tag( + 'span', + class => $set->visible ? 'font-visible' : 'font-hidden', + format_set_name_display($setID =~ s/,v\d+$//r) + ); + + # If the set is a template gateway set and there are no versions, we acknowledge that the set exists and the + # student hasn't attempted it. Otherwise, we skip it and let the versions speak for themselves. + if (defined $setVersionsCount{$setID}) { + next if $setVersionsCount{$setID}; + push @$rows, + $c->tag( + 'tr', + $c->c( + $c->tag( + 'th', + dir => 'ltr', + (after($set->open_date) || $authz->hasPermissions($c->param('user'), 'view_unopened_sets')) + ? $c->link_to($setName => $act_as_student_set_url) + : $setName + ), + $c->tag( + 'td', + colspan => $max_problems + 3, + $c->tag( + 'em', + after($set->open_date) ? $c->maketext('No versions of this test have been taken.') + : $c->maketext( + 'Will open on [_1].', + $c->formatDateTime($set->open_date, $ce->{studentDateDisplayFormat}) + ) + ) + ) + )->join('') + ); + next; + } + + # If the set has hide_score set, then we need to skip printing the score as well. + if ( + defined $set->assignment_type + && $set->assignment_type =~ /gateway/ + && defined $set->hide_score + && ( + !$authz->hasPermissions($c->param('user'), 'view_hidden_work') + && ($set->hide_score eq 'Y' || ($set->hide_score eq 'BeforeAnswerDate' && time < $set->answer_date)) + ) + ) + { + # Add a link to the test version if the problems can be seen. + my $thisSetName = + $c->link_to($setName => $act_as_student_set_url) . ' (' + . ( + ( + $set->hide_work eq 'N' + || ($set->hide_work eq 'BeforeAnswerDate' && time >= $set->answer_date) + || $authz->hasPermissions($c->param('user'), 'view_unopened_sets') + ) + ? $c->link_to($c->maketext('version [_1]', $set->version_id) => $act_as_student_test_url) + : $c->maketext('version [_1]', $set->version_id) + ) . ')'; + push( + @$rows, + $c->tag( + 'tr', + $c->c( + $c->tag( + 'th', + dir => 'ltr', + sub {$thisSetName} + ), + $c->tag( + 'td', + colspan => $max_problems + 3, + $c->tag('em', $c->maketext('Display of scores for this test is not allowed.')) + ) + )->join('') + ) + ); + next; + } + + my ($totalRight, $total, $problem_scores, $problem_incorrect_attempts, $problem_records) = + grade_set($db, $set, $studentID, $setIsVersioned, 1); + $totalRight = wwRound(2, $totalRight); + + my @html_prob_scores; + + my $show_problem_scores = 1; + + if (defined $set->hide_score_by_problem + && !$authz->hasPermissions($c->param('user'), 'view_hidden_work') + && $set->hide_score_by_problem eq 'Y') + { + $show_problem_scores = 0; + } + + for my $i (0 .. $max_problems - 1) { + my $score = defined $problem_scores->[$i] && $show_problem_scores ? $problem_scores->[$i] : ''; + my $is_correct = $score =~ /^\d+$/ && compute_unreduced_score($ce, $problem_records->[$i], $set) == 1; + push( + @html_prob_scores, + $c->tag( + 'td', + class => 'problem-data', + $c->c( + $c->tag( + 'span', + class => $is_correct ? 'correct' : $score eq ' . ' ? 'unattempted' : '', + $c->b($score) + ), + $c->tag('br'), + (defined $problem_incorrect_attempts->[$i] && $show_problem_scores) + ? $problem_incorrect_attempts->[$i] + : $c->b(' ') + )->join('') + ) + ); + } + + # Get percentage correct. + my $totalRightPercent = 100 * wwRound(2, $total ? $totalRight / $total : 0); + my $class = ''; + if ($totalRightPercent == 0) { + $class = 'unattempted'; + } elsif ($totalRightPercent == 100) { + $class = 'correct'; + } + + # If its a gateway set, then in order to mimic the scoring done in Scoring Tools we need to use the best score a + # student had. Otherwise we just add the set to the running course total. + if ($setIsVersioned) { + $setID =~ /(.+),v(\d+)$/; + my $gatewayName = $1; + my $currentVersion = $2; + + # If we are just starting a new gateway then set variables to look for the max. + if ($currentVersion == 1) { + $numGatewayVersions = $db->countSetVersions($studentID, $gatewayName); + } + + if ($totalRight > $bestGatewayScore) { + $bestGatewayScore = $totalRight; + } + + # If its the last version then add the max to the course totals and reset variables; + if ($currentVersion == $numGatewayVersions) { + if (after($set->open_date())) { + $courseTotal += $total; + $courseTotalRight += $bestGatewayScore; + } + $bestGatewayScore = 0; + } + } else { + if (after($set->open_date())) { + $courseTotal += $total; + $courseTotalRight += $totalRight; + } + } + + # Only show scores for open sets, and don't link to non open sets. + if (after($set->open_date) || $authz->hasPermissions($c->param('user'), 'view_unopened_sets')) { + # Set the set name and link. If a test, don't link to the version unless the problems can be seen. + my $thisSetName = $setIsVersioned + ? $c->link_to($setName => $act_as_student_set_url) . ' (' + . ( + ( + $set->hide_work eq 'N' + || ($set->hide_work eq 'BeforeAnswerDate' && time >= $set->answer_date) + || $authz->hasPermissions($c->param('user'), 'view_unopened_sets') + ) + ? $c->link_to($c->maketext('version [_1]', $set->version_id) => $act_as_student_test_url) + : $c->maketext('version [_1]', $set->version_id) + ) + . ')' + : $c->link_to($setName => $act_as_student_set_url); + push @$rows, $c->tag( + 'tr', + $c->c( + $c->tag( + 'th', + scope => 'row', + dir => 'ltr', + sub {$thisSetName} + ), + $c->tag('td', $c->tag('span', class => $class, $totalRightPercent . '%')), + $c->tag('td', sprintf('%0.2f', $totalRight)), # score + $c->tag('td', $total), # out of + @html_prob_scores # problems + )->join('') + ); + } else { + push @$rows, + $c->tag( + 'tr', + $c->c( + $c->tag( + 'th', + dir => 'ltr', + $setName + ), + $c->tag( + 'td', + colspan => $max_problems + 3, + $c->tag( + 'em', + $c->maketext( + 'Will open on [_1].', + $c->formatDateTime($set->open_date, $ce->{studentDateDisplayFormat}) + ) + ) + ) + )->join('') + ); + } + } + + return $c->include( + 'ContentGenerator/Instructor/StudentProgress/student_stats', + fullName => $fullName, + max_problems => $max_problems, + rows => $rows->join(''), + courseTotal => $courseTotal, + courseTotalRight => $courseTotalRight + ); +} + 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..a7192b6e56 100644 --- a/templates/ContentGenerator/Grades.html.ep +++ b/templates/ContentGenerator/Grades.html.ep @@ -1,2 +1,13 @@ -<%= $c->displayStudentStats($c->{studentID}) =%> -<%= $c->scoring_info =%> +% use WeBWorK::Utils qw(getAssetURL); +% +% content_for css => begin + <%= stylesheet getAssetURL($ce, 'js/Grades/grades.css') =%> +% end +% +<%= $c->displayStudentGrades =%> +% +% 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..1aed4b7a75 --- /dev/null +++ b/templates/ContentGenerator/Grades/grade_items.html.ep @@ -0,0 +1,37 @@ + + diff --git a/templates/ContentGenerator/Grades/problem_table.html.ep b/templates/ContentGenerator/Grades/problem_table.html.ep new file mode 100644 index 0000000000..ff1f68645f --- /dev/null +++ b/templates/ContentGenerator/Grades/problem_table.html.ep @@ -0,0 +1,59 @@ +% my @problems = @{ $item->{problems} }; +% +% unless (@problems) { + % # If this is a test and no problems are available, this means the problems are hidden + % # in which case a message was already displayed. + % unless ($item->{version_id}) { + <%= maketext('This assignment has zero problems.') %> + % } + % last; +% } +% + + + % if ($item->{version_id}) { + + + + + % } + + + + + + % for my $problem (@problems) { + + % } + + + + % for my $problem (@problems) { + + % } + + +
<%= maketext('Test') %> + % if ($item->{version_link}) { + + % } + % if ($item->{version_count} > 1) { + <%= maketext('Version [_1] of [_2] versions taken', + $item->{version_id}, $item->{version_count}) %> + % } else { + <%= maketext('Version [_1]', $item->{version_id}) %> + % } + % if ($item->{version_link}) { + + % } +
<%= maketext('Score') %> + <%= maketext('[_1] out of [_2]', $item->{grade_total_right}, $item->{grade_total}) %> +
<%= maketext('Problem') %> + % if ($problem->{link}) { + <%= link_to $problem->{id} => $problem->{link}, + class => "fw-bold", + 'aria-label' => maketext('[_1] problem [_2]', $item->{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..b9cf8e6695 --- /dev/null +++ b/templates/ContentGenerator/Grades/student_grades.html.ep @@ -0,0 +1,52 @@ +% use WeBWorK::Utils qw(wwRound); +% +% if ($ce->{showCourseHomeworkTotals}) { +

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

+
+
+
+ % my $course_grade = 100 * wwRound(2, $courseTotal ? $totalRight / $courseTotal : 0); + % if ($course_grade == 100) { + 💯 + % } else { + <%= $course_grade %>% + % } +
+
+
+ <%= maketext('[_1] out of [_2] total points.', $totalRight, $courseTotal) %> +
+
+% } +% +% if (@$open) { +

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

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

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

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

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

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

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

+ +% } diff --git a/templates/ContentGenerator/Instructor/StudentProgress/student_progress.html.ep b/templates/ContentGenerator/Instructor/StudentProgress/student_progress.html.ep index 913d3e410f..2eb40ddba1 100644 --- a/templates/ContentGenerator/Instructor/StudentProgress/student_progress.html.ep +++ b/templates/ContentGenerator/Instructor/StudentProgress/student_progress.html.ep @@ -24,4 +24,4 @@ <%= link_to $studentRecord->user_id => $c->systemLink(url_for('set_list'), params => { effectiveUser => $c->{studentID} }) =%> % } -<%= WeBWorK::ContentGenerator::Grades::displayStudentStats($c, $c->{studentID}) =%> +<%= $c->displayStudentStats =%> diff --git a/templates/ContentGenerator/Grades/student_stats.html.ep b/templates/ContentGenerator/Instructor/StudentProgress/student_stats.html.ep similarity index 100% rename from templates/ContentGenerator/Grades/student_stats.html.ep rename to templates/ContentGenerator/Instructor/StudentProgress/student_stats.html.ep diff --git a/templates/HelpFiles/Grades.html.ep b/templates/HelpFiles/Grades.html.ep index 3c13cc1c22..53db57cf92 100644 --- a/templates/HelpFiles/Grades.html.ep +++ b/templates/HelpFiles/Grades.html.ep @@ -2,13 +2,21 @@ % 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, closed, and future assignments. No grades are ' + . 'shown for future assignments, just their open date. Assignments are sorted using the same urgency logic ' + . 'as the "Assignments" page. For open assignments, a table of per problem scores are shown for homework ' + . 'assignments. Just in time assignments only show top level problems. Tests only show the best test version ' + . 'provided students can see the test grade.') %>

<%== maketext(