From raeburn at source.lon-capa.org Tue Dec 3 18:20:59 2024 From: raeburn at source.lon-capa.org (raeburn) Date: Tue, 03 Dec 2024 23:20:59 -0000 Subject: [LON-CAPA-cvs] cvs: loncom /homework lonhomework.pm Message-ID: raeburn Tue Dec 3 23:20:59 2024 EDT Modified files: /loncom/homework lonhomework.pm Log: - Coding style. - Change scalar name from $key to $skey to distinguish from another scalar in a nearby foreach loop which is already called $key. Index: loncom/homework/lonhomework.pm diff -u loncom/homework/lonhomework.pm:1.384 loncom/homework/lonhomework.pm:1.385 --- loncom/homework/lonhomework.pm:1.384 Thu Nov 21 07:26:01 2024 +++ loncom/homework/lonhomework.pm Tue Dec 3 23:20:59 2024 @@ -1,7 +1,7 @@ # The LearningOnline Network with CAPA # The LON-CAPA Homework handler # -# $Id: lonhomework.pm,v 1.384 2024/11/21 07:26:01 raeburn Exp $ +# $Id: lonhomework.pm,v 1.385 2024/12/03 23:20:59 raeburn Exp $ # # Copyright Michigan State University Board of Trustees # @@ -2026,7 +2026,7 @@ if ($sent) { if ($code == 200) { if ($item->{'linkprot'}) { - my $key = join("\0",($linkuri,$linkprotector,$scope)); + my $skey = join("\0",($linkuri,$linkprotector,$scope)); my $namespace = $cdom.'_'.$cnum.'_lp_passback'; my $store = { 'score' => $score, @@ -2046,7 +2046,7 @@ } $value=~s/\&$//; &Apache::lonnet::courselog(&escape($linkuri).':'.$uname.':'.$udom.':EXPORT:'.$value); - &Apache::lonnet::cstore({'score' => $score},$key,$namespace,$udom,$uname,'',$ip,1); + &Apache::lonnet::cstore({'score' => $score},$skey,$namespace,$udom,$uname,'',$ip,1); } } else { if ($item->{'linkprot'}) { From raeburn at source.lon-capa.org Tue Dec 3 18:34:12 2024 From: raeburn at source.lon-capa.org (raeburn) Date: Tue, 03 Dec 2024 23:34:12 -0000 Subject: [LON-CAPA-cvs] cvs: doc /loncapafiles loncapafiles.lpml loncom/homework grades.pm loncom/html/res/adm/pages passback.png loncom/interface loncourserespicker.pm Message-ID: raeburn Tue Dec 3 23:34:12 2024 EDT Added files: /loncom/html/res/adm/pages passback.png Modified files: /loncom/homework grades.pm /loncom/interface loncourserespicker.pm /doc/loncapafiles loncapafiles.lpml Log: - Bug 6907. "Content in a course can be set to be deep-link only". Course personnel with mgr priv can pass scores back to launcher CMS for students who accessed via deep-link with LTI-mediated link protection. -------------- next part -------------- Index: loncom/homework/grades.pm diff -u loncom/homework/grades.pm:1.795 loncom/homework/grades.pm:1.796 --- loncom/homework/grades.pm:1.795 Mon Jul 1 22:29:01 2024 +++ loncom/homework/grades.pm Tue Dec 3 23:34:10 2024 @@ -1,7 +1,7 @@ # The LearningOnline Network with CAPA # The LON-CAPA Grading handler # -# $Id: grades.pm,v 1.795 2024/07/01 22:29:01 raeburn Exp $ +# $Id: grades.pm,v 1.796 2024/12/03 23:34:10 raeburn Exp $ # # Copyright Michigan State University Board of Trustees # @@ -47,10 +47,12 @@ 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); @@ -636,7 +638,7 @@ #--- 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 @@ # 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 @@ } } } + 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$/, at getsec)) { @@ -771,7 +798,7 @@ } } my @sections = sort(keys(%sections)); - return ($classlist,\@sections,\%fullnames); + return ($classlist,\@sections,\%fullnames,\%passback); } sub canmodify { @@ -1034,6 +1061,611 @@ return $string; } +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]', + '→'). + '

'; + } + $result .= '
'."\n". + ''."\n". + ''."\n"; + my ($submittext,$newcommand); + if ($launcher ne '') { + $submittext = &mt('Next').' →'; + $newcommand = 'passbacknames'; + $result .= &selectfield(0)."\n"; + } else { + $submittext = '← '.&mt('Previous'); + $newcommand = 'initialpassback'; + if ($env{'form.passback'}) { + $result .= ''.&mt('Invalid launcher').''."\n"; + } else { + $result .= ''.&mt('No launcher selected').''."\n"; + } + } + $result .= ''."\n". + '
'."\n". + ''."\n". + '
'."\n". + '
'."\n"; + return $result; +} + +sub names_for_passback { + 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,$ctr,$newcommand,$submittext); + if ($launcher ne '') { + $result = &launcher_info_box($launcher,$appname,$setter,$linkuri,$scope); + } + $ctr = 0; + my @statuses = &Apache::loncommon::get_env_multiple('form.Status'); + my $stu_status = join(':', at statuses); + $result .= '
'."\n". + ''."\n"; + if ($launcher ne '') { + $result .= ''."\n". + ''."\n"; + my ($sections,$groups,$group_display,$disabled) = §ions_and_groups(); + my $section_display = join(' ',@{$sections}); + my $status_display; + if ((grep(/^Any$/, at statuses)) || + (@statuses == 3)) { + $status_display = &mt('Any'); + } else { + $status_display = join(' '.&mt('or').' ',map { &mt($_); } @statuses); + } + $result .= '

'.&mt('Student(s) with stored passback credentials for [_1], and also satisfy:', + ''.$linkuri.''). + '

    '. + '
  • '.&mt('Section(s)').": $section_display
  • \n". + '
  • '.&mt('Group(s)').": $group_display
  • \n". + '
  • '.&mt('Status').": $status_display
  • \n". + '
'; + my ($classlist,undef,$fullname) = &getclasslist($sections,'1',$groups,'','','',$chosen); + if (keys(%$fullname)) { + $newcommand = 'passbackscores'; + $result .= &build_section_inputs(). + &checkselect_js('passbackusers'). + '


'. + &mt("To send scores, check box(es) next to the student's name(s), then push 'Send Scores'."). + '

'. + &check_script('passbackusers', 'stuinfo')."\n". + '
'."\n". + &check_buttons()."\n". + &Apache::loncommon::start_data_table(). + &Apache::loncommon::start_data_table_header_row(); + my $loop = 0; + while ($loop < 2) { + $result .= ''.&mt('No.').''.&mt('Select').''. + ''.&nameUserString('header').' '.&mt('Section/Group').''; + $loop++; + } + $result .= &Apache::loncommon::end_data_table_header_row()."\n"; + 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))) { + $ctr++; + my $section = $classlist->{$student}->[&Apache::loncoursedata::CL_SECTION()]; + my $group = $classlist->{$student}->[&Apache::loncoursedata::CL_GROUP()]; + my $udom = $classlist->{$student}->[&Apache::loncoursedata::CL_SDOM()]; + my $uname = $classlist->{$student}->[&Apache::loncoursedata::CL_SNAME()]; + if ( $perm{'vgr'} eq 'F' ) { + if ($ctr%2 ==1) { + $result.= &Apache::loncommon::start_data_table_row(); + } + $result .= ''.$ctr.' '. + ''."\n".''. + &nameUserString(undef,$$fullname{$student},$uname,$udom). + ' '.$section.($group ne '' ?'/'.$group:'').''."\n"; + + if ($ctr%2 ==0) { + $result .= &Apache::loncommon::end_data_table_row()."\n"; + } + } + } + if ($ctr%2 ==1) { + $result .= &Apache::loncommon::end_data_table_row(); + } + $result .= &Apache::loncommon::end_data_table()."\n"; + if ($ctr) { + $result .= ''."\n"; + } + } else { + $submittext = '← '.&mt('Previous'); + $newcommand = 'passback'; + $result .= ''.&mt('No students match the selection criteria').'

'; + } + } else { + $newcommand = 'initialpassback'; + $submittext = &mt('Start over'); + if ($env{'form.passback'}) { + $result .= ''.&mt('Invalid launcher').''."\n"; + } else { + $result .= ''.&mt('No launcher selected').''."\n"; + } + } + $result .= ''."\n"; + if (!$ctr) { + $result .= '
'."\n". + ''."\n". + '
'."\n"; + } + $result .= ''."\n"; + return $result; +} + +sub do_passback { + 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); + } + if ($launcher ne '') { + $request->print(&launcher_info_box($launcher,$appname,$setter,$linkuri,$scope)); + } + my $error; + if ($perm{'mgr'}) { + if ($launcher ne '') { + my @poss_students = &Apache::loncommon::get_env_multiple('form.stuinfo'); + if (@poss_students) { + my %possibles; + foreach my $item (@poss_students) { + my ($stuname,$studom) = split(/:/,$item,3); + $possibles{$stuname.':'.$studom} = 1; + } + my ($sections,$groups,$group_display,$disabled) = §ions_and_groups(); + my ($classlist,undef,$fullname,$pbinfo) = + &getclasslist($sections,'1',$groups,'','','',$chosen,\%possibles); + if ((ref($classlist) eq 'HASH') && (ref($pbinfo) eq 'HASH')) { + my %passback = %{$pbinfo}; + my (%tosend,%remotenotok,%scorenotok,%zeroposs,%nopbinfo); + foreach my $possible (keys(%possibles)) { + if ((exists($classlist->{$possible})) && + (exists($passback{$possible})) && (ref($passback{$possible}) eq 'ARRAY')) { + $tosend{$possible} = 1; + } + } + if (keys(%tosend)) { + my ($lti_in_use,$crsdef); + my ($ltinum,$ltitype) = ($linkprotector =~ /^(\d+)(c|d)$/); + if ($ltitype eq 'c') { + my %crslti = &Apache::lonnet::get_course_lti($cnum,$cdom,'provider'); + $lti_in_use = $crslti{$ltinum}; + $crsdef = 1; + } else { + my %domlti = &Apache::lonnet::get_domain_lti($cdom,'linkprot'); + $lti_in_use = $domlti{$ltinum}; + } + if (ref($lti_in_use) eq 'HASH') { + my $msgformat = $lti_in_use->{'passbackformat'}; + my $keynum = $lti_in_use->{'cipher'}; + my $scoretype = 'decimal'; + if ($lti_in_use->{'scoreformat'} =~ /^(decimal|ratio|percentage)$/) { + $scoretype = $1; + } + my $pbsymb = &Apache::loncommon::symb_from_tinyurl($linkuri,$cnum,$cdom); + my $pbmap; + if ($pbsymb =~ /\.(page|sequence)$/) { + $pbmap = &Apache::lonnet::deversion((&Apache::lonnet::decode_symb($pbsymb))[2]); + } else { + $pbmap = &Apache::lonnet::deversion((&Apache::lonnet::decode_symb($pbsymb))[0]); + } + $pbmap = &Apache::lonnet::clutter($pbmap); + my $pbscope; + if ($scope eq 'res') { + $pbscope = 'resource'; + } elsif ($scope eq 'map') { + $pbscope = 'nonrec'; + } elsif ($scope eq 'rec') { + $pbscope = 'map'; + } + my $sigmethod = 'HMAC-SHA1'; + my $type = 'linkprot'; + my $clientip = &Apache::lonnet::get_requestor_ip(); + my $lonhost = $Apache::lonnet::perlvar{'lonHostID'}; + my $ip = &Apache::lonnet::get_host_ip($lonhost); + my $numstudents = scalar(keys(%tosend)); + my %prog_state = &Apache::lonhtmlcommon::Create_PrgWin($request,$numstudents); + my $outcome = &Apache::loncommon::start_data_table(). + &Apache::loncommon::start_data_table_header_row(); + my $loop = 0; + while ($loop < 2) { + $outcome .= ''.&mt('No.').''. + ''.&nameUserString('header').' '.&mt('Section/Group').''. + ''.&mt('Score').''; + $loop++; + } + $outcome .= &Apache::loncommon::end_data_table_header_row()."\n"; + my $ctr=0; + 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); + &Apache::lonhtmlcommon::Increment_PrgWin($request,\%prog_state,'last student'); + my ($uname,$udom) = split(/:/,$student); + my $uhome = &Apache::lonnet::homeserver($uname,$udom), + my $id = $passback{$student}[0], + my $url = $passback{$student}[1], + my ($total,$possible,$usec); + if (ref($classlist->{$student}) eq 'ARRAY') { + $usec = $classlist->{$student}->[&Apache::loncoursedata::CL_SECTION]; + } + if ($pbscope eq 'resource') { + $total = 0; + $possible = 0; + my $navmap = Apache::lonnavmaps::navmap->new($uname,$udom); + if (ref($navmap)) { + my $res = $navmap->getBySymb($pbsymb); + if (ref($res)) { + my $partlist = $res->parts(); + if (ref($partlist) eq 'ARRAY') { + my %record = &Apache::lonnet::restore($pbsymb,$env{'request.course.id'},$udom,$uname); + foreach my $part (@{$partlist}) { + next if ($record{"resource.$part.solved"} =~/^excused/); + my $weight = &Apache::lonnet::EXT("resource.$part.weight",$pbsymb,$udom,$uname,$usec); + $possible += $weight; + if (($record{'version'}) && (exists($record{"resource.$part.awarded"}))) { + my $awarded = $record{"resource.$part.awarded"}; + if ($awarded) { + $total += $weight * $awarded; + } + } + } + } + } + } + } elsif (($pbscope eq 'map') || ($pbscope eq 'nonrec')) { + ($total,$possible) = + &Apache::lonhomework::get_lti_score($uname,$udom,$pbmap,$pbscope); + } + if (($id ne '') && ($url ne '') && ($possible)) { + my ($sent,$score,$code,$result) = + &LONCAPA::ltiutils::send_grade($cdom,$cnum,$crsdef,$type,$ltinum,$keynum,$id, + $url,$scoretype,$sigmethod,$msgformat,$total,$possible); + my $no_passback; + if ($sent) { + if ($code == 200) { + delete($tosend{$student}); + my $namespace = $cdom.'_'.$cnum.'_lp_passback'; + my $store = { + 'score' => $score, + 'ip' => $ip, + 'host' => $lonhost, + 'protector' => $linkprotector, + 'deeplink' => $linkuri, + 'scope' => $scope, + 'url' => $url, + 'id' => $id, + 'clientip' => $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($linkuri).':'.$uname.':'.$udom.':EXPORT:'.$value); + &Apache::lonnet::cstore({'score' => $score},$chosen,$namespace,$udom,$uname,'',$ip,1); + $ctr++; + if ($ctr%2 ==1) { + $outcome .= &Apache::loncommon::start_data_table_row(); + } + my $section = $classlist->{$student}->[&Apache::loncoursedata::CL_SECTION()]; + my $group = $classlist->{$student}->[&Apache::loncoursedata::CL_GROUP()]; + $outcome .= ''.$ctr.' '. + ''.&nameUserString(undef,$$fullname{$student},$uname,$udom). + ' '.$section.($group ne '' ?'/'.$group:'').''. + ''.$score.''."\n"; + if ($ctr%2 ==0) { + $outcome .= &Apache::loncommon::end_data_table_row()."\n"; + } + } else { + $remotenotok{$student} = 1; + $no_passback = "Passback response for ".$linkprotector." was $code ($result)"; + &Apache::lonnet::logthis($no_passback." for $uname:$udom in ${cdom}_${cnum}"); + } + } else { + $scorenotok{$student} = 1; + $no_passback = "Passback of grades not sent for ".$linkprotector; + &Apache::lonnet::logthis($no_passback." for $uname:$udom in ${cdom}_${cnum}"); + } + if ($no_passback) { + &Apache::lonnet::log($udom,$uname,$uhome,$no_passback." score: $score; total: $total; possible: $possible"); + my $ltigrade = { + 'ltinum' => $ltinum, + 'lti' => $lti_in_use, + 'crsdef' => $crsdef, + 'cid' => $cdom.'_'.$cnum, + 'uname' => $uname, + 'udom' => $udom, + 'uhome' => $uhome, + 'pbid' => $id, + 'pburl' => $url, + 'pbtype' => $type, + 'pbscope' => $pbscope, + 'pbmap' => $pbmap, + 'pbsymb' => $pbsymb, + 'format' => $scoretype, + 'scope' => $scope, + 'clientip' => $clientip, + 'linkprot' => $linkprotector, + 'total' => $total, + 'possible' => $possible, + 'score' => $score, + }; + &Apache::lonnet::put('linkprot_passback_pending',$ltigrade,$cdom,$cnum); + } + } else { + if (($id ne '') && ($url ne '')) { + $zeroposs{$student} = 1; + } else { + $nopbinfo{$student} = 1; + } + } + } + &Apache::lonhtmlcommon::Close_PrgWin($request,\%prog_state); + if ($ctr%2 ==1) { + $outcome .= &Apache::loncommon::end_data_table_row(); + } + $outcome .= &Apache::loncommon::end_data_table(); + if ($ctr) { + $request->print('


'.&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 = '

  • '.&nameUserString(undef,$$fullname{$student},$uname,$udom).'
  • '."\n"; + if ($zeroposs{$student}) { + $zeros .= $line; + } elsif ($nopbinfo{$student}) { + $nopbcreds .= $line; + } elsif ($remotenotok{$student}) { + $noconfirm .= $line; + } elsif ($scorenotok{$student}) { + $noscore .= $line; + } + } + if ($zeros) { + $request->print('
    '.&mt('Total points possible was 0').':'. + '
      '.$zeros.'

    '); + } + if ($nopbcreds) { + $request->print('
    '.&mt('Missing unique identifier and/or passback location').':'. + '
      '.$nopbcreds.'

    '); + } + if ($noconfirm) { + $request->print('
    '.&mt('Score receipt not confirmed by receiving CMS').':'. + '
      '.$noconfirm.'

    '); + } + if ($noscore) { + $request->print('
    '.&mt('Score computation or transmission failed').':'. + '
      '.$noscore.'

    '); + } + $request->print('

    '); + } + } else { + $error = &mt('Settings for deep-link launch target unavailable, so no scores were sent'); + } + } else { + $error = &mt('No available students for whom scores can be sent.'); + } + } else { + $error = &mt('Classlist could not be retrieved so no scores were sent.'); + } + } else { + $error = &mt('No students selected to receive scores so none were sent.'); + } + } else { + if ($env{'form.passback'}) { + $error = &mt('Deep-link launch target was invalid so no scores were sent.'); + } else { + $error = &mt('Deep-link launch target was missing so no scores were sent.'); + } + } + } else { + $error = &mt('You do not have permission to manage grades, so no scores were sent'); + } + if ($error) { + $request->print('

    '.$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, at groups,$group_display); + @groups = &Apache::loncommon::get_env_multiple('form.group'); + if (grep(/^all$/, at groups)) { + @groups = ('all'); + $group_display = 'all'; + } elsif (grep(/^none$/, at groups)) { + @groups = ('none'); + $group_display = 'none'; + } elsif (@groups > 0) { + $group_display = join(', ', at 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$/, at sections)) { + undef($disabled); + } else { + foreach my $sec (@sections) { + if (&canmodify($sec)) { + undef($disabled); + last; + } + } + } + } + if (grep(/^all$/, at 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"; +} + #--- 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 +1697,8 @@ } } - 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(< 1) { - for (var i=0; i 'Please select a student or group of students before pushing the Save Scores button.', + 'single' => 'Please select the student before pushing the Save Scores button.', + ); + } else { + %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); + return &Apache::lonhtmlcommon::scripttag(< 1) { + for (var i=0; i&"').'&command='.$cmd; + return '/adm/grades?symb='.&HTML::Entities::encode(&Apache::lonenc::check_encrypt($symb),'<>&"').'&command='. + &HTML::Entities::encode($cmd,'<>&"'); } sub grading_menu { @@ -10669,7 +11324,20 @@ ] }); - + my $cdom = $env{"course.$env{'request.course.id'}.domain"}; + my $cnum = $env{"course.$env{'request.course.id'}.num"}; + my %passback = &Apache::lonnet::dump('nohist_linkprot_passback',$cdom,$cnum); + if (keys(%passback)) { + $fields{'command'} = 'initialpassback'; + my $url6 = &Apache::lonhtmlcommon::build_url('grades/',\%fields); + push (@{$menu[1]{items}}, + { linktext => 'Passback of Scores', + url => $url6, + permission => $permissions{'either'}, + icon => 'passback.png', + linktitle => 'Passback scores to launcher CMS for resources accessed via LTI-mediated deep-linking', + }); + } # Create the menu my $Str; $Str .= '
    '; @@ -11889,6 +12557,44 @@ undef,undef,undef,undef,undef,undef,undef,1); $request->print('
    '); &submit_download_link($request,$symb); + } elsif ($command eq 'initialpassback') { + &startpage($request,$symb,[{href=>'', text=>'Choose Launcher'}],undef,1); + $request->print(&initialpassback($request,$symb)); + } elsif ($command eq 'passback') { + &startpage($request,$symb, + [{href=>&href_symb_cmd($symb,'initialpassback'), text=>'Choose Launcher'}, + {href=>'', text=>'Types of User'}],undef,1); + $request->print(&passback_filters($request,$symb)); + } elsif ($command eq 'passbacknames') { + my $chosen; + if ($env{'form.passback'} ne '') { + if ($env{'form.passback'} eq &unescape($env{'form.passback'})) { + $env{'form.passback'} = &escape($env{'form.passback'} ); + } + $chosen = &HTML::Entities::encode($env{'form.passback'},'<>"&'); + } + &startpage($request,$symb, + [{href=>&href_symb_cmd($symb,'initialpassback'), text=>'Choose Launcher'}, + {href=>&href_symb_cmd($symb,'passback').'&passback='.$chosen, text=>'Types of User'}, + {href=>'', text=>'Select Users'}],undef,1); + $request->print(&names_for_passback($request,$symb)); + } elsif ($command eq 'passbackscores') { + my ($chosen,$stu_status); + if ($env{'form.passback'} ne '') { + if ($env{'form.passback'} eq &unescape($env{'form.passback'})) { + $env{'form.passback'} = &escape($env{'form.passback'} ); + } + $chosen = &HTML::Entities::encode($env{'form.passback'},'<>"&'); + } + if ($env{'form.Status'}) { + $stu_status = &HTML::Entities::encode($env{'form.Status'}); + } + &startpage($request,$symb, + [{href=>&href_symb_cmd($symb,'initialpassback'), text=>'Choose Launcher'}, + {href=>&href_symb_cmd($symb,'passback').'&passback='.$chosen, text=>'Types of User'}, + {href=>&href_symb_cmd($symb,'passbacknames').'&Status='.$stu_status.'&passback='.$chosen, text=>'Select Users'}, + {href=>'', text=>'Execute Passback'}],undef,1); + $request->print(&do_passback($request,$symb)); } elsif ($command) { &startpage($request,$symb,[{href=>'', text=>'Access denied'}]); $request->print('

    '.&mt('Access Denied ([_1])',$command).'

    '); Index: loncom/interface/loncourserespicker.pm diff -u loncom/interface/loncourserespicker.pm:1.18 loncom/interface/loncourserespicker.pm:1.19 --- loncom/interface/loncourserespicker.pm:1.18 Sun Nov 24 04:17:50 2024 +++ loncom/interface/loncourserespicker.pm Tue Dec 3 23:34:11 2024 @@ -1,6 +1,6 @@ # The LearningOnline Network # -# $Id: loncourserespicker.pm,v 1.18 2024/11/24 04:17:50 raeburn Exp $ +# $Id: loncourserespicker.pm,v 1.19 2024/12/03 23:34:11 raeburn Exp $ # # Copyright Michigan State University Board of Trustees # @@ -33,51 +33,73 @@ =head1 SYNOPSIS -loncourserespicker provides an interface for selecting which folders and/or -resources are to be either: +loncourserespicker provides either (1) an interface for selecting which +folders and/or resources are to be selected for a specific action, one of: -(a) exported to an IMS Content Package -(b) subject to access blocking for the duration of an exam/quiz. -(c) dumped to an Authoring Space +(a) export to an IMS Content Package +(b) be subject to access blocking for the duration of an exam/quiz. +(c) dump to an Authoring Space (d) receive shortened URLs to be used when deep-linking into a course +or (2) an interface for selecting a single folder or resource for which +existing passback credentials can be used to send scores to another Course +Management System (CMS). + =head1 DESCRIPTION This module provides routines to generate a hierarchical display of folders -and resources in a course which can be selected for specific actions. - -The choice of items is copied back to the main window from which the pop-up -window used to display the Course Contents was opened. +and resources in a course which can be selected for specific actions. In +all except one use case all items in the course are shown. The case where +only a filtered list is shown is passback of scores, and filtering limits +folders and resources to those items for which passback credentials exist, +(and their parent folders). + +When the display is shown in a pop-up window, The choice of items will be +copied back to the main window from which the pop-up window used to display +the Course Contents was opened. =head1 OVERVIEW -The main subroutine: &create_picker() will display the hierarchy of folders, -sub-folders, and resources in the Main Content area. Items can be selected -using checkboxes, and/or a "Check All" button. Selection of a folder -causes the contents of the folder to also be selected automatically. The -propagation of check status is recursive into sub-folders. Likewise, if an -item deep in a nested set of folders and sub-folders is unchecked, the -uncheck will propagate up through the hierarchy causing any folders at -a higher level to become unchecked. +In the cases where multiple items may be selected the main subroutine: +&create_picker() will display the hierarchy of folders, sub-folders, and +resources in the Main Content area. Items can be selected using checkboxes, +and/or a "Check All" button. Selection of a folder causes the contents of +the folder to also be selected automatically. The propagation of check +status is recursive into sub-folders. Likewise, if an item deep in a nested +set of folders and sub-folders is unchecked, the uncheck will propagate up +through the hierarchy causing any folders at a higher level to become +unchecked. + +In the case where only a single item may be selected the main subroutine: +&create_picker() will display the hierarchy of folders and sub-folders for +only those items for which passback credentials exist, There is a submit button, which will be named differently according to the context in which resource/folder selection is being made. -The four contexts currently supported are: IMS export, selection of +The five contexts currently supported are: IMS export, selection of content to be subject to access restructions for the duration of an -exam, selection of items for dumping to an Authoring Space, and -display or creation of shortened URLs for deep-linking, +exam, selection of items for dumping to an Authoring Space, display or +creation of shortened URLs for deep-linking, and selection of a single +item for apssback of grades to another CMS. =head1 INTERNAL SUBROUTINES =item &create_picker() -Created HTML mark up to display contents of course with checkboxes to +In the cases where multiple items may be selected ... + +Creates HTML markup to display contents of course with checkboxes to select items. Checking a folder causes recursive checking of items within the folder. Unchecking a resource causing unchecking of folders containing the item back up to the top level. -Inputs: 11. +In the case where only a single item may be selected ... + +Creates HTML markup to display filtered contents of course with radio +buttons to select an item. + +Inputs: 13. - $navmap -- Reference to LON-CAPA navmap object (encapsulates information about resources in the course). @@ -113,13 +135,23 @@ - $tiny -- Reference to hash: keys are symbs of course items for which shortened URLs have already been created. + - $passback -- Reference to hash: keys are symbs of course items for + which passback credentials exist. For each symb the + hash value is itself a hash of deeplink launch items + for that symb with inner hash key set to: + $linkuri\0$linkprotector\0$scope, and corresponding + value of 1. + - $readonly -- if true, no "check all" or "uncheck all" buttons will be displayed, and checkboxes will be disabled, if this - is for an exam block or for shortened URL creation. + is for an exam block or for shortened URL creation, + and radio buttons will be disabled, if this is for + passback of scores to another CMS, Output: $output is the HTML mark-up for display/selection of content - items in the pop-up window. + items, either in a pop-up window, or in the main window, + depending on context. =item &respicker_javascript() @@ -154,7 +186,8 @@ Inputs: 2. - $crstype -- Container type: Course or Community - - $context -- Context: imsexport, examblock, dumpdocs, or shorturls + - $context -- Context: imsexport, examblock, dumpdocs, shorturls + or passback. =item &clean() @@ -219,13 +252,15 @@ sub create_picker { my ($navmap,$context,$formname,$crstype,$blockedmaps,$blockedresources,$block,$preamble, - $numhome,$uploadedfiles,$tiny,$readonly) = @_; + $numhome,$uploadedfiles,$tiny,$passback,$readonly) = @_; return unless (ref($navmap)); my ($it,$output,$numdisc,%discussiontime,%currmaps,%currresources,%files, - %shorturls,$chkname); + %shorturls,%shownmaps,%shownsymbs,%recursed,%retrieved,%pb,$chkname); $chkname = 'archive'; if ($context eq 'shorturls') { $chkname = 'addtiny'; + } elsif ($context eq 'passback') { + $chkname = 'passback'; } $it = $navmap->getIterator(undef,undef,undef,1,undef,undef); if (ref($blockedmaps) eq 'HASH') { @@ -237,6 +272,51 @@ %files = %{$uploadedfiles}; } elsif (ref($tiny) eq 'HASH') { %shorturls = %{$tiny}; + } elsif ($context eq 'passback') { + if (ref($passback) eq 'HASH') { + %pb = %{$passback}; + foreach my $symb (keys(%pb)) { + my ($map,$id,$url) = &Apache::lonnet::decode_symb($symb); + my @recurseup; + if ($url =~ /\.(page|sequence)$/) { + @recurseup = $navmap->recurseup_maps($url); + $shownmaps{&Apache::lonnet::clutter($url)} = 1; + if (ref($pb{$symb}) eq 'HASH') { + foreach my $entry (keys(%{$pb{$symb}})) { + my $scope = (split("\0",$entry))[-1]; + if (($scope eq 'map') || ($scope eq 'rec')) { + my @contents; + if ($scope eq 'map') { + unless ($retrieved{$url} || $recursed{$url}) { + @contents = $navmap->retrieveResources($url,sub { $_[0]->is_gradable() },0); + $retrieved{$url} = 1; + } + } elsif ($scope eq 'rec') { + unless ($recursed{$url}) { + @contents = $navmap->retrieveResources($url,sub { $_[0]->is_gradable() },1,0,1); + my @subfolders = $navmap->retrieveResources($url,sub { $_[0]->is_map() },1,0,1); + if (@subfolders) { + map { $shownmaps{$_->src()} = 1; } @subfolders; + } + $recursed{$url} = 1; + } + } + if (@contents) { + map { $shownsymbs{$_->symb()} = 1; } @contents; + } + } + } + } + } else { + @recurseup = $navmap->recurseup_maps($map); + $shownmaps{&Apache::lonnet::clutter($map)} = 1; + $shownsymbs{$symb} = 1; + } + if (@recurseup) { + map { $shownmaps{&Apache::lonnet::clutter($_)} = 1; } @recurseup; + } + } + } } my @checked_maps; my $curRes; @@ -256,7 +336,7 @@ my $cnum = $env{'course.'.$env{'request.course.id'}.'.num'}; my $crsprefix = &propath($cdom,$cnum).'/userfiles/'; - my ($info,$display,$onsubmit,$togglebuttons,$disabled); + my ($info,$display,$onsubmit,$togglebuttons,$disabled,$action); if ($context eq 'examblock') { my $maps_elem = 'docs_maps_'.$block; my $res_elem = 'docs_resources_'.$block; @@ -279,16 +359,25 @@ } elsif ($context eq 'imsexport') { $info = &mt('Choose which items you wish to export from your '.$crstype.'.'); $startcount = 5; + } elsif ($context eq 'passback') { + $action = '/adm/grades'; + $info = '

    '. + &mt('Select link-protected launch item for which scores should be sent to launcher CMS, then push Next [_1].', + '→'). + '


    '; + if ($readonly) { + $disabled = ' disabled="disabled"'; + } } if ($disabled) { $togglebuttons = '
    '; - } else { + } elsif ($context ne 'passback') { $togglebuttons = ''. '  '; } - $display = ''."\n"; + $display = ''."\n"; if ($context eq 'imsexport') { $display .= $info. '
    '."\n". @@ -309,7 +398,7 @@ ''; } $display .= '
    '; - } elsif (($context eq 'examblock') || ($context eq 'shorturls')) { + } elsif (($context eq 'examblock') || ($context eq 'shorturls') || ($context eq 'passback')) { $display .= $info.$togglebuttons; } elsif ($context eq 'dumpdocs') { $display .= $preamble. @@ -336,6 +425,12 @@ } elsif ($context eq 'shorturls') { $display .= ''.&mt('Tiny URL').''. ''.&mt("Title in $crstype").''; + } elsif ($context eq 'passback') { + $display .= ''.&mt("Title in $crstype").''. + ''.&mt('Tiny URL Deep-link').''. + ''.&mt('Launcher').''. + ''.&mt('Score Type').''. + ''.&mt('Select').''; } $display .= &Apache::loncommon::end_data_table_header_row(); while ($curRes = $it->next()) { @@ -361,18 +456,26 @@ } } $count ++; - my ($currelem,$mapurl,$is_map); + my ($currelem,$mapurl,$is_map,$showitem); if ($context eq 'imsexport') { $currelem = $count+$boards+$startcount; } else { $currelem = $count+$startcount; } - $display .= &Apache::loncommon::start_data_table_row()."\n"; if (($curRes->is_sequence()) || ($curRes->is_page())) { $lastcontainer = $currelem; $mapurl = (&Apache::lonnet::decode_symb($symb))[2]; $is_map = 1; } + if ($context eq 'passback') { + if (($curRes->is_sequence()) || ($curRes->is_page())) { + next unless ($shownmaps{$curRes->src}); + } else { + next unless ($shownsymbs{$symb}); + } + } else { + $display .= &Apache::loncommon::start_data_table_row()."\n"; + } if ($context eq 'shorturls') { if ($shorturls{$symb}) { $display .= ' '."/tiny/$cdom/$shorturls{$symb}".''."\n"; @@ -381,7 +484,7 @@ 'value="'.$count.'"'.$disabled.' />'.&mt('Add').''. ' '."\n"; } - } else { + } elsif ($context ne 'passback') { $display .= 'is_problem()) { - $numprobs ++; + $numprobs ++; } $display .= 'onclick="javascript:checkResource(document.'.$formname.','."'$currelem'".')" '; if ($currresources{$symb}) { @@ -406,7 +509,7 @@ $display .= ''; } for (my $i=0; $i<$depth; $i++) { - $display .= "$whitespace\n"; + $showitem .= "$whitespace\n"; } my $icon = 'src="'.$location.'/unknown.gif" alt=""'; if ($curRes->is_sequence()) { @@ -420,7 +523,7 @@ } elsif ($curRes->src ne '') { $icon = 'src="'.&Apache::loncommon::icon($curRes->src).'" alt=""'; } - $display .= ' '."\n"; + $showitem .= ' '."\n"; $children{$parent{$depth}} .= $currelem.':'; if ($context eq 'examblock') { if ($parent{$depth} > 1) { @@ -431,8 +534,65 @@ } } } - $display .= ' '.$curRes->title().$whitespace.''."\n"; - + $showitem .= ' '.$curRes->title().$whitespace; + if ($context eq 'passback') { + if ((exists($pb{$symb})) && (ref($pb{$symb}) eq 'HASH')) { + my $numlinks = scalar(keys(%{$pb{$symb}})); + my $count = 0; + foreach my $launcher (sort(keys(%{$pb{$symb}}))) { + if ($count == 0) { + $display .= &Apache::loncommon::start_data_table_row()."\n"; + if ($numlinks > 1) { + $display .= ''.$showitem.''; + } else { + $display .= ''.$showitem.''; + } + } else { + $display .= &Apache::loncommon::end_data_table_row(). + &Apache::loncommon::start_data_table_row()."\n"; + } + my ($linkuri,$linkprotector,$scope) = split("\0",$launcher); + 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)'; + } + } + } + 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'); + } + $display .= ''.$linkuri.''."\n". + ''.$appname.$setter.''."\n". + ''.$shownscope.''."\n". + ''."\n"; + $count ++; + } + } else { + $display .= &Apache::loncommon::start_data_table_row()."\n". + ''.$showitem.''; + } + } else { + $display .= $showitem.''."\n"; + } if ($context eq 'imsexport') { # Existing discussion posts? if ($discussiontime{$ressymb} > 0) { @@ -519,8 +679,20 @@ '

    '; } + } elsif ($context eq 'passback') { + unless ($readonly) { + $display .= + '

    '. + ''."\n". + ''. + '

    '; + } } $display .= '
    '; + if ($context eq 'passback') { + return $display; + } my $scripttag = &respicker_javascript($startcount,$numcount,$context,$formname,\%children, \%hierarchy,\@checked_maps,$numhome,$chkname); Index: doc/loncapafiles/loncapafiles.lpml diff -u doc/loncapafiles/loncapafiles.lpml:1.1073 doc/loncapafiles/loncapafiles.lpml:1.1074 --- doc/loncapafiles/loncapafiles.lpml:1.1073 Fri Nov 15 16:25:30 2024 +++ doc/loncapafiles/loncapafiles.lpml Tue Dec 3 23:34:12 2024 @@ -2,7 +2,7 @@ "http://lpml.sourceforge.net/DTD/lpml.dtd"> - +