diff --git a/lib/WeBWorK/ContentGenerator/Grades.pm b/lib/WeBWorK/ContentGenerator/Grades.pm index 567dd25f08..2a8aff4a55 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(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; @@ -122,16 +137,30 @@ sub scoring_info ($c) { return $output->join(''); } -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) { 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 +173,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,286 +196,192 @@ 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, @recentClosed, @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); - } - } 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; + # 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 { - $act_as_student_test_url = $act_as_student_set_url =~ s/($courseName)\//$1\/test_mode\//r; + push(@closed, $item); } - # 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; + # 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; + } - my $show_problem_scores = 1; + # 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; + } - 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; - } + # 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} = ''; + } - 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('') - ) - ); + # 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; } - # Get percentage correct. - my $totalRightPercent = 100 * wwRound(2, $total ? $totalRight / $total : 0); - my $class = ''; - if ($totalRightPercent == 0) { - $class = 'unattempted'; - } elsif ($totalRightPercent == 100) { - $class = 'correct'; + 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 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}; + if ($item->{grade} > $templateItem->{grade}) { + for ('grade', 'grade_total', 'grade_total_right') { + $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, @recentClosed, @closed) { + $courseTotal += $_->{grade_total}; + $totalRight += $_->{grade_total_right}; } } 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, + recentClosed => \@recentClosed, + closed => \@closed, + courseTotal => $courseTotal, + totalRight => $totalRight ); } 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..7480226bcc 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 =%> +% +% 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/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..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(