--- loncom/homework/grades.pm 2023/02/12 21:01:30 1.792 +++ loncom/homework/grades.pm 2024/12/09 21:39:48 1.799 @@ -1,7 +1,7 @@ # The LearningOnline Network with CAPA # The LON-CAPA Grading handler # -# $Id: grades.pm,v 1.792 2023/02/12 21:01:30 raeburn Exp $ +# $Id: grades.pm,v 1.799 2024/12/09 21:39:48 raeburn Exp $ # # Copyright Michigan State University Board of Trustees # @@ -47,10 +47,12 @@ use Apache::lonstathelpers; use Apache::lonquickgrades; use Apache::bridgetask(); use Apache::lontexconvert(); +use Apache::loncourserespicker; use String::Similarity; use HTML::Parser(); use File::MMagic; use LONCAPA; +use LONCAPA::ltiutils(); use POSIX qw(floor); @@ -65,7 +67,7 @@ my $ssi_retries = 5; my $ssi_error; my $ssi_error_resource; my $ssi_error_message; - +my $registered_cleanup; sub ssi_with_retries { my ($resource, $retries, %form) = @_; @@ -636,7 +638,7 @@ COMMONJSFUNCTIONS #--- Dumps the class list with usernames,list of sections, #--- section, ids and fullnames for each user. sub getclasslist { - my ($getsec,$filterbyaccstatus,$getgroup,$symb,$submitonly,$filterbysubmstatus) = @_; + my ($getsec,$filterbyaccstatus,$getgroup,$symb,$submitonly,$filterbysubmstatus,$filterbypbid,$possibles) = @_; my @getsec; my @getgroup; my $stu_status = join(':',&Apache::loncommon::get_env_multiple('form.Status')); @@ -664,12 +666,16 @@ sub getclasslist { # my %sections; my %fullnames; + my %passback; my ($cdom,$cnum,$partlist); if (($filterbysubmstatus) && ($submitonly ne 'all') && ($symb ne '')) { $cdom = $env{"course.$env{'request.course.id'}.domain"}; $cnum = $env{"course.$env{'request.course.id'}.num"}; my $res_error; ($partlist) = &response_type($symb,\$res_error); + } elsif ($filterbypbid) { + $cdom = $env{"course.$env{'request.course.id'}.domain"}; + $cnum = $env{"course.$env{'request.course.id'}.num"}; } foreach my $student (keys(%$classlist)) { my $end = @@ -756,6 +762,27 @@ sub getclasslist { } } } + if ($filterbypbid) { + if (ref($possibles) eq 'HASH') { + unless (exists($possibles->{$student})) { + delete($classlist->{$student}); + next; + } + } + my $udom = + $classlist->{$student}->[&Apache::loncoursedata::CL_SDOM()]; + my $uname = + $classlist->{$student}->[&Apache::loncoursedata::CL_SNAME()]; + if (($udom ne '') && ($uname ne '')) { + my %pbinfo = &Apache::lonnet::get('nohist_'.$cdom.'_'.$cnum.'_linkprot_pb',[$filterbypbid],$udom,$uname); + if (ref($pbinfo{$filterbypbid}) eq 'ARRAY') { + $passback{$student} = $pbinfo{$filterbypbid}; + } else { + delete($classlist->{$student}); + next; + } + } + } $section = ($section ne '' ? $section : 'none'); if (&canview($section)) { if (!@getsec || grep(/^\Q$section\E$/,@getsec)) { @@ -771,7 +798,7 @@ sub getclasslist { } } my @sections = sort(keys(%sections)); - return ($classlist,\@sections,\%fullnames); + return ($classlist,\@sections,\%fullnames,\%passback); } sub canmodify { @@ -1034,6 +1061,878 @@ sub verifyreceipt { return $string; } +#------------------------------------------------------------------- + +#------------------------------------------- Grade Passback Routines +# + +sub initialpassback { + my ($request,$symb) = @_; + my $cdom = $env{"course.$env{'request.course.id'}.domain"}; + my $cnum = $env{"course.$env{'request.course.id'}.num"}; + my $crstype = &Apache::loncommon::course_type(); + my %passback = &Apache::lonnet::dump('nohist_linkprot_passback',$cdom,$cnum); + my $readonly; + unless ($perm{'mgr'}) { + $readonly = 1; + } + my $formname = 'initialpassback'; + my $navmap = Apache::lonnavmaps::navmap->new(); + my $output; + if (!defined($navmap)) { + if ($crstype eq 'Community') { + $output = &mt('Unable to retrieve information about community contents'); + } else { + $output = &mt('Unable to retrieve information about course contents'); + } + return '
'.$output.'
'; + } + return &Apache::loncourserespicker::create_picker($navmap,'passback',$formname,$crstype,undef, + undef,undef,undef,undef,undef,undef, + \%passback,$readonly); +} + +sub passback_filters { + my ($request,$symb) = @_; + my $cdom = $env{"course.$env{'request.course.id'}.domain"}; + my $cnum = $env{"course.$env{'request.course.id'}.num"}; + my $crstype = &Apache::loncommon::course_type(); + my ($launcher,$appname,$setter,$linkuri,$linkprotector,$scope,$chosen); + if ($env{'form.passback'} ne '') { + $chosen = &unescape($env{'form.passback'}); + ($linkuri,$linkprotector,$scope) = split("\0",$chosen); + ($launcher,$appname,$setter) = &get_passback_launcher($cdom,$cnum,$chosen); + } + my $result; + if ($launcher ne '') { + $result = &launcher_info_box($launcher,$appname,$setter,$linkuri,$scope). + '
'.&mt('Set criteria to use to list students for possible passback of scores, then push Next [_1]',
+ '→').
+ '
'.&mt('Scores sent to launcher CMS').'
'.$outcome.'
'); + } else { + $request->print(''.&mt('No scores sent to launcher CMS').'
'); + } + if (keys(%tosend)) { + $request->print(''.&mt('No scores sent for following')); + my ($zeros,$nopbcreds,$noconfirm,$noscore); + foreach my $student (sort + { + if (lc($$fullname{$a}) ne lc($$fullname{$b})) { + return (lc($$fullname{$a}) cmp lc($$fullname{$b})); + } + return $a cmp $b; + } (keys(%$fullname))) { + next unless ($tosend{$student}); + my ($uname,$udom) = split(/:/,$student); + my $line = '
'.$error.'
'); + } + return; +} + +sub get_passback_launcher { + my ($cdom,$cnum,$chosen) = @_; + my ($linkuri,$linkprotector,$scope) = split("\0",$chosen); + my ($ltinum,$ltitype) = ($linkprotector =~ /^(\d+)(c|d)$/); + my ($appname,$setter); + if ($ltitype eq 'c') { + my %lti = &Apache::lonnet::get_course_lti($cnum,$cdom,'provider'); + if (ref($lti{$ltinum}) eq 'HASH') { + $appname = $lti{$ltinum}{'name'}; + if ($appname) { + $setter = ' (defined in course)'; + } + } + } elsif ($ltitype eq 'd') { + my %lti = &Apache::lonnet::get_domain_lti($cdom,'linkprot'); + if (ref($lti{$ltinum}) eq 'HASH') { + $appname = $lti{$ltinum}{'name'}; + if ($appname) { + $setter = ' (defined in domain)'; + } + } + } + if ($linkuri =~ m{^\Q/tiny/$cdom/\E(\w+)$}) { + my $key = $1; + my $tinyurl; + my ($result,$cached)=&Apache::lonnet::is_cached_new('tiny',$cdom."\0".$key); + if (defined($cached)) { + $tinyurl = $result; + } else { + my $configuname = &Apache::lonnet::get_domainconfiguser($cdom); + my %currtiny = &Apache::lonnet::get('tiny',[$key],$cdom,$configuname); + if ($currtiny{$key} ne '') { + $tinyurl = $currtiny{$key}; + &Apache::lonnet::do_cache_new('tiny',$cdom."\0".$key,$currtiny{$key},600); + } + } + if ($tinyurl) { + my ($crsnum,$launchsymb) = split(/\&/,$tinyurl); + if ($crsnum eq $cnum) { + my %passback = &Apache::lonnet::get('nohist_linkprot_passback',[$launchsymb],$cdom,$cnum); + if (ref($passback{$launchsymb}) eq 'HASH') { + if (exists($passback{$launchsymb}{$chosen})) { + return ($launchsymb,$appname,$setter); + } + } + } + } + } + return (); +} + +sub sections_and_groups { + my (@sections,@groups,$group_display); + @groups = &Apache::loncommon::get_env_multiple('form.group'); + if (grep(/^all$/,@groups)) { + @groups = ('all'); + $group_display = 'all'; + } elsif (grep(/^none$/,@groups)) { + @groups = ('none'); + $group_display = 'none'; + } elsif (@groups > 0) { + $group_display = join(', ',@groups); + } + if ($env{'request.course.sec'} ne '') { + @sections = ($env{'request.course.sec'}); + } else { + @sections = &Apache::loncommon::get_env_multiple('form.section'); + } + my $disabled = ' disabled="disabled"'; + if ($perm{'mgr'}) { + if (grep(/^all$/,@sections)) { + undef($disabled); + } else { + foreach my $sec (@sections) { + if (&canmodify($sec)) { + undef($disabled); + last; + } + } + } + } + if (grep(/^all$/,@sections)) { + @sections = ('all'); + } + return(\@sections,\@groups,$group_display,$disabled); +} + +sub launcher_info_box { + my ($launcher,$appname,$setter,$linkuri,$scope) = @_; + my $shownscope; + if ($scope eq 'res') { + $shownscope = &mt('Resource'); + } elsif ($scope eq 'map') { + $shownscope = &mt('Folder'); + } elsif ($scope eq 'rec') { + $shownscope = &mt('Folder + sub-folders'); + } + return ''. + &Apache::lonhtmlcommon::start_pick_box(). + &Apache::lonhtmlcommon::row_title(&mt('Launch Item Title')). + &Apache::lonnet::gettitle($launcher). + &Apache::lonhtmlcommon::row_closure(). + &Apache::lonhtmlcommon::row_title(&mt('Deep-link')). + $linkuri. + &Apache::lonhtmlcommon::row_closure(). + &Apache::lonhtmlcommon::row_title(&mt('Launcher')). + $appname.' '.$setter. + &Apache::lonhtmlcommon::row_closure(). + &Apache::lonhtmlcommon::row_title(&mt('Score Type')). + $shownscope. + &Apache::lonhtmlcommon::row_closure(1). + &Apache::lonhtmlcommon::end_pick_box().'
'."\n"; +} + +sub passbacks_for_symb { + my ($cdom,$cnum,$symb) = @_; + my %passback = &Apache::lonnet::dump('nohist_linkprot_passback',$cdom,$cnum); + my %needpb; + if (keys(%passback)) { + my $checkpb = 1; + if (exists($passback{$symb})) { + if (keys(%passback) == 1) { + undef($checkpb); + } + if (ref($passback{$symb}) eq 'HASH') { + foreach my $launcher (keys(%{$passback{$symb}})) { + $needpb{$launcher} = 1; + } + } + } + if ($checkpb) { + my ($map,$id,$url) = &Apache::lonnet::decode_symb($symb); + my $navmap = Apache::lonnavmaps::navmap->new(); + if (ref($navmap)) { + my $mapres = $navmap->getResourceByUrl($map); + if (ref($mapres)) { + my $mapsymb = $mapres->symb(); + if (exists($passback{$mapsymb})) { + if (keys(%passback) == 1) { + undef($checkpb); + } + if (ref($passback{$mapsymb}) eq 'HASH') { + foreach my $launcher (keys(%{$passback{$mapsymb}})) { + $needpb{$launcher} = 1; + } + } + } + my %posspb; + if ($checkpb) { + my @recurseup = $navmap->recurseup_maps($map,1); + if (@recurseup) { + map { $posspb{$_} = 1; } @recurseup; + } + } + foreach my $key (keys(%passback)) { + if (exists($posspb{$key})) { + if (ref($passback{$key}) eq 'HASH') { + foreach my $launcher (keys(%{$passback{$key}})) { + my ($linkuri,$linkprotector,$scope) = split("\0",$launcher); + next unless ($scope eq 'rec'); + $needpb{$launcher} = 1; + } + } + } + } + } + } + } + } + return %needpb; +} + +sub process_passbacks { + my ($context,$symbs,$cdom,$cnum,$udom,$uname,$weights,$awardeds,$excuseds,$needpb, + $skip_passback,$pbsave,$pbids) = @_; + if ((ref($needpb) eq 'HASH') && (ref($skip_passback) eq 'HASH') && (ref($pbsave) eq 'HASH')) { + my (%weight,%awarded,%excused); + if ((ref($symbs) eq 'ARRAY') && (ref($weights) eq 'HASH') && (ref($awardeds) eq 'HASH') && + (ref($excuseds) eq 'HASH')) { + %weight = %{$weights}; + %awarded = %{$awardeds}; + %excused = %{$excuseds}; + } + my $uhome = &Apache::lonnet::homeserver($uname,$udom); + my @launchers = keys(%{$needpb}); + my %pbinfo; + if (ref($pbids) eq 'HASH') { + %pbinfo = %{$pbids}; + } else { + %pbinfo = &Apache::lonnet::get('nohist_'.$cdom.'_'.$cnum.'_linkprot_pb',\@launchers,$udom,$uname); + } + my %pbc = &common_passback_info(); + foreach my $launcher (@launchers) { + if (ref($pbinfo{$launcher}) eq 'ARRAY') { + my $pbid = $pbinfo{$launcher}[0]; + my $pburl = $pbinfo{$launcher}[1]; + my (%total_by_symb,%possible_by_symb); + if (($pbid ne '') && ($pburl ne '')) { + next if ($skip_passback->{$launcher}); + my %pb = %pbc; + if ((exists($pbsave->{$launcher})) && + (ref($pbsave->{$launcher}) eq 'HASH')) { + foreach my $item ('lti_in_use','crsdef','ltinum','keynum','scoretype','msgformat', + 'symb','map','pbscope','linkuri','linkprotector','scope') { + $pb{$item} = $pbsave->{$launcher}{$item}; + } + } else { + my $ltitype; + ($pb{'linkuri'},$pb{'linkprotector'},$pb{'scope'}) = split("\0",$launcher); + ($pb{'ltinum'},$ltitype) = ($pb{'linkprotector'} =~ /^(\d+)(c|d)$/); + if ($ltitype eq 'c') { + my %crslti = &Apache::lonnet::get_course_lti($cnum,$cdom,'provider'); + $pb{'lti_in_use'} = $crslti{$pb{'ltinum'}}; + $pb{'crsdef'} = 1; + } else { + my %domlti = &Apache::lonnet::get_domain_lti($cdom,'linkprot'); + $pb{'lti_in_use'} = $domlti{$pb{'ltinum'}}; + } + if (ref($pb{'lti_in_use'}) eq 'HASH') { + $pb{'msgformat'} = $pb{'lti_in_use'}->{'passbackformat'}; + $pb{'keynum'} = $pb{'lti_in_use'}->{'cipher'}; + $pb{'scoretype'} = 'decimal'; + if ($pb{'lti_in_use'}->{'scoreformat'} =~ /^(decimal|ratio|percentage)$/) { + $pb{'scoretype'} = $1; + } + $pb{'symb'} = &Apache::loncommon::symb_from_tinyurl($pb{'linkuri'},$cnum,$cdom); + if ($pb{'symb'} =~ /\.(page|sequence)$/) { + $pb{'map'} = &Apache::lonnet::deversion((&Apache::lonnet::decode_symb($pb{'symb'}))[2]); + } else { + $pb{'map'} = &Apache::lonnet::deversion((&Apache::lonnet::decode_symb($pb{'symb'}))[0]); + } + $pb{'map'} = &Apache::lonnet::clutter($pb{'map'}); + if ($pb{'scope'} eq 'res') { + $pb{'pbscope'} = 'resource'; + } elsif ($pb{'scope'} eq 'map') { + $pb{'pbscope'} = 'nonrec'; + } elsif ($pb{'scope'} eq 'rec') { + $pb{'pbscope'} = 'map'; + } + foreach my $item ('lti_in_use','crsdef','ltinum','keynum','scoretype','msgformat', + 'symb','map','pbscope','linkuri','linkprotector','scope') { + $pbsave->{$launcher}{$item} = $pb{$item}; + } + } else { + $skip_passback->{$launcher} = 1; + } + } + if (ref($symbs) eq 'ARRAY') { + foreach my $symb (@{$symbs}) { + if ((ref($weight{$symb}) eq 'HASH') && (ref($awarded{$symb}) eq 'HASH') && + (ref($excused{$symb}) eq 'HASH')) { + foreach my $part (keys(%{$weight{$symb}})) { + if ($excused{$symb}{$part}) { + next; + } + my $partweight = $weight{$symb}{$part} eq '' ? 1 : + $weight{$symb}{$part}; + if ($awarded{$symb}{$part}) { + $total_by_symb{$symb} += $partweight * $awarded{$symb}{$part}; + } + $possible_by_symb{$symb} += $partweight; + } + } + } + } + if ($context eq 'updatebypage') { + my $ltigrade = { + 'ltinum' => $pb{'ltinum'}, + 'lti' => $pb{'lti_in_use'}, + 'crsdef' => $pb{'crsdef'}, + 'cid' => $cdom.'_'.$cnum, + 'uname' => $uname, + 'udom' => $udom, + 'uhome' => $uhome, + 'pbid' => $pbid, + 'pburl' => $pburl, + 'pbtype' => $pb{'type'}, + 'pbscope' => $pb{'pbscope'}, + 'pbmap' => $pb{'map'}, + 'pbsymb' => $pb{'symb'}, + 'format' => $pb{'scoretype'}, + 'scope' => $pb{'scope'}, + 'clientip' => $pb{'clientip'}, + 'linkprot' => $pb{'linkprotector'}.':'.$pb{'linkuri'}, + 'total_s' => \%total_by_symb, + 'possible_s' => \%possible_by_symb, + }; + push(@Apache::lonhomework::ltipassback,$ltigrade); + next; + } + my ($total,$possible); + if ($pb{'pbscope'} eq 'resource') { + $total = $total_by_symb{$pb{'symb'}}; + $possible = $possible_by_symb{$pb{'symb'}}; + } elsif (($pb{'pbscope'} eq 'map') || ($pb{'pbscope'} eq 'nonrec')) { + ($total,$possible) = + &Apache::lonhomework::get_lti_score($uname,$udom,$pb{'map'},$pb{'pbscope'}, + \%total_by_symb,\%possible_by_symb); + } + if (!$possible) { + $total = 0; + $possible = 1; + } + my ($sent,$score,$code,$result) = + &LONCAPA::ltiutils::send_grade($cdom,$cnum,$pb{'crsdef'},$pb{'type'},$pb{'ltinum'}, + $pb{'keynum'},$pbid,$pburl,$pb{'scoretype'},$pb{'sigmethod'}, + $pb{'msgformat'},$total,$possible); + my $no_passback; + if ($sent) { + if ($code == 200) { + my $namespace = $cdom.'_'.$cnum.'_lp_passback'; + my $store = { + 'score' => $score, + 'ip' => $pb{'ip'}, + 'host' => $pb{'lonhost'}, + 'protector' => $pb{'linkprotector'}, + 'deeplink' => $pb{'linkuri'}, + 'scope' => $pb{'scope'}, + 'url' => $pburl, + 'id' => $pbid, + 'clientip' => $pb{'clientip'}, + 'whodoneit' => $env{'user.name'}.':'.$env{'user.domain'}, + }; + my $value=''; + foreach my $key (keys(%{$store})) { + $value.=&escape($key).'='.&Apache::lonnet::freeze_escape($store->{$key}).'&'; + } + $value=~s/\&$//; + &Apache::lonnet::courselog(&escape($pb{'linkuri'}).':'.$uname.':'.$udom.':EXPORT:'.$value); + &Apache::lonnet::cstore({'score' => $score},$launcher,$namespace,$udom,$uname,'',$pb{'ip'},1); + } else { + $no_passback = 1; + } + } else { + $no_passback = 1; + } + if ($no_passback) { + &Apache::lonnet::log($udom,$uname,$uhome,$no_passback." score: $score; total: $total; possible: $possible"); + my $ltigrade = { + 'ltinum' => $pb{'ltinum'}, + 'lti' => $pb{'lti_in_use'}, + 'crsdef' => $pb{'crsdef'}, + 'cid' => $cdom.'_'.$cnum, + 'uname' => $uname, + 'udom' => $udom, + 'uhome' => $uhome, + 'pbid' => $pbid, + 'pburl' => $pburl, + 'pbtype' => $pb{'type'}, + 'pbscope' => $pb{'pbscope'}, + 'pbmap' => $pb{'map'}, + 'pbsymb' => $pb{'symb'}, + 'format' => $pb{'scoretype'}, + 'scope' => $pb{'scope'}, + 'clientip' => $pb{'clientip'}, + 'linkprot' => $pb{'linkprotector'}.':'.$pb{'linkuri'}, + 'total' => $total, + 'possible' => $possible, + 'score' => $score, + }; + &Apache::lonnet::put('linkprot_passback_pending',$ltigrade,$cdom,$cnum); + } + } + } + } + } + return; +} + +sub common_passback_info { + my %pbc = ( + sigmethod => 'HMAC-SHA1', + type => 'linkprot', + clientip => &Apache::lonnet::get_requestor_ip(), + lonhost => $Apache::lonnet::perlvar{'lonHostID'}, + ip => &Apache::lonnet::get_host_ip($Apache::lonnet::perlvar{'lonHostID'}), + ); + return %pbc; +} + #--- This is called by a number of programs. #--- Called from the Grading Menu - View/Grade an individual student #--- Also called directly when one clicks on the subm button @@ -1065,34 +1964,8 @@ sub listStudents { } } - my %js_lt = &Apache::lonlocal::texthash ( - 'multiple' => 'Please select a student or group of students before clicking on the Next button.', - 'single' => 'Please select the student before clicking on the Next button.', - ); - &js_escape(\%js_lt); + $request->print(&checkselect_js()); $request->print(&Apache::lonhtmlcommon::scripttag(<'. - &keywords_highlight($oessay). - '
'. + &keywords_highlight($oessay). + '
". &mt("Failed to save data for student [_1]. Message when trying to save was: [_2]", @@ -5752,6 +6798,7 @@ sub updateGradeByPage { $iterator->next(); # skip the first BEGIN_MAP my $curRes = $iterator->next(); # for "current resource" my ($depth,$question,$prob,$changeflag,$hideflag)= (1,1,1,0,0); + my (@updates,%weights,%excuseds,%awardeds,@symbs_in_map); while ($depth > 0) { if($curRes == $iterator->BEGIN_MAP) { $depth++; } if($curRes == $iterator->END_MAP) { $depth--; } @@ -5760,6 +6807,7 @@ sub updateGradeByPage { my $parts = $curRes->parts(); my $title = $curRes->compTitle(); my $symbx = $curRes->symb(); + push(@symbs_in_map,$symbx); $studentTable.= &Apache::loncommon::start_data_table_row(). '
'. + &mt('Part(s) graded correct by the computer is marked with a [_1] symbol.',$checkIcon). + "
\n"; + } + $output .= $partinfo; + $output .= $lastsubonly; + $output .= &displaySubByDates($symb,\%record,$partlist,$responseType,$checkIcon,$vuname,$vudom); + $output .= ''.&mt('Access Denied ([_1])',$command).'
');