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(