Parent Directory
|
Revision Log
Adjusted the headers for the dates
1 ################################################################################ 2 # WeBWorK Online Homework Delivery System 3 # Copyright © 2000-2003 The WeBWorK Project, http://openwebwork.sf.net/ 4 # 5 # 6 # This program is free software; you can redistribute it and/or modify it under 7 # the terms of either: (a) the GNU General Public License as published by the 8 # Free Software Foundation; either version 2, or (at your option) any later 9 # version, or (b) the "Artistic License" which comes with this package. 10 # 11 # This program is distributed in the hope that it will be useful, but WITHOUT 12 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 13 # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the 14 # Artistic License for more details. 15 ################################################################################ 16 17 package WeBWorK::ContentGenerator::Instructor::ProblemSetDetail; 18 use base qw(WeBWorK::ContentGenerator::Instructor); 19 20 =head1 NAME 21 22 WeBWorK::ContentGenerator::Instructor::ProblemSetDetail - Edit general set and specific user/set information as well as problem information 23 24 =cut 25 26 use strict; 27 use warnings; 28 use CGI qw(); 29 use WeBWorK::HTML::ComboBox qw/comboBox/; 30 use WeBWorK::Utils qw(readDirectory list2hash listFilesRecursive max); 31 use WeBWorK::DB::Record::Set; 32 use WeBWorK::Utils::Tasks qw(renderProblems); 33 use WeBWorK::Debug; 34 35 # Important Note: the following two sets of constants may seem similar 36 # but they are functionally and semantically different 37 38 # these constants determine which fields belong to what type of record 39 use constant SET_FIELDS => [qw(set_header hardcopy_header open_date due_date answer_date published)]; 40 use constant PROBLEM_FIELDS =>[qw(source_file value max_attempts)]; 41 use constant USER_PROBLEM_FIELDS => [qw(problem_seed status num_correct num_incorrect)]; 42 43 # these constants determine what order those fields should be displayed in 44 use constant HEADER_ORDER => [qw(set_header hardcopy_header)]; 45 use constant PROBLEM_FIELD_ORDER => [qw(problem_seed status value max_attempts attempted last_answer num_correct num_incorrect)]; 46 use constant SET_FIELD_ORDER => [qw(open_date due_date answer_date published)]; 47 48 # this constant is massive hash of information corresponding to each db field. 49 # override indicates for how many students at a time a field can be overridden 50 # this hash should make it possible to NEVER have explicitly: if (somefield) { blah() } 51 # 52 # All but name are optional 53 # some_field => { 54 # name => "Some Field", 55 # type => "edit", # edit, choose, hidden, view - defines how the data is displayed 56 # size => "50", # size of the edit box (if any) 57 # override => "none", # none, one, any, all - defines for whom this data can/must be overidden 58 # module => "problem_list", # WeBWorK module 59 # default => 0 # if a field cannot default to undefined/empty what should it default to 60 # labels => { # special values can be hashed to display labels 61 # 1 => "Yes", 62 # 0 => "No", 63 # }, 64 use constant FIELD_PROPERTIES => { 65 # Set information 66 set_header => { 67 name => "Set Header", 68 type => "edit", 69 size => "50", 70 override => "all", 71 module => "problem_list", 72 default => "", 73 }, 74 hardcopy_header => { 75 name => "Hardcopy Header", 76 type => "edit", 77 size => "50", 78 override => "all", 79 module => "hardcopy_preselect_set", 80 default => "", 81 }, 82 open_date => { 83 name => "Opens", 84 type => "edit", 85 size => "26", 86 override => "any", 87 labels => { 88 0 => "None Specified", 89 "" => "None Specified", 90 }, 91 }, 92 due_date => { 93 name => "Answers Due", 94 type => "edit", 95 size => "26", 96 override => "any", 97 labels => { 98 0 => "None Specified", 99 "" => "None Specified", 100 }, 101 }, 102 answer_date => { 103 name => "Answers Available", 104 type => "edit", 105 size => "26", 106 override => "any", 107 labels => { 108 0 => "None Specified", 109 "" => "None Specified", 110 }, 111 }, 112 published => { 113 name => "Visible to Students", 114 type => "choose", 115 override => "all", 116 choices => [qw( 0 1 )], 117 labels => { 118 1 => "Yes", 119 0 => "No", 120 }, 121 }, 122 # Problem information 123 source_file => { 124 name => "Source File", 125 type => "edit", 126 size => 50, 127 override => "any", 128 default => "", 129 }, 130 value => { 131 name => "Weight", 132 type => "edit", 133 size => 6, 134 override => "any", 135 }, 136 max_attempts => { 137 name => "Max attempts", 138 type => "edit", 139 size => 6, 140 override => "any", 141 labels => { 142 "-1" => "unlimited", 143 }, 144 }, 145 problem_seed => { 146 name => "Seed", 147 type => "edit", 148 size => 6, 149 override => "one", 150 151 }, 152 status => { 153 name => "Status", 154 type => "edit", 155 size => 6, 156 override => "one", 157 default => 0, 158 }, 159 attempted => { 160 name => "Attempted", 161 type => "hidden", 162 override => "none", 163 choices => [qw( 0 1 )], 164 labels => { 165 1 => "Yes", 166 0 => "No", 167 }, 168 default => 0, 169 }, 170 last_answer => { 171 name => "Last Answer", 172 type => "hidden", 173 override => "none", 174 }, 175 num_correct => { 176 name => "Correct", 177 type => "hidden", 178 override => "none", 179 default => 0, 180 }, 181 num_incorrect => { 182 name => "Incorrect", 183 type => "hidden", 184 override => "none", 185 default => 0, 186 }, 187 }; 188 189 # Create a table of fields for the given parameters, one row for each db field 190 # if only the setID is included, it creates a table of set information 191 # if the problemID is included, it creates a table of problem information 192 sub FieldTable { 193 my ($self, $userID, $setID, $problemID, $globalRecord, $userRecord) = @_; 194 195 my $r = $self->r; 196 my @editForUser = $r->param('editForUser'); 197 my $forUsers = scalar(@editForUser); 198 my $forOneUser = $forUsers == 1; 199 200 my @fieldOrder; 201 if (defined $problemID) { 202 @fieldOrder = @{ PROBLEM_FIELD_ORDER() }; 203 } else { 204 @fieldOrder = @{ SET_FIELD_ORDER() }; 205 } 206 207 my $output = CGI::start_table({border => 0, cellpadding => 1}); 208 if ($forUsers) { 209 $output .= CGI::Tr( 210 CGI::th({colspan=>"2"}, " "), 211 CGI::th({colspan=>"1"}, "Individual set dates"), 212 CGI::th({}, "Class set dates"), 213 ); 214 } 215 216 foreach my $field (@fieldOrder) { 217 my %properties = %{ FIELD_PROPERTIES()->{$field} }; 218 unless ($properties{type} eq "hidden") { 219 $output .= CGI::Tr({}, CGI::td({}, [$self->FieldHTML($userID, $setID, $problemID, $globalRecord, $userRecord, $field)])); 220 } 221 } 222 223 if (defined $problemID) { 224 #my $problemRecord = $r->{db}->getUserProblem($userID, $setID, $problemID); 225 my $problemRecord = $userRecord; # we get this from the caller, hopefully 226 $output .= CGI::Tr({}, CGI::td({}, ["","Attempts", ($problemRecord->num_correct || 0) + ($problemRecord->num_incorrect || 0)])) if $forOneUser; 227 } 228 $output .= CGI::end_table(); 229 230 return $output; 231 } 232 233 # Returns a list of information and HTML widgets 234 # for viewing and editing the specified db fields 235 # if only the setID is included, it creates a list of set information 236 # if the problemID is included, it creates a list of problem information 237 sub FieldHTML { 238 my ($self, $userID, $setID, $problemID, $globalRecord, $userRecord, $field) = @_; 239 240 my $r = $self->r; 241 my $db = $r->db; 242 my @editForUser = $r->param('editForUser'); 243 my $forUsers = scalar(@editForUser); 244 my $forOneUser = $forUsers == 1; 245 246 #my ($globalRecord, $userRecord, $mergedRecord); 247 #if (defined $problemID) { 248 # $globalRecord = $db->getGlobalProblem($setID, $problemID); 249 # $userRecord = $db->getUserProblem($userID, $setID, $problemID); 250 # #$mergedRecord = $db->getMergedProblem($userID, $setID, $problemID); # never used --sam 251 #} else { 252 # $globalRecord = $db->getGlobalSet($setID); 253 # $userRecord = $db->getUserSet($userID, $setID); 254 # #$mergedRecord = $db->getMergedSet($userID, $setID); # never user --sam 255 #} 256 257 return "No data exists for set $setID and problem $problemID" unless $globalRecord; 258 return "No user specific data exists for user $userID" if $forOneUser and $globalRecord and not $userRecord; 259 260 my %properties = %{ FIELD_PROPERTIES()->{$field} }; 261 my %labels = %{ $properties{labels} }; 262 return "" if $properties{type} eq "hidden"; 263 return "" if $properties{override} eq "one" && not $forOneUser; 264 return "" if $properties{override} eq "none" && not $forOneUser; 265 return "" if $properties{override} eq "all" && $forUsers; 266 267 my $edit = ($properties{type} eq "edit") && ($properties{override} ne "none"); 268 my $choose = ($properties{type} eq "choose") && ($properties{override} ne "none"); 269 270 my $globalValue = $globalRecord->{$field}; 271 $globalValue = $globalValue ? ($labels{$globalValue || ""} || $globalValue) : ""; 272 my $userValue = $userRecord->{$field}; 273 $userValue = $userValue ? ($labels{$userValue || ""} || $userValue) : ""; 274 275 if ($field =~ /_date/) { 276 $globalValue = $self->formatDateTime($globalValue) if $globalValue; 277 $userValue = $self->formatDateTime($userValue) if $userValue; 278 } 279 280 # check to make sure that a given value can be overridden 281 my %canOverride = map { $_ => 1 } (@{ PROBLEM_FIELDS() }, @{ SET_FIELDS() }); 282 my $check = $canOverride{$field}; 283 284 # $recordType is a shorthand in the return statement for problem or set 285 # $recordID is a shorthand in the return statement for $problemID or $setID 286 my $recordType = ""; 287 my $recordID = ""; 288 if (defined $problemID) { 289 $recordType = "problem"; 290 $recordID = $problemID; 291 } else { 292 $recordType = "set"; 293 $recordID = $setID; 294 } 295 296 # $inputType contains either an input box or a popup_menu for changing a given db field 297 my $inputType = ""; 298 if ($edit) { 299 $inputType = CGI::input({ 300 name => "$recordType.$recordID.$field", 301 value => $r->param("$recordType.$recordID.$field") || ($forUsers ? $userValue : $globalValue), 302 size => $properties{size} || 5, 303 }); 304 } elsif ($choose) { 305 # Note that in popup menus, you're almost guaranteed to have the choices hashed to labels in %properties 306 # but $userValue and and $globalValue are the values in the hash not the keys 307 # so we have to use the actual db record field values to select our default here. 308 $inputType = CGI::popup_menu({ 309 name => "$recordType.$recordID.$field", 310 values => $properties{choices}, 311 labels => \%labels, 312 default => $r->param("$recordType.$recordID.$field") || ($forUsers ? $userRecord->$field : $globalRecord->$field), 313 }); 314 } 315 316 return (($forUsers && $edit && $check) ? CGI::checkbox({ 317 type => "checkbox", 318 name => "$recordType.$recordID.$field.override", 319 label => "", 320 value => $field, 321 checked => $r->param("$recordType.$recordID.$field.override") || ($userValue ne "" ? 1 : 0), 322 }) : "", 323 $properties{name}, 324 $inputType, 325 $forUsers ? " $globalValue" : "", 326 ); 327 } 328 329 # creates a popup menu of all possible problem numbers (for possible rearranging) 330 sub problem_number_popup { 331 my $num = shift; 332 my $total = shift; 333 return (CGI::popup_menu(-name => "problem_num_$num", 334 -values => [1..$total], 335 -default => $num)); 336 } 337 338 # handles rearrangement necessary after changes to problem ordering 339 sub handle_problem_numbers { 340 my $newProblemNumbersref = shift; 341 my %newProblemNumbers = %$newProblemNumbersref; 342 my $maxNum = shift; 343 my $db = shift; 344 my $setID = shift; 345 my $force = shift || 0; 346 my @sortme=(); 347 my ($j, $val); 348 349 foreach $j (keys %newProblemNumbers) { 350 # what happens our first time on this page 351 return "" if (not defined $newProblemNumbers{"$j"}); 352 if ($newProblemNumbers{"$j"} != $j) { 353 $force = 1; 354 $val = 1000 * $newProblemNumbers{$j} - $j; 355 } else { 356 $val = 1000 * $newProblemNumbers{$j}; 357 } 358 push @sortme, [$j, $val]; 359 $newProblemNumbers{$j} = $db->getGlobalProblem($setID, $j); 360 die "global $j for set $setID not found." unless $newProblemNumbers{$j}; 361 } 362 363 return "" unless $force; 364 365 @sortme = sort {$a->[1] <=> $b->[1]} @sortme; 366 # now, for global and each user with this set, loop through problem list 367 # get all of the problem records 368 # assign new problem numbers 369 # loop - if number is new, put the problem record 370 # print "Sorted to get ". join(', ', map {$_->[0] } @sortme) ."<p>\n"; 371 372 373 # Now, three stages. First global values 374 375 for ($j = 0; $j < scalar @sortme; $j++) { 376 if($sortme[$j]->[0] == $j + 1) { 377 # do nothing 378 } elsif (not defined $newProblemNumbers{$j + 1}) { 379 $newProblemNumbers{$sortme[$j]->[0]}->problem_id($j + 1); 380 $db->addGlobalProblem($newProblemNumbers{$sortme[$j]->[0]}); 381 } else { 382 $newProblemNumbers{$sortme[$j]->[0]}->problem_id($j + 1); 383 $db->putGlobalProblem($newProblemNumbers{$sortme[$j]->[0]}); 384 } 385 } 386 387 my @setUsers = $db->listSetUsers($setID); 388 my (@problist, $user); 389 my $globalUserID = $db->{set}->{params}->{globalUserID} || ''; 390 391 foreach $user (@setUsers) { 392 # if this is gdbm, the global user has been taken care of above. 393 # we can't do it again. This relies on the global user not having 394 # a blank name. 395 next if $globalUserID eq $user; 396 for $j (keys %newProblemNumbers) { 397 $problist[$j] = $db->getUserProblem($user, $setID, $j); 398 die " problem $j for set $setID and effective user $user not found" 399 unless $problist[$j]; 400 } 401 # ok, now we have all problem data for $user 402 for($j = 0; $j < scalar @sortme; $j++) { 403 if ($sortme[$j]->[0] == $j + 1) { 404 # do nothing 405 } elsif (not defined $newProblemNumbers{$j + 1}) { 406 $problist[$sortme[$j]->[0]]->problem_id($j + 1); 407 $db->addUserProblem($problist[$sortme[$j]->[0]]); 408 } else { 409 $problist[$sortme[$j]->[0]]->problem_id($j + 1); 410 $db->putUserProblem($problist[$sortme[$j]->[0]]); 411 } 412 } 413 } 414 415 416 foreach ($j = scalar @sortme; $j < $maxNum; $j++) { 417 if (defined $newProblemNumbers{$j + 1}) { 418 $db->deleteGlobalProblem($setID, $j+1); 419 } 420 } 421 422 return join(', ', map {$_->[0]} @sortme); 423 } 424 425 # swap index given with next bigger index 426 # leftover from when we had up/down buttons 427 # maybe we will bring them back 428 429 #sub moveme { 430 # my $index = shift; 431 # my $db = shift; 432 # my $setID = shift; 433 # my (@problemIDList) = @_; 434 # my ($prob1, $prob2, $prob); 435 # 436 # foreach my $problemID (@problemIDList) { 437 # my $problemRecord = $db->getGlobalProblem($setID, $problemID); # checked 438 # die "global $problemID for set $setID not found." unless $problemRecord; 439 # if ($problemRecord->problem_id == $index) { 440 # $prob1 = $problemRecord; 441 # } elsif ($problemRecord->problem_id == $index + 1) { 442 # $prob2 = $problemRecord; 443 # } 444 # } 445 # if (not defined $prob1 or not defined $prob2) { 446 # die "cannot find problem $index or " . ($index + 1); 447 # } 448 # 449 # $prob1->problem_id($index + 1); 450 # $prob2->problem_id($index); 451 # $db->putGlobalProblem($prob1); 452 # $db->putGlobalProblem($prob2); 453 # 454 # my @setUsers = $db->listSetUsers($setID); 455 # 456 # my $user; 457 # foreach $user (@setUsers) { 458 # $prob1 = $db->getUserProblem($user, $setID, $index); #checked 459 # die " problem $index for set $setID and effective user $user not found" 460 # unless $prob1; 461 # $prob2 = $db->getUserProblem($user, $setID, $index+1); #checked 462 # die " problem $index for set $setID and effective user $user not found" 463 # unless $prob2; 464 # $prob1->problem_id($index+1); 465 # $prob2->problem_id($index); 466 # $db->putUserProblem($prob1); 467 # $db->putUserProblem($prob2); 468 # } 469 #} 470 471 # primarily saves any changes into the correct set or problem records (global vs user) 472 # also deals with deleting or rearranging problems 473 sub initialize { 474 my ($self) = @_; 475 my $r = $self->r; 476 my $db = $r->db; 477 my $ce = $r->ce; 478 my $authz = $r->authz; 479 my $user = $r->param('user'); 480 my $setID = $r->urlpath->arg("setID"); 481 my $setRecord = $db->getGlobalSet($setID); # checked 482 die "global set $setID not found." unless $setRecord; 483 484 $self->{set} = $setRecord; 485 my @editForUser = $r->param('editForUser'); 486 # some useful booleans 487 my $forUsers = scalar(@editForUser); 488 my $forOneUser = $forUsers == 1; 489 490 # Check permissions 491 return unless ($authz->hasPermissions($user, "access_instructor_tools")); 492 return unless ($authz->hasPermissions($user, "modify_problem_sets")); 493 494 495 my %properties = %{ FIELD_PROPERTIES() }; 496 497 # takes a hash of hashes and inverts it 498 my %undoLabels; 499 foreach my $key (keys %properties) { 500 %{ $undoLabels{$key} } = map { $properties{$key}->{labels}->{$_} => $_ } keys %{ $properties{$key}->{labels} }; 501 } 502 503 # Unfortunately not everyone uses Javascript enabled browsers so 504 # we must fudge the information coming from the ComboBoxes 505 # Since the textfield and menu both have the same name, we get an array of two elements 506 # We then reset the param to the first if its not-empty or the second (empty or not). 507 foreach ( @{ HEADER_ORDER() } ) { 508 my @values = $r->param("set.$setID.$_"); 509 my $value = $values[0] || $values[1] || ""; 510 $r->param("set.$setID.$_", $value); 511 } 512 513 ##################################################################### 514 # Check date information 515 ##################################################################### 516 517 my ($open_date, $due_date, $answer_date); 518 my $error = 0; 519 if (defined $r->param('submit_changes')) { 520 521 my $od_param = $r->param("set.$setID.open_date"); 522 my $dd_param = $r->param("set.$setID.due_date"); 523 my $ad_param = $r->param("set.$setID.answer_date"); 524 #my $setRecord = $db->getGlobalSet($setID); # already fetched above --sam 525 526 $open_date = $od_param ? $self->parseDateTime($od_param) : $setRecord->open_date; 527 $due_date = $dd_param ? $self->parseDateTime($dd_param) : $setRecord->due_date; 528 $answer_date = $ad_param ? $self->parseDateTime($ad_param) : $setRecord->answer_date; 529 530 if ($answer_date < $due_date || $answer_date < $open_date) { 531 $self->addbadmessage("Answers cannot be made available until on or after the due date!"); 532 $error = $r->param('submit_changes'); 533 } 534 535 if ($due_date < $open_date) { 536 $self->addbadmessage("Answers cannot be due until on or after the open date!"); 537 $error = $r->param('submit_changes'); 538 } 539 540 if ($error) { 541 $self->addbadmessage("No changes were saved!"); 542 } 543 } 544 545 546 if (defined $r->param('submit_changes') && !$error) { 547 548 #my $setRecord = $db->getGlobalSet($setID); # already fetched above --sam 549 550 ##################################################################### 551 # Save general set information (including headers) 552 ##################################################################### 553 554 if ($forUsers) { 555 my @userRecords = $db->getUserSets(map { [$_, $setID] } @editForUser); 556 foreach my $record (@userRecords) { 557 foreach my $field ( @{ SET_FIELDS() } ) { 558 next unless canChange($forUsers, $field); 559 560 my $override = $r->param("set.$setID.$field.override"); 561 if (defined $override && $override eq $field) { 562 563 my $param = $r->param("set.$setID.$field"); 564 $param = $properties{$field}->{default} || "" unless defined $param && $param ne ""; 565 $param = $undoLabels{$field}->{$param} || $param; 566 if ($field =~ /_date/) { 567 $param = $self->parseDateTime($param); 568 } 569 $record->$field($param); 570 } else { 571 $record->$field(undef); 572 } 573 } 574 $db->putUserSet($record); 575 } 576 } else { 577 foreach my $field ( @{ SET_FIELDS() } ) { 578 next unless canChange($forUsers, $field); 579 580 my $param = $r->param("set.$setID.$field"); 581 $param = $properties{$field}->{default} || "" unless defined $param && $param ne ""; 582 $param = $undoLabels{$field}->{$param} || $param; 583 if ($field =~ /_date/) { 584 $param = $self->parseDateTime($param); 585 } 586 $setRecord->$field($param); 587 } 588 $db->putGlobalSet($setRecord); 589 } 590 591 592 ##################################################################### 593 # Save problem information 594 ##################################################################### 595 596 my @problemIDs = sort { $a <=> $b } $db->listGlobalProblems($setID);; 597 my @problemRecords = $db->getGlobalProblems(map { [$setID, $_] } @problemIDs); 598 foreach my $problemRecord (@problemRecords) { 599 my $problemID = $problemRecord->problem_id; 600 die "Global problem $problemID for set $setID not found." unless $problemRecord; 601 602 if ($forUsers) { 603 # Since we're editing for specific users, we don't allow the GlobalProblem record to be altered on that same page 604 # So we only need to make changes to the UserProblem record and only then if we are overriding a value 605 # in the GlobalProblem record or for fields unique to the UserProblem record. 606 607 my @userIDs = @editForUser; 608 my @userProblemIDs = map { [$_, $setID, $problemID] } @userIDs; 609 my @userProblemRecords = $db->getUserProblems(@userProblemIDs); 610 foreach my $record (@userProblemRecords) { 611 612 my $changed = 0; # keep track of any changes, if none are made, avoid unnecessary db accesses 613 foreach my $field ( @{ PROBLEM_FIELDS() } ) { 614 next unless canChange($forUsers, $field); 615 616 my $override = $r->param("problem.$problemID.$field.override"); 617 if (defined $override && $override eq $field) { 618 619 my $param = $r->param("problem.$problemID.$field"); 620 $param = $properties{$field}->{default} || "" unless defined $param && $param ne ""; 621 $param = $undoLabels{$field}->{$param} || $param; 622 $changed ||= changed($record->$field, $param); 623 $record->$field($param); 624 } else { 625 $changed ||= changed($record->$field, undef); 626 $record->$field(undef); 627 } 628 629 } 630 631 foreach my $field ( @{ USER_PROBLEM_FIELDS() } ) { 632 next unless canChange($forUsers, $field); 633 634 my $param = $r->param("problem.$problemID.$field"); 635 $param = $properties{$field}->{default} || "" unless defined $param && $param ne ""; 636 $param = $undoLabels{$field}->{$param} || $param; 637 $changed ||= changed($record->$field, $param); 638 $record->$field($param); 639 } 640 $db->putUserProblem($record) if $changed; 641 } 642 } else { 643 # Since we're editing for ALL set users, we will make changes to the GlobalProblem record. 644 # We may also have instances where a field is unique to the UserProblem record but we want 645 # all users to (at least initially) have the same value 646 647 # this only edits a globalProblem record 648 my $changed = 0; # keep track of any changes, if none are made, avoid unnecessary db accesses 649 foreach my $field ( @{ PROBLEM_FIELDS() } ) { 650 next unless canChange($forUsers, $field); 651 652 my $param = $r->param("problem.$problemID.$field"); 653 $param = $properties{$field}->{default} || "" unless defined $param && $param ne ""; 654 $param = $undoLabels{$field}->{$param} || $param; 655 $changed ||= changed($problemRecord->$field, $param); 656 $problemRecord->$field($param); 657 } 658 $db->putGlobalProblem($problemRecord) if $changed; 659 660 661 # sometimes (like for status) we might want to change an attribute in 662 # the userProblem record for every assigned user 663 # However, since this data is stored in the UserProblem records, 664 # it won't be displayed once its been changed and if you hit "Save Changes" again 665 # it gets erased 666 667 # So we'll enforce that there be something worth putting in all the UserProblem records 668 # This also will make hitting "Save Changes" on the global page MUCH faster 669 my %useful; 670 foreach my $field ( @{ USER_PROBLEM_FIELDS() } ) { 671 my $param = $r->param("problem.$problemID.$field"); 672 $useful{$field} = 1 if defined $param and $param ne ""; 673 } 674 675 if (keys %useful) { 676 my @userIDs = $db->listProblemUsers($setID, $problemID); 677 my @userProblemIDs = map { [$_, $setID, $problemID] } @userIDs; 678 my @userProblemRecords = $db->getUserProblems(@userProblemIDs); 679 foreach my $record (@userProblemRecords) { 680 my $changed = 0; # keep track of any changes, if none are made, avoid unnecessary db accesses 681 foreach my $field ( keys %useful ) { 682 next unless canChange($forUsers, $field); 683 684 my $param = $r->param("problem.$problemID.$field"); 685 $param = $properties{$field}->{default} || "" unless defined $param && $param ne ""; 686 $param = $undoLabels{$field}->{$param} || $param; 687 $changed ||= changed($record->$field, $param); 688 $record->$field($param); 689 } 690 $db->putUserProblem($record) if $changed; 691 } 692 } 693 } 694 } 695 696 # Delete all problems marked for deletion 697 foreach my $problemID ($r->param('deleteProblem')) { 698 $db->deleteGlobalProblem($setID, $problemID); 699 } 700 701 # Sets the specified header to "" so that the default file will get used. 702 foreach my $header ($r->param('defaultHeader')) { 703 $setRecord->$header(""); 704 } 705 706 # Mark the specified problems as correct for all users 707 foreach my $problemID ($r->param('markCorrect')) { 708 my @userProblemIDs = map { [$_, $setID, $problemID] } ($forUsers ? @editForUser : $db->listProblemUsers($setID, $problemID)); 709 my @userProblemRecords = $db->getUserProblems(@userProblemIDs); 710 foreach my $record (@userProblemRecords) { 711 $self->addbadmessage($record->user_id); 712 if (defined $record && ($record->status eq "" || $record->status < 1)) { 713 $record->status(1); 714 $record->attempted(1); 715 $db->putUserProblem($record); 716 } 717 } 718 } 719 } 720 721 # Leftover code from when there were up/down buttons 722 723 # } else { 724 # # Look for up and down buttons 725 # my $index = 2; 726 # while ($index <= scalar @problemList) { 727 # if (defined $r->param("move.up.$index.x")) { 728 # moveme($index-1, $db, $setID, @problemList); 729 # } 730 # $index++; 731 # } 732 # $index = 1; 733 # 734 # while ($index < scalar @problemList) { 735 # if (defined $r->param("move.down.$index.x")) { 736 # moveme($index, $db, $setID, @problemList); 737 # } 738 # $index++; 739 # } 740 # } 741 742 743 # This erases any sticky fields if the user saves changes, resets the form, or reorders problems 744 # It may not be obvious why this is necessary when saving changes or reordering problems 745 # but when the problems are reorder the param problem.1.source_file needs to be the source 746 # file of the problem that is NOW #1 and not the problem that WAS #1. 747 unless (defined $r->param('refresh')) { 748 749 # reset all the parameters dealing with set/problem/header information 750 # if the current naming scheme is changed/broken, this could reek havoc 751 # on all kinds of things 752 foreach my $param ($r->param) { 753 $r->param($param, "") if $param =~ /^(set|problem|header)\./; 754 } 755 } 756 757 } 758 759 # helper method for debugging 760 sub definedness ($) { 761 my ($variable) = @_; 762 763 return "undefined" unless defined $variable; 764 return "empty" unless $variable ne ""; 765 return $variable; 766 } 767 768 # helper method for checking if two things are different 769 # the return values will usually be thrown away, but they could be useful for debugging 770 sub changed ($$) { 771 my ($first, $second) = @_; 772 773 return "def/undef" if defined $first and not defined $second; 774 return "undef/def" if not defined $first and defined $second; 775 return "" if not defined $first and not defined $second; 776 return "ne" if $first ne $second; 777 return ""; # if they're equal, there's no change 778 } 779 780 # helper method that determines for how many users at a time a field can be changed 781 # none means it can't be changed for anyone 782 # any means it can be changed for anyone 783 # one means it can ONLY be changed for one at a time. (eg problem_seed) 784 # all means it can ONLY be changed for all at a time. (eg set_header) 785 sub canChange ($$) { 786 my ($forUsers, $field) = @_; 787 788 my %properties = %{ FIELD_PROPERTIES() }; 789 my $forOneUser = $forUsers == 1; 790 791 my $howManyCan = $properties{$field}->{override}; 792 793 return 0 if $howManyCan eq "none"; 794 return 1 if $howManyCan eq "any"; 795 return 1 if $howManyCan eq "one" && $forOneUser; 796 return 1 if $howManyCan eq "all" && !$forUsers; 797 return 0; # FIXME: maybe it should default to 1? 798 } 799 800 # helper method that determines if a file is valid and returns a pretty error message 801 sub checkFile ($) { 802 my ($self, $file) = @_; 803 804 my $r = $self->r; 805 my $ce = $r->ce; 806 807 return "No source file specified" unless $file; 808 $file = $ce->{courseDirs}->{templates} . '/' . $file unless $file =~ m|^/|; 809 810 my $text = "This source file "; 811 my $fileError; 812 return "" if -e $file && -f $file && -r $file; 813 return $text . "is not readable!" if -e $file && -f $file; 814 return $text . "is a directory!" if -d $file; 815 return $text . "does not exist!" unless -e $file; 816 return $text . "is not a plain file!"; 817 } 818 819 # Creates two separate tables, first of the headers, and the of the problems in a given set 820 # If one or more users are specified in the "editForUser" param, only the data for those users 821 # becomes editable, not all the data 822 sub body { 823 824 my ($self) = @_; 825 my $r = $self->r; 826 my $db = $r->db; 827 my $ce = $r->ce; 828 my $authz = $r->authz; 829 my $userID = $r->param('user'); 830 my $urlpath = $r->urlpath; 831 my $courseID = $urlpath->arg("courseID"); 832 my $setID = $urlpath->arg("setID"); 833 my $setRecord = $db->getGlobalSet($setID) or die "No record for global set $setID."; 834 835 my $userRecord = $db->getUser($userID) or die "No record for user $userID."; 836 # Check permissions 837 return CGI::div({class=>"ResultsWithError"}, "You are not authorized to access the Instructor tools.") 838 unless $authz->hasPermissions($userRecord->user_id, "access_instructor_tools"); 839 840 return CGI::div({class=>"ResultsWithError"}, "You are not authorized to modify problems.") 841 unless $authz->hasPermissions($userRecord->user_id, "modify_problem_sets"); 842 843 my @editForUser = $r->param('editForUser'); 844 845 # Check that every user that we're editing for has a valid UserSet 846 my @assignedUsers; 847 my @unassignedUsers; 848 if (scalar @editForUser) { 849 foreach my $ID (@editForUser) { 850 if ($db->getUserSet($ID, $setID)) { 851 unshift @assignedUsers, $ID; 852 } else { 853 unshift @unassignedUsers, $ID; 854 } 855 } 856 @editForUser = @assignedUsers; 857 $r->param("editForUser", \@editForUser); 858 859 if (scalar @editForUser && scalar @unassignedUsers) { 860 print CGI::div({class=>"ResultsWithError"}, "The following users are NOT assigned to this set and will be ignored: " . CGI::b(join(", ", @unassignedUsers))); 861 } elsif (scalar @editForUser == 0) { 862 print CGI::div({class=>"ResultsWithError"}, "None of the selected users are assigned to this set: " . CGI::b(join(", ", @unassignedUsers))); 863 print CGI::div({class=>"ResultsWithError"}, "Global set data will be shown instead of user specific data"); 864 } 865 } 866 867 # some useful booleans 868 my $forUsers = scalar(@editForUser); 869 my $forOneUser = $forUsers == 1; 870 871 # If you're editing for users, initially their records will be different but 872 # if you make any changes to them they will be the same. 873 # if you're editing for one user, the problems shown should be his/hers 874 my $userToShow = $forUsers ? $editForUser[0] : $userID; 875 876 my $userCount = $db->listUsers(); 877 my $setCount = $db->listGlobalSets() if $forOneUser; 878 my $setUserCount = $db->countSetUsers($setID); 879 my $userSetCount = $db->countUserSets($editForUser[0]) if $forOneUser; 880 881 882 my $editUsersAssignedToSetURL = $self->systemLink( 883 $urlpath->newFromModule( 884 "WeBWorK::ContentGenerator::Instructor::UsersAssignedToSet", 885 courseID => $courseID, setID => $setID)); 886 my $editSetsAssignedToUserURL = $self->systemLink( 887 $urlpath->newFromModule( 888 "WeBWorK::ContentGenerator::Instructor::SetsAssignedToUser", 889 courseID => $courseID, userID => $editForUser[0])) if $forOneUser; 890 891 892 my $setDetailPage = $urlpath -> newFromModule($urlpath->module, courseID => $courseID, setID => $setID); 893 my $setDetailURL = $self->systemLink($setDetailPage,authen=>0); 894 895 896 my $userCountMessage = CGI::a({href=>$editUsersAssignedToSetURL}, $self->userCountMessage($setUserCount, $userCount)); 897 my $setCountMessage = CGI::a({href=>$editSetsAssignedToUserURL}, $self->setCountMessage($userSetCount, $setCount)) if $forOneUser; 898 899 $userCountMessage = "The set $setID is assigned to " . $userCountMessage . "."; 900 $setCountMessage = "The user $editForUser[0] has been assigned " . $setCountMessage . "." if $forOneUser; 901 902 if ($forUsers) { 903 print CGI::p("$userCountMessage Editing user-specific overrides for ". CGI::b(join ", ", @editForUser)); 904 if ($forOneUser) { 905 print CGI::p($setCountMessage); 906 } 907 } else { 908 print CGI::p($userCountMessage); 909 } 910 911 # handle renumbering of problems if necessary 912 print CGI::a({name=>"problems"}); 913 914 my %newProblemNumbers = (); 915 my $maxProblemNumber = -1; 916 for my $jj (sort { $a <=> $b } $db->listGlobalProblems($setID)) { 917 $newProblemNumbers{$jj} = $r->param('problem_num_' . $jj); 918 $maxProblemNumber = $jj if $jj > $maxProblemNumber; 919 } 920 921 my $forceRenumber = $r->param('force_renumber') || 0; 922 handle_problem_numbers(\%newProblemNumbers, $maxProblemNumber, $db, $setID, $forceRenumber) unless defined $r->param('undo_changes'); 923 924 my %properties = %{ FIELD_PROPERTIES() }; 925 926 my %display_modes = %{WeBWorK::PG::DISPLAY_MODES()}; 927 my @active_modes = grep { exists $display_modes{$_} } @{$r->ce->{pg}->{displayModes}}; 928 push @active_modes, 'None'; 929 my $default_header_mode = $r->param('header.displaymode') || 'None'; 930 my $default_problem_mode = $r->param('problem.displaymode') || 'None'; 931 932 ##################################################################### 933 # Browse available header/problem files 934 ##################################################################### 935 936 my $templates = $r->ce->{courseDirs}->{templates}; 937 my %probLibs = %{ $r->ce->{courseFiles}->{problibs} }; 938 my $skip = join("|", keys %probLibs); 939 940 my @headerFileList = listFilesRecursive( 941 $templates, 942 qr/header.*\.pg$/i, # match these files 943 qr/^(?:$skip|CVS)$/, # prune these directories 944 0, # match against file name only 945 1, # prune against path relative to $templates 946 ); 947 948 # this just takes too much time to search 949 # my @problemFileList = listFilesRecursive( 950 # $templates, 951 # qr/\.pg$/i, # problem files don't say problem 952 # qr/^(?:$skip|CVS)$/, # prune these directories 953 # 0, # match against file name only 954 # 1, # prune against path relative to $templates 955 # ); 956 957 # Display a useful warning message 958 if ($forUsers) { 959 print CGI::p(CGI::b("Any changes made below will be reflected in the set for ONLY the student" . 960 ($forOneUser ? "" : "s") . " listed above.")); 961 } else { 962 print CGI::p(CGI::b("Any changes made below will be reflected in the set for ALL students.")); 963 } 964 965 print CGI::start_form({method=>"POST", action=>$setDetailURL}); 966 print $self->hiddenEditForUserFields(@editForUser); 967 print $self->hidden_authen_fields; 968 print CGI::input({type=>"submit", name=>"submit_changes", value=>"Save Changes"}); 969 print CGI::input({type=>"submit", name=>"undo_changes", value => "Reset Form"}); 970 971 # spacing 972 print CGI::p(); 973 974 ##################################################################### 975 # Display general set information 976 ##################################################################### 977 978 print CGI::start_table({border=>1, cellpadding=>4}); 979 print CGI::Tr({}, CGI::th({}, [ 980 "General Information", 981 ])); 982 983 # this is kind of a hack -- we need to get a user record here, so we can 984 # pass it to FieldTable, so FieldTable can pass it to FieldHTML, so 985 # FieldHTML doesn't have to fetch it itself. 986 my $userSetRecord = $db->getUserSet($userToShow, $setID); 987 988 print CGI::Tr({}, CGI::td({}, [ 989 $self->FieldTable($userToShow, $setID, undef, $setRecord, $userSetRecord), 990 ])); 991 print CGI::end_table(); 992 993 # spacing 994 print CGI::p(); 995 996 997 ##################################################################### 998 # Display header information 999 ##################################################################### 1000 my @headers = @{ HEADER_ORDER() }; 1001 my %headerModules = (set_header => 'problem_list', hardcopy_header => 'hardcopy_preselect_set'); 1002 my %headerDefaults = (set_header => $ce->{webworkFiles}->{screenSnippets}->{setHeader}, hardcopy_header => $ce->{webworkFiles}->{hardcopySnippets}->{setHeader}); 1003 my @headerFiles = map { $setRecord->{$_} } @headers; 1004 if (scalar @headers and not $forUsers) { 1005 1006 print CGI::start_table({border=>1, cellpadding=>4}); 1007 print CGI::Tr({}, CGI::th({}, [ 1008 "Headers", 1009 # "Data", 1010 "Display Mode: " . 1011 CGI::popup_menu(-name => "header.displaymode", -values => \@active_modes, -default => $default_header_mode) . ' '. 1012 CGI::input({type => "submit", name => "refresh", value => "Refresh Display"}), 1013 ])); 1014 1015 my %header_html; 1016 1017 my %error; 1018 foreach my $header (@headers) { 1019 my $headerFile = $r->param("set.$setID.$header") || $setRecord->{$header} || $headerDefaults{$header}; 1020 1021 $error{$header} = $self->checkFile($headerFile); 1022 unless ($error{$header}) { 1023 my @temp = renderProblems( r=> $r, 1024 user => $db->getUser($userToShow), 1025 displayMode=> $default_header_mode, 1026 problem_number=> 0, 1027 this_set => $db->getMergedSet($userToShow, $setID), 1028 problem_list => [$headerFile], 1029 ); 1030 $header_html{$header} = $temp[0]; 1031 } 1032 } 1033 1034 foreach my $header (@headers) { 1035 1036 my $editHeaderPage = $urlpath->new(type => 'instructor_problem_editor_withset_withproblem', args => { courseID => $courseID, setID => $setID, problemID => 0 }); 1037 my $editHeaderLink = $self->systemLink($editHeaderPage, params => { file_type => $header, make_local_copy => 1 }); 1038 1039 my $viewHeaderPage = $urlpath->new(type => $headerModules{$header}, args => { courseID => $courseID, setID => $setID }); 1040 my $viewHeaderLink = $self->systemLink($viewHeaderPage); 1041 1042 print CGI::Tr({}, CGI::td({}, [ 1043 CGI::start_table({border => 0, cellpadding => 0}) . 1044 CGI::Tr({}, CGI::td({}, $properties{$header}->{name})) . 1045 CGI::Tr({}, CGI::td({}, CGI::a({href => $editHeaderLink}, "Edit it"))) . 1046 CGI::Tr({}, CGI::td({}, CGI::a({href => $viewHeaderLink}, "View it"))) . 1047 # CGI::Tr({}, CGI::td({}, CGI::checkbox({name => "defaultHeader", value => $header, label => "Use Default"}))) . 1048 CGI::end_table(), 1049 # "", 1050 # CGI::input({ name => "set.$setID.$header", value => $setRecord->{$header}, size => 50}) . 1051 # join ("\n", $self->FieldHTML($userToShow, $setID, $problemID, "source_file")) . 1052 # CGI::br() . CGI::div({class=> "RenderSolo"}, $problem_html[0]->{body_text}), 1053 1054 comboBox({ 1055 name => "set.$setID.$header", 1056 request => $r, 1057 default => $r->param("set.$setID.$header") || $setRecord->{$header}, 1058 multiple => 0, 1059 values => ["", @headerFileList], 1060 labels => { "" => "Use Default Header File" }, 1061 }) . 1062 ($error{$header} ? 1063 CGI::div({class=>"ResultsWithError", style=>"font-weight: bold"}, $error{$header}) 1064 : CGI::div({class=> "RenderSolo"}, $header_html{$header}->{body_text}) 1065 ), 1066 ])); 1067 } 1068 1069 print CGI::end_table(); 1070 } else { 1071 print CGI::p(CGI::b("Screen and Hardcopy set header information can not be overridden for individual students.")); 1072 } 1073 1074 # spacing 1075 print CGI::p(); 1076 1077 1078 ##################################################################### 1079 # Display problem information 1080 ##################################################################### 1081 1082 my @problemIDList = sort { $a <=> $b } $db->listGlobalProblems($setID); 1083 1084 # get global problem records for all problems in one go 1085 my %GlobalProblems; 1086 my @globalKeypartsRef = map { [$setID, $_] } @problemIDList; 1087 @GlobalProblems{@problemIDList} = $db->getGlobalProblems(@globalKeypartsRef); 1088 1089 # if needed, get user problem records for all problems in one go 1090 my (%UserProblems, %MergedProblems); 1091 if ($forOneUser) { 1092 my @userKeypartsRef = map { [$editForUser[0], $setID, $_] } @problemIDList; 1093 @UserProblems{@problemIDList} = $db->getUserProblems(@userKeypartsRef); 1094 @MergedProblems{@problemIDList} = $db->getMergedProblems(@userKeypartsRef); 1095 } 1096 1097 if (scalar @problemIDList) { 1098 1099 print CGI::start_table({border=>1, cellpadding=>4}); 1100 print CGI::Tr({}, CGI::th({}, [ 1101 "Problems", 1102 "Data", 1103 "Display Mode: " . 1104 CGI::popup_menu(-name => "problem.displaymode", -values => \@active_modes, -default => $default_problem_mode) . ' '. 1105 CGI::input({type => "submit", name => "refresh", value => "Refresh Display"}), 1106 ])); 1107 1108 my %shownYet; 1109 my $repeatFile; 1110 foreach my $problemID (@problemIDList) { 1111 1112 my $problemRecord; 1113 if ($forOneUser) { 1114 #$problemRecord = $db->getMergedProblem($editForUser[0], $setID, $problemID); 1115 $problemRecord = $MergedProblems{$problemID}; # already fetched above --sam 1116 } else { 1117 #$problemRecord = $db->getGlobalProblem($setID, $problemID); 1118 $problemRecord = $GlobalProblems{$problemID}; # already fetched above --sam 1119 } 1120 1121 #$self->addgoodmessage(""); 1122 #$self->addbadmessage($problemRecord->toString()); 1123 1124 1125 my $editProblemPage = $urlpath->new(type => 'instructor_problem_editor_withset_withproblem', args => { courseID => $courseID, setID => $setID, problemID => $problemID }); 1126 my $editProblemLink = $self->systemLink($editProblemPage, params => { make_local_copy => 0 }); 1127 1128 # FIXME: should we have an "act as" type link here when editing for multiple users? 1129 my $viewProblemPage = $urlpath->new(type => 'problem_detail', args => { courseID => $courseID, setID => $setID, problemID => $problemID }); 1130 my $viewProblemLink = $self->systemLink($viewProblemPage, params => { effectiveUser => ($forOneUser ? $editForUser[0] : $userID)}); 1131 1132 my @fields = @{ PROBLEM_FIELDS() }; 1133 push @fields, @{ USER_PROBLEM_FIELDS() } if $forOneUser; 1134 1135 my $problemFile = $r->param("problem.$problemID.source_file") || $problemRecord->source_file; 1136 1137 # warn of repeat problems 1138 if (defined $shownYet{$problemFile}) { 1139 $repeatFile = "This problem uses the same source file as number " . $shownYet{$problemFile} . "."; 1140 } else { 1141 $shownYet{$problemFile} = $problemID; 1142 } 1143 1144 my $error = $self->checkFile($problemFile); 1145 my @problem_html; 1146 unless ($error) { 1147 @problem_html = renderProblems( r=> $r, 1148 user => $db->getUser($userToShow), 1149 displayMode=> $default_problem_mode, 1150 problem_number=> $problemID, 1151 this_set => $db->getMergedSet($userToShow, $setID), 1152 problem_seed => $forOneUser ? $problemRecord->problem_seed : 0, 1153 problem_list => [$problemRecord->source_file], 1154 ); 1155 } 1156 1157 print CGI::Tr({}, CGI::td({}, [ 1158 CGI::start_table({border => 0, cellpadding => 1}) . 1159 CGI::Tr({}, CGI::td({}, problem_number_popup($problemID, $maxProblemNumber))) . 1160 CGI::Tr({}, CGI::td({}, CGI::a({href => $editProblemLink}, "Edit it"))) . 1161 CGI::Tr({}, CGI::td({}, CGI::a({href => $viewProblemLink}, "Try it" . ($forOneUser ? " (as $editForUser[0])" : "")))) . 1162 ($forUsers ? "" : CGI::Tr({}, CGI::td({}, CGI::checkbox({name => "deleteProblem", value => $problemID, label => "Delete it?"})))) . 1163 # CGI::Tr({}, CGI::td({}, "Delete it?" . CGI::input({type => "checkbox", name => "deleteProblem", value => $problemID}))) . 1164 ($forOneUser ? "" : CGI::Tr({}, CGI::td({}, CGI::checkbox({name => "markCorrect", value => $problemID, label => "Mark Correct?"})))) . 1165 CGI::end_table(), 1166 $self->FieldTable($userToShow, $setID, $problemID, $GlobalProblems{$problemID}, $UserProblems{$problemID}), 1167 # A comprehensive list of problems is just TOO big to be handled well 1168 # comboBox({ 1169 # name => "set.$setID.$problemID", 1170 # request => $r, 1171 # default => $problemRecord->{problem_id}, 1172 # multiple => 0, 1173 # values => \@problemFileList, 1174 # }) . 1175 1176 join ("\n", $self->FieldHTML( 1177 $userToShow, 1178 $setID, 1179 $problemID, 1180 $GlobalProblems{$problemID}, # pass previously fetched global record to FieldHTML --sam 1181 $UserProblems{$problemID}, # pass previously fetched user record to FieldHTML --sam 1182 "source_file" 1183 )) . 1184 CGI::br() . 1185 ($error ? 1186 CGI::div({class=>"ResultsWithError", style=>"font-weight: bold"}, $error) 1187 : CGI::div({class=> "RenderSolo"}, $problem_html[0]->{body_text}) 1188 ) . 1189 ($repeatFile ? CGI::div({class=>"ResultsWithError", style=>"font-weight: bold"}, $repeatFile) : ''), 1190 ])); 1191 } 1192 1193 print CGI::end_table(); 1194 print CGI::checkbox({ 1195 label=> "Force problems to be numbered consecutively from one", 1196 name=>"force_renumber", value=>"1"}), 1197 1198 CGI::br(); 1199 print CGI::input({type=>"submit", name=>"submit_changes", value=>"Save Changes"}); 1200 print CGI::input({type=>"submit", name=>"handle_numbers", value=>"Reorder problems only"}) . "(Any unsaved changes will be lost.)"; 1201 print CGI::p(<<HERE); 1202 Any time problem numbers are intentionally changed, the problems will 1203 always be renumbered consecutively, starting from one. When deleting 1204 problems, gaps will be left in the numbering unless the box above is 1205 checked. 1206 HERE 1207 print CGI::p("It is before the open date. You probably want to renumber the problems if you are deleting some from the middle.") if ($setRecord->open_date>time()); 1208 print CGI::p("When changing problem numbers, we will move 1209 the problem to be ", CGI::em("before"), " the chosen number."); 1210 1211 } else { 1212 print CGI::p(CGI::b("This set doesn't contain any problems yet.")); 1213 } 1214 1215 print CGI::end_form(); 1216 1217 return ""; 1218 } 1219 1220 1; 1221 1222 =head1 AUTHOR 1223 1224 Written by Robert Van Dam, toenail (at) cif.rochester.edu 1225 1226 =cut
| aubreyja at gmail dot com | ViewVC Help |
| Powered by ViewVC 1.0.9 |