--- loncom/homework/grades.pm 2010/04/27 00:06:34 1.632
+++ loncom/homework/grades.pm 2011/09/17 19:01:20 1.650
@@ -1,7 +1,7 @@
# The LearningOnline Network with CAPA
# The LON-CAPA Grading handler
#
-# $Id: grades.pm,v 1.632 2010/04/27 00:06:34 www Exp $
+# $Id: grades.pm,v 1.650 2011/09/17 19:01:20 raeburn Exp $
#
# Copyright Michigan State University Board of Trustees
#
@@ -40,10 +40,11 @@ use Apache::lonhomework;
use Apache::lonpickcode;
use Apache::loncoursedata;
use Apache::lonmsg();
-use Apache::Constants qw(:common);
+use Apache::Constants qw(:common :http);
use Apache::lonlocal;
use Apache::lonenc;
use Apache::lonstathelpers;
+use Apache::lonquickgrades;
use String::Similarity;
use LONCAPA;
@@ -212,8 +213,13 @@ sub reset_caches {
}
sub get_analyze {
- my ($symb,$uname,$udom,$no_increment,$add_to_hash)=@_;
+ my ($symb,$uname,$udom,$no_increment,$add_to_hash,$type,$trial,$rndseed,$bubbles_per_row)=@_;
my $key = "$symb\0$uname\0$udom";
+ if ($type eq 'randomizetry') {
+ if ($trial ne '') {
+ $key .= "\0".$trial;
+ }
+ }
if (exists($analyze_cache{$key})) {
my $getupdate = 0;
if (ref($add_to_hash) eq 'HASH') {
@@ -241,9 +247,18 @@ sub reset_caches {
'grade_courseid' => $env{'request.course.id'},
'grade_username' => $uname,
'grade_noincrement' => $no_increment);
+ if ($bubbles_per_row ne '') {
+ $form{'bubbles_per_row'} = $bubbles_per_row;
+ }
+ if ($type eq 'randomizetry') {
+ $form{'grade_questiontype'} = $type;
+ if ($rndseed ne '') {
+ $form{'grade_rndseed'} = $rndseed;
+ }
+ }
if (ref($add_to_hash)) {
%form = (%form,%{$add_to_hash});
- }
+ }
my $subresult=&ssi_with_retries($url, $ssi_retries,%form);
(undef,$subresult)=split(/_HASH_REF__/,$subresult,2);
my %analyze=&Apache::lonnet::str2hash($subresult);
@@ -256,15 +271,15 @@ sub reset_caches {
}
sub get_order {
- my ($partid,$respid,$symb,$uname,$udom,$no_increment)=@_;
- my $analyze = &get_analyze($symb,$uname,$udom,$no_increment);
+ my ($partid,$respid,$symb,$uname,$udom,$no_increment,$type,$trial,$rndseed)=@_;
+ my $analyze = &get_analyze($symb,$uname,$udom,$no_increment,undef,$type,$trial,$rndseed);
return $analyze->{"$partid.$respid.shown"};
}
sub get_radiobutton_correct_foil {
- my ($partid,$respid,$symb,$uname,$udom)=@_;
- my $analyze = &get_analyze($symb,$uname,$udom);
- my $foils = &get_order($partid,$respid,$symb,$uname,$udom);
+ my ($partid,$respid,$symb,$uname,$udom,$type,$trial,$rndseed)=@_;
+ my $analyze = &get_analyze($symb,$uname,$udom,undef,undef,$type,$trial,$rndseed);
+ my $foils = &get_order($partid,$respid,$symb,$uname,$udom,undef,$type,$trial,$rndseed);
if (ref($foils) eq 'ARRAY') {
foreach my $foil (@{$foils}) {
if ($analyze->{"$partid.$respid.foil.value.$foil"} eq 'true') {
@@ -275,7 +290,7 @@ sub reset_caches {
}
sub scantron_partids_tograde {
- my ($resource,$cid,$uname,$udom,$check_for_randomlist) = @_;
+ my ($resource,$cid,$uname,$udom,$check_for_randomlist,$bubbles_per_row) = @_;
my (%analysis,@parts);
if (ref($resource)) {
my $symb = $resource->symb();
@@ -283,7 +298,9 @@ sub reset_caches {
if ($check_for_randomlist) {
$add_to_form = { 'check_parts_withrandomlist' => 1,};
}
- my $analyze = &get_analyze($symb,$uname,$udom,undef,$add_to_form);
+ my $analyze =
+ &get_analyze($symb,$uname,$udom,undef,$add_to_form,
+ undef,undef,undef,$bubbles_per_row);
if (ref($analyze) eq 'HASH') {
%analysis = %{$analyze};
}
@@ -306,7 +323,7 @@ sub reset_caches {
# response types only.
sub cleanRecord {
my ($answer,$response,$symb,$partid,$respid,$record,$order,$version,
- $uname,$udom) = @_;
+ $uname,$udom,$type,$trial,$rndseed) = @_;
my $grayFont = '';
if ($response =~ /^(option|rank)$/) {
my %answer=&Apache::lonnet::str2hash($answer);
@@ -350,7 +367,7 @@ sub cleanRecord {
my %answer=&Apache::lonnet::str2hash($answer);
my ($toprow,$bottomrow);
my $correct =
- &get_radiobutton_correct_foil($partid,$respid,$symb,$uname,$udom);
+ &get_radiobutton_correct_foil($partid,$respid,$symb,$uname,$udom,$type,$trial,$rndseed);
foreach my $foil (@$order) {
if (exists($answer{$foil})) {
if ($foil eq $correct) {
@@ -1267,12 +1284,6 @@ sub sub_page_js {
}
}
- if (val == "Grade Student") {
- if (formname.Status.value == "") {
- formname.Status.value = "Active";
- }
- formname.studentNo.value = total;
- }
formname.submit();
}
@@ -1506,7 +1517,7 @@ INNERJS
var ypos = (screen.height-height)/2-30;
ypos = (ypos < 0) ? '0' : ypos;
- pWin = window.open('', 'MessageCenter', 'resizable=yes,toolbar=no,location=no,scrollbars='+scrollbar+',screenx='+xpos+',screeny='+ypos+',width=600,height='+height);
+ pWin = window.open('', 'MessageCenter', 'resizable=yes,toolbar=no,location=no,scrollbars='+scrollbar+',screenx='+xpos+',screeny='+ypos+',width=700,height='+height);
pWin.focus();
pDoc = pWin.document;
pDoc.$docopen;
@@ -2138,6 +2149,12 @@ KEYWORDS
my ($ressub,$hide,$subval) = split(/:/,$submission,3);
# Similarity check
my $similar='';
+ my ($type,$trial,$rndseed);
+ if ($hide eq 'rand') {
+ $type = 'randomizetry';
+ $trial = $record{"resource.$partid.tries"};
+ $rndseed = $record{"resource.$partid.rndseed"};
+ }
if($env{'form.checkPlag'}){
my ($oname,$odom,$ocrsid,$oessay,$osim)=
&most_similar($uname,$udom,$subval,\%old_essays);
@@ -2147,7 +2164,7 @@ KEYWORDS
&Apache::lonnet::coursedescription($ocrsid,
{'one_time' => 1});
- if ($hide) {
+ if ($hide eq 'anon') {
$similar='
'.&mt("Essay was found to be similar to another essay submitted for this assignment.").'
'.
&mt('As the current submission is for an anonymous survey, no other details are available.').'
';
} else {
@@ -2164,7 +2181,8 @@ KEYWORDS
}
}
}
- my $order=&get_order($partid,$respid,$symb,$uname,$udom);
+ my $order=&get_order($partid,$respid,$symb,$uname,$udom,
+ undef,$type,$trial,$rndseed);
if ($env{'form.lastSub'} eq 'lastonly' ||
($env{'form.lastSub'} eq 'hdgrade' &&
$$handgrade{$$part[0].'_'.$$part[1]} eq 'yes')) {
@@ -2176,7 +2194,7 @@ KEYWORDS
' ';
my $files=&get_submitted_files($udom,$uname,$partid,$respid,\%record);
if (@$files) {
- if ($hide) {
+ if ($hide eq 'anon') {
$lastsubonly.='
'.&mt('[quant,_1,file] uploaded to this anonymous survey',scalar(@{$files}));
} else {
$lastsubonly.='
'.&mt('Like all files provided by users, this file may contain viruses').'
';
@@ -2187,12 +2205,12 @@ KEYWORDS
}
$lastsubonly.='
';
}
- if ($hide) {
+ if ($hide eq 'anon') {
$lastsubonly.=''.&mt('Anonymous Survey').'';
} else {
$lastsubonly.=''.&mt('Submitted Answer:').' '.
&cleanRecord($subval,$responsetype,$symb,$partid,
- $respid,\%record,$order,undef,$uname,$udom);
+ $respid,\%record,$order,undef,$uname,$udom,$type,$trial,$rndseed);
}
if ($similar) {$lastsubonly.="
$similar\n";}
$lastsubonly.='';
@@ -2214,13 +2232,8 @@ KEYWORDS
$request->print(''."\n");
# return if view submission with no grading option
-# FIXME: the logic seems off here. Why show the grade button if you cannot grade?
if (!&canmodify($usec)) {
- my $toGrade.=' '."\n" if (&canmodify($usec));
- $toGrade.=''."\n";
- $request->print($toGrade);
+ $request->print('
'.&mt('No grading privileges').'
'); return; } else { $request->print(''."\n"); @@ -2396,35 +2409,51 @@ sub get_last_submission { &Apache::lonlocal::locallocaltime($$returnhash{$version.':timestamp'}); } } - my %typeparts; + my (%typeparts,%randombytry); my $showsurv = &Apache::lonnet::allowed('vas',$env{'request.course.id'}); foreach my $key (sort(keys(%lasthash))) { if ($key =~ /\.type$/) { if (($lasthash{$key} eq 'anonsurvey') || - ($lasthash{$key} eq 'anonsurveycred')) { + ($lasthash{$key} eq 'anonsurveycred') || + ($lasthash{$key} eq 'randomizetry')) { my ($ign,@parts) = split(/\./,$key); pop(@parts); - unless ($showsurv) { - my $id = join(',',@parts); - $typeparts{$ign.'.'.$id} = $lasthash{$key}; + my $id = join('.',@parts); + if ($lasthash{$key} eq 'randomizetry') { + $randombytry{$ign.'.'.$id} = $lasthash{$key}; + } else { + unless ($showsurv) { + $typeparts{$ign.'.'.$id} = $lasthash{$key}; + } } delete($lasthash{$key}); } } } my @hidden = keys(%typeparts); + my @randomize = keys(%randombytry); foreach my $key (keys(%lasthash)) { next if ($key !~ /\.submission$/); my $hide; if (@hidden) { foreach my $id (@hidden) { if ($key =~ /^\Q$id\E/) { - $hide = 1; + $hide = 'anon'; last; } } } + unless ($hide) { + if (@randomize) { + foreach my $id (@hidden) { + if ($key =~ /^\Q$id\E/) { + $hide = 'rand'; + last; + } + } + } + } my ($partid,$foo) = split(/submission$/,$key); my $draft = $lasthash{$partid.'awarddetail'} eq 'DRAFT' ? 'Draft Copy ' : ''; @@ -2593,16 +2622,6 @@ sub processHandGrade { return ''; } -# Go directly to grade student - from submission or link from chart page -# FIXME: looks like reading off the button label! - if ($button eq 'Grade Student') { - my $processUser = $env{'form.unamedom'.$env{'form.studentNo'}}; - ($env{'form.student'},$env{'form.userdom'}) = split(/:/,$processUser); - $env{'form.fullname'} = $$fullname{$processUser}; - &submission($request,0,0,$symb); - return ''; - } - # Get the next/previous one or group of students my $firststu = $env{'form.unamedom0'}; my $laststu = $env{'form.unamedom'.($ngrade-1)}; @@ -4078,6 +4097,9 @@ sub csvuploadassign { my $pcr=$entries{$fields{$dest}} / $wgt; my $award=($pcr == 0) ? 'incorrect_by_override' : 'correct_by_override'; + if ($pcr>1) { + push(@skipped,&mt("[_1]: point value larger than weight","$username:$domain")); + } $grades{"resource.$part.awarded"}=$pcr; $grades{"resource.$part.solved"}=$award; $points{$part}=1; @@ -4383,8 +4405,8 @@ sub displayPage { &Apache::loncommon::start_data_table_row(). ''.&mt('If this information is correct, please click on \'[_1]\'.',&mt($button_text)).'
-'.&mt('If something is incorrect, please click the \'Grading Menu\' button to start over.').'
+ '.&mt('If this information is correct, please click on \'[_1]\'.',&mt($button_text)).'
+'.&mt('If something is incorrect, please return to [_1]Grade/Manage/Review Bubblesheets[_2] to start over.','','').'
".&mt('You have forgetten to specify some information. Please go Back and try again.')."
"); + $r->print("".&mt('You have forgotten to specify some information. Please go Back and try again.')."
"); if ( $env{'form.selectpage'} eq '') { $r->print(''.&mt('You have not selected a Sequence to grade').'
'); } if ( $env{'form.scantron_selectfile'} eq '') { - $r->print(''.&mt('You have not selected a file that contains the student\'s response data.').'
'); + $r->print(''.&mt("You have not selected a file that contains the student's response data.").'
'); } if ( $env{'form.scantron_format'} eq '') { - $r->print(''.&mt('You have not selected a the format of the student\'s response data.').'
'); + $r->print(''.&mt("You have not selected the format of the student's response data.").'
'); } } else { - my $warning=&scantron_warning_screen('Grading: Validate Records'); + my $warning=&scantron_warning_screen('Grading: Validate Records',$symb); $r->print(' '.$warning.' @@ -6272,7 +6307,8 @@ sub scantron_validate_file { #get the student pick code ready $r->print(&Apache::loncommon::studentbrowser_javascript()); my $nav_error; - my $max_bubble=&scantron_get_maxbubble(\$nav_error); + my %scantron_config=&get_scantron_config($env{'form.scantron_format'}); + my $max_bubble=&scantron_get_maxbubble(\$nav_error,\%scantron_config); if ($nav_error) { $r->print(&navmap_errormsg()); return ''; @@ -6302,7 +6338,7 @@ sub scantron_validate_file { } } if (!$stop) { - my $warning=&scantron_warning_screen('Start Grading'); + my $warning=&scantron_warning_screen('Start Grading',$symb); $r->print(&mt('Validation process complete.').'".&mt("Or click the 'Grading Menu' button to start over.")."
"); + $r->print(''.&mt('Or return to [_1]Grade/Manage/Review Bubblesheets[_2] to start over.','','').'
'); } else { if ($validate_phases[$currentphase] eq 'doublebubble' || $validate_phases[$currentphase] eq 'missingbubbles') { $r->print(''); @@ -6725,7 +6761,7 @@ sub scantron_validate_ID { my ($scanlines,$scan_data)=&scantron_getfile(); my $nav_error; - &scantron_get_maxbubble(\$nav_error); # parse needs the bubble_lines.. array. + &scantron_get_maxbubble(\$nav_error,\%scantron_config); # parse needs the bubble_lines.. array. if ($nav_error) { $r->print(&navmap_errormsg()); return(1,$currentphase); @@ -7124,7 +7160,19 @@ sub scantron_bubble_selector { my $max=$$scan_config{'Qlength'}; my $scmode=$$scan_config{'Qon'}; - if ($scmode eq 'number' || $scmode eq 'letter') { $max=10; } + if ($scmode eq 'number' || $scmode eq 'letter') { + if (($$scan_config{'BubblesPerRow'} =~ /^\d+$/) && + ($$scan_config{'BubblesPerRow'} > 0)) { + $max=$$scan_config{'BubblesPerRow'}; + if (($scmode eq 'number') && ($max > 10)) { + $max = 10; + } elsif (($scmode eq 'letter') && $max > 26) { + $max = 26; + } + } else { + $max = 10; + } + } my @alphabet=('A'..'Z'); $r->print(&Apache::loncommon::start_data_table(). @@ -7279,7 +7327,7 @@ sub scantron_validate_CODE { my %allcodes=&get_codes(); my $nav_error; - &scantron_get_maxbubble(\$nav_error); # parse needs the lines per response array. + &scantron_get_maxbubble(\$nav_error,\%scantron_config); # parse needs the lines per response array. if ($nav_error) { $r->print(&navmap_errormsg()); return(1,$currentphase); @@ -7338,7 +7386,7 @@ sub scantron_validate_doublebubble { my %scantron_config=&get_scantron_config($env{'form.scantron_format'}); my ($scanlines,$scan_data)=&scantron_getfile(); my $nav_error; - &scantron_get_maxbubble(\$nav_error); # parse needs the bubble line array. + &scantron_get_maxbubble(\$nav_error,\%scantron_config); # parse needs the bubble line array. if ($nav_error) { $r->print(&navmap_errormsg()); return(1,$currentphase); @@ -7360,7 +7408,7 @@ sub scantron_validate_doublebubble { sub scantron_get_maxbubble { - my ($nav_error) = @_; + my ($nav_error,$scantron_config) = @_; if (defined($env{'form.scantron_maxbubble'}) && $env{'form.scantron_maxbubble'}) { &restore_bubble_lines(); @@ -7379,6 +7427,7 @@ sub scantron_get_maxbubble { } my $map=$navmap->getResourceByUrl($sequence); my @resources=$navmap->retrieveResources($map,\&scantron_filter,1,0); + my $bubbles_per_row = &bubblesheet_bubbles_per_row($scantron_config); &Apache::lonxml::clear_problem_counter(); @@ -7394,7 +7443,7 @@ sub scantron_get_maxbubble { my $response_number = 0; my $bubble_line = 0; foreach my $resource (@resources) { - my ($analysis,$parts) = &scantron_partids_tograde($resource,$cid,$uname,$udom); + my ($analysis,$parts) = &scantron_partids_tograde($resource,$cid,$uname,$udom,undef,$bubbles_per_row); if ((ref($analysis) eq 'HASH') && (ref($parts) eq 'ARRAY')) { foreach my $part_id (@{$parts}) { my $lines; @@ -7423,9 +7472,10 @@ sub scantron_get_maxbubble { if (ref($analysis->{$part_id.'.shown'}) eq 'ARRAY') { $numshown = scalar(@{$analysis->{$part_id.'.shown'}}); } - my $bubbles_per_line = 10; - my $inner_bubble_lines = int($numbub/$bubbles_per_line); - if (($numbub % $bubbles_per_line) != 0) { + my $bubbles_per_row = + &bubblesheet_bubbles_per_row($scantron_config); + my $inner_bubble_lines = int($numbub/$bubbles_per_row); + if (($numbub % $bubbles_per_row) != 0) { $inner_bubble_lines++; } for (my $i=0; $i<$numshown; $i++) { @@ -7436,7 +7486,7 @@ sub scantron_get_maxbubble { $lines = $numshown * $inner_bubble_lines; } else { $lines = $analysis->{"$part_id.bubble_lines"}; - } + } $first_bubble_line{$response_number} = $bubble_line; $bubble_lines_per_response{$response_number} = $lines; @@ -7457,6 +7507,18 @@ sub scantron_get_maxbubble { return $env{'form.scantron_maxbubble'}; } +sub bubblesheet_bubbles_per_row { + my ($scantron_config) = @_; + my $bubbles_per_row; + if (ref($scantron_config) eq 'HASH') { + $bubbles_per_row = $scantron_config->{'BubblesPerRow'}; + } + if ((!$bubbles_per_row) || ($bubbles_per_row < 1)) { + $bubbles_per_row = 10; + } + return $bubbles_per_row; +} + sub scantron_validate_missingbubbles { my ($r,$currentphase) = @_; #get student info @@ -7467,7 +7529,7 @@ sub scantron_validate_missingbubbles { my %scantron_config=&get_scantron_config($env{'form.scantron_format'}); my ($scanlines,$scan_data)=&scantron_getfile(); my $nav_error; - my $max_bubble=&scantron_get_maxbubble(\$nav_error); + my $max_bubble=&scantron_get_maxbubble(\$nav_error,\%scantron_config); if ($nav_error) { return(1,$currentphase); } @@ -7525,6 +7587,8 @@ sub scantron_process_students { my $default_form_data=&defaultFormData($symb); my %scantron_config=&get_scantron_config($env{'form.scantron_format'}); + my $bubbles_per_row = + &bubblesheet_bubbles_per_row(\%scantron_config); my ($scanlines,$scan_data)=&scantron_getfile(); my $classlist=&Apache::loncoursedata::get_classlist(); my %idmap=&username_to_idmap($classlist); @@ -7537,7 +7601,7 @@ sub scantron_process_students { my @resources=$navmap->retrieveResources($map,\&scantron_filter,1,0); my (%grader_partids_by_symb,%grader_randomlists_by_symb); &graders_resources_pass(\@resources,\%grader_partids_by_symb, - \%grader_randomlists_by_symb); + \%grader_randomlists_by_symb,$bubbles_per_row); my $resource_error; foreach my $resource (@resources) { my $ressymb; @@ -7549,7 +7613,7 @@ sub scantron_process_students { } my ($analysis,$parts) = &scantron_partids_tograde($resource,$env{'request.course.id'}, - $env{'user.name'},$env{'user.domain'},1); + $env{'user.name'},$env{'user.domain'},1,$bubbles_per_row); $grader_partids_by_symb{$ressymb} = $parts; if (ref($analysis) eq 'HASH') { if (ref($analysis->{'parts_withrandomlist'}) eq 'ARRAY') { @@ -7587,7 +7651,7 @@ SCANTRONFORM my $started; my $nav_error; - &scantron_get_maxbubble(\$nav_error); # Need the bubble lines array to parse. + &scantron_get_maxbubble(\$nav_error,\%scantron_config); # Need the bubble lines array to parse. if ($nav_error) { $r->print(&navmap_errormsg()); return ''; @@ -7643,7 +7707,7 @@ SCANTRONFORM if ((exists($grader_randomlists_by_symb{$ressymb})) || (ref($grader_partids_by_symb{$ressymb}) ne 'ARRAY')) { my ($analysis,$parts) = - &scantron_partids_tograde($resource,$env{'request.course.id'},$uname,$udom); + &scantron_partids_tograde($resource,$env{'request.course.id'},$uname,$udom,undef,$bubbles_per_row); $partids_by_symb{$ressymb} = $parts; } else { $partids_by_symb{$ressymb} = $grader_partids_by_symb{$ressymb}; @@ -7672,7 +7736,8 @@ SCANTRONFORM } if (&grade_student_bubbles($r,$uname,$udom,$scan_record,$scancode, - \@resources,\%partids_by_symb) eq 'ssi_error') { + \@resources,\%partids_by_symb, + $bubbles_per_row) eq 'ssi_error') { $ssi_error = 0; # So end of handler error message does not trigger. $r->print(""); &ssi_print_error($r); @@ -7699,7 +7764,8 @@ SCANTRONFORM if ($studentrecord ne $studentdata) { &Apache::lonxml::clear_problem_counter(); if (&grade_student_bubbles($r,$uname,$udom,$scan_record,$scancode, - \@resources,\%partids_by_symb) eq 'ssi_error') { + \@resources,\%partids_by_symb, + $bubbles_per_row) eq 'ssi_error') { $ssi_error = 0; # So end of handler error message does not trigger. $r->print(""); &ssi_print_error($r); @@ -7762,14 +7828,15 @@ SCANTRONFORM } sub graders_resources_pass { - my ($resources,$grader_partids_by_symb,$grader_randomlists_by_symb) = @_; + my ($resources,$grader_partids_by_symb,$grader_randomlists_by_symb, + $bubbles_per_row) = @_; if ((ref($resources) eq 'ARRAY') && (ref($grader_partids_by_symb)) && (ref($grader_randomlists_by_symb) eq 'HASH')) { foreach my $resource (@{$resources}) { my $ressymb = $resource->symb(); my ($analysis,$parts) = &scantron_partids_tograde($resource,$env{'request.course.id'}, - $env{'user.name'},$env{'user.domain'},1); + $env{'user.name'},$env{'user.domain'},1,$bubbles_per_row); $grader_partids_by_symb->{$ressymb} = $parts; if (ref($analysis) eq 'HASH') { if (ref($analysis->{'parts_withrandomlist'}) eq 'ARRAY') { @@ -7783,7 +7850,8 @@ sub graders_resources_pass { } sub grade_student_bubbles { - my ($r,$uname,$udom,$scan_record,$scancode,$resources,$parts) = @_; + my ($r,$uname,$udom,$scan_record,$scancode,$resources,$parts,$bubbles_per_row) = @_; +# Walk folder as student here to get resources in order student sees. if (ref($resources) eq 'ARRAY') { my $count = 0; foreach my $resource (@{$resources}) { @@ -7796,6 +7864,9 @@ sub grade_student_bubbles { 'grade_symb' => $ressymb, 'CODE' => $scancode ); + if ($bubbles_per_row ne '') { + $form{'bubbles_per_row'} = $bubbles_per_row; + } if (ref($parts) eq 'HASH') { if (ref($parts->{$ressymb}) eq 'ARRAY') { foreach my $part (@{$parts->{$ressymb}}) { @@ -7855,7 +7926,7 @@ sub scantron_upload_scantron_data { ')); $r->print(' -