Parent Directory
|
Revision Log
This commit was manufactured by cvs2svn to create branch 'rel-2-1-patches'.
1 #!/usr/bin/env perl 2 ################################################################################ 3 # WeBWorK Online Homework Delivery System 4 # Copyright © 2000-2003 The WeBWorK Project, http://openwebwork.sf.net/ 5 # $CVSHeader: webwork2/bin/ww_db_v2_to_v3,v 1.4 2004/12/09 16:30:12 sh002i Exp $ 6 # 7 # This program is free software; you can redistribute it and/or modify it under 8 # the terms of either: (a) the GNU General Public License as published by the 9 # Free Software Foundation; either version 2, or (at your option) any later 10 # version, or (b) the "Artistic License" which comes with this package. 11 # 12 # This program is distributed in the hope that it will be useful, but WITHOUT 13 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 14 # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the 15 # Artistic License for more details. 16 ################################################################################ 17 18 =head1 NAME 19 20 ww_db_v2_to_v3 - convert a WWDBv2 database to a WWDBv3 database. 21 22 =head1 SYNOPSIS 23 24 ww_db_v2_to_v3 -crsuv course ... 25 26 =head1 DESCRIPTION 27 28 Copies course data from legacy WWDBv2 database(s) to a WWDBv3 database. This may 29 take a long time. 30 31 You must disallow login to the WeBWorK system while the transfer is taking 32 place. To disable logins for all courses, set the permission level necessary for 33 C<login> to $nobody in F<global.conf>. (It is usually set to $student.) 34 35 =head1 OPTIONS 36 37 =over 38 39 =item -c 40 41 If an error occurs while copying a course's data, continue copying with the next 42 course. 43 44 =item -r 45 46 Update role table in WWDBv3 database from permission level information in 47 F<global.conf>. 48 49 =item -s 50 51 Update status table in WWDBv3 database from the status information in 52 F<global.conf>. 53 54 =item -u 55 56 When importing a user that already exists in the WWDBv3 database, replace the 57 existing information (including the password) with the information in the user 58 record being imported. 59 60 If this option is not specified, existing users are not updated. 61 62 =item -v 63 64 Verbose operation. 65 66 =item course ... 67 68 Data from these courses will be copied. 69 70 =back 71 72 =head1 BEHAVIOR 73 74 =head2 ROLES 75 76 =over 77 78 =item * 79 80 Roles created with the -r switch are created as system-wide roles. 81 82 =item * 83 84 Roles are created by observing the %permissionLevels hash in F<global.conf>, and 85 collecting the privileges granted at each permission level into sets. Each set 86 of privileges becomes a WWDBv3 role record. 87 88 =item * 89 90 When a role with the same set of permissions already exists in the WWDBv3 91 database, a new one is not created. 92 93 =back 94 95 =head2 STATUSES 96 97 =over 98 99 =item * 100 101 Statuses created with the -s switch are created as system-wide statuses. 102 103 =item * 104 105 Statuses are created by observing the %{$siteDefaults{status}} hash in 106 F<global.conf>, and 107 108 =item * 109 110 A status named "Enrolled" is imported into the database with the 111 C<allow_course_access>, C<include_in_assignment>, C<include_in_stats>, and 112 C<include_in_scoring> flags set. 113 114 =item * 115 116 A status named "Audit" is imported into the database with the 117 C<allow_course_access>, C<include_in_assignment>, and C<include_in_stats>, flags 118 set, and the C<include_in_scoring> flag unset. 119 120 =item * 121 122 A status named "Drop" is imported into the database with the 123 C<allow_course_access>, C<include_in_assignment>, C<include_in_stats>, and 124 C<include_in_scoring> flags unset. 125 126 =item * 127 128 Statuses with other names are imported into the database with the same flags set 129 as the "Enrolled" flag. 130 131 =back 132 133 =head2 USERS 134 135 =over 136 137 =item * 138 139 WWDBv2 user IDs are converted to login IDs. 140 141 =item * 142 143 Users with the same v2 user ID in different courses are assumed to be the same 144 user. 145 146 =item * 147 148 A user's permission level is used to determine the role to assign to their v3 149 participant record. (See L<ROLES>.) If the user has an empty permission level, 150 they are assigned the role associated with permission level "0". 151 152 =item * 153 154 A user's status abbreviation is used to determine the status to assign to their 155 v3 participant record. (See L<STATUSES>.) If the user has an empty status, they 156 are assigned the status "Enrolled". 157 158 =item * 159 160 If a user has a non-empty section or recitation, their v3 participant record 161 will be assigned to the section or recitation with a matching name. 162 163 =back 164 165 =cut 166 167 use strict; 168 use warnings; 169 use Data::Dumper; 170 use DateTime; 171 use Getopt::Std; 172 173 BEGIN { 174 die "WEBWORK_ROOT not found in environment.\n" 175 unless exists $ENV{WEBWORK_ROOT}; 176 } 177 178 use lib "$ENV{WEBWORK_ROOT}/lib"; 179 use WeBWorK::CourseEnvironment; 180 use WeBWorK::DB; 181 use WeBWorK::DBv3; 182 183 # map statuses from course environment to sets of status privileges 184 use constant STATUS_MAP => { 185 Enrolled => { allow_course_access => 1, include_in_assignment => 1, include_in_stats => 1, include_in_scoring => 1 }, 186 Audit => { allow_course_access => 1, include_in_assignment => 1, include_in_stats => 1, include_in_scoring => 0 }, 187 Drop => { allow_course_access => 0, include_in_assignment => 0, include_in_stats => 0, include_in_scoring => 0 }, 188 }; 189 190 use constant DEFAULT_STATUS => "C"; 191 use constant DEFAULT_PERMISSION_LEVEL => "0"; 192 193 our ($opt_c, $opt_r, $opt_s, $opt_u, $opt_v); 194 getopts("crsuv"); 195 196 sub debug { print STDERR @_ if $opt_v } 197 sub usage { print STDERR "usage: $0 [-crsuv] course ...\n"; exit 1 } 198 199 main(@ARGV); 200 201 sub main { 202 my (@courseIDs) = @_; 203 204 usage() unless @courseIDs; 205 206 my $ce = WeBWorK::CourseEnvironment->new({webwork_dir => $ENV{WEBWORK_ROOT}}); 207 208 WeBWorK::DBv3::init($ce->{wwdbv3_settings}); 209 210 my %abbrev_to_status_id = set_up_statuses($ce->{siteDefaults}{status}); 211 warn "abbrev_to_status_id: ", Dumper(\%abbrev_to_status_id); 212 213 my %level_to_role_id = set_up_roles($ce->{permissionLevels}); 214 warn "level_to_role_id: ", Dumper(\%level_to_role_id); 215 216 foreach my $courseID (@courseIDs) { 217 eval { copy_course_data($courseID, \%abbrev_to_status_id, \%level_to_role_id) }; 218 if ($@) { 219 warn "An error occured while copying data from course '$courseID':\n\n$@\n\n"; 220 if ($opt_c) { 221 warn "Continuing with the next course...\n"; 222 } else { 223 warn "Exiting.\n"; 224 exit 2; 225 } 226 } 227 } 228 } 229 230 ################################################################################ 231 232 sub reverse_hash { 233 my (%hash) = @_; 234 235 my %reverse_hash; 236 foreach my $key (keys %hash) { 237 my $value = $hash{$key}; 238 if (defined $value and not ref $value) { 239 push @{ $reverse_hash{$value} }, $key; 240 #} else { 241 # my $val_string = defined $value ? $value : "UNDEF"; 242 # warn "pair ( $key => $val_string ) skipped.\n"; 243 } 244 } 245 246 return %reverse_hash; 247 } 248 249 sub listeq { 250 my ($a, $b) = @_; 251 return "" unless @$a == @$b; 252 for (my $i = 0; $i < @$a; $i++) { 253 return "" unless $a->[$i] eq $b->[$i]; 254 } 255 return 1; 256 } 257 258 sub is_empty { 259 my ($val) = @_; 260 return (not defined $val or $val eq ""); 261 } 262 263 ################################################################################ 264 265 sub set_up_roles { 266 my ($permissionLevels) = @_; 267 my %permissionLevels = %$permissionLevels; 268 269 my %level_to_role_id; 270 271 # reverse the permission levels hash, resulting in a hash mapping 272 # permissions levels to arrayrefs containing privileges 273 my %levels = reverse_hash(%permissionLevels); 274 275 # copy up the privileges at each level to the next-higher level 276 # also sort each level 277 my @level_names = sort { $a <=> $b } keys %levels; 278 foreach my $i (0 .. $#level_names-1) { 279 my $this_level = $level_names[$i]; 280 my $next_level = $level_names[$i+1]; 281 push @{ $levels{$next_level} }, @{ $levels{$this_level} }; 282 } 283 284 # sort the privileges in each level 285 debug("I found the following permission levels:\n"); 286 foreach my $level (keys %levels) { 287 my @sorted = sort @{ $levels{$level} }; 288 $levels{$level} = [ @sorted ]; 289 debug("\t$level => @sorted\n"); 290 } 291 292 # keep track of role names so we know if we need to rename any of our new ones 293 my %role_names; 294 295 # look at existing roles to see if we can avoid adding some new ones 296 my $i = retrieve_all WeBWorK::DBv3::Role; 297 while (my $Role = $i->next) { 298 $role_names{$Role->name} = 1; 299 my @role_privs = sort $Role->privs_list; 300 301 foreach my $level (keys %levels) { 302 if (listeq($levels{$level}, \@role_privs)) { 303 debug("Permission level '$level' is already represented as role '", 304 $Role->name, "' (ID $Role) -- skipping.\n"); 305 delete $levels{$level}; 306 $level_to_role_id{$level} = $Role->id; 307 } 308 } 309 } 310 311 if ($opt_r) { 312 debug("Updating role table (as per -r switch).\n"); 313 foreach my $level (keys %levels) { 314 my $name = "Legacy permission level $level"; 315 if (exists $role_names{$name}) { 316 my $i = 2; 317 while (1) { 318 my $try_name = "$name (#$i)"; 319 if (not exists $role_names{$try_name}) { 320 $name = $try_name; 321 last; 322 } 323 } 324 } 325 326 my @privs = @{ $levels{$level} }; 327 328 my $Role = create WeBWorK::DBv3::Role({name => $name}); 329 $Role->privs_list(@privs); 330 $Role->update; 331 debug("Added role '", $Role->name, "' (ID $Role) with privileges '@privs'.\n"); 332 $level_to_role_id{$level} = $Role->id; 333 } 334 } else { 335 debug("Not updating role table (as per lack of -r switch).\n"); 336 debug("I might run into users with permission levels that don't map to roles later.\n"); 337 } 338 339 return %level_to_role_id; 340 } 341 342 sub set_up_statuses { 343 my ($abbrevs) = @_; 344 my %abbrevs = %$abbrevs; 345 346 my %abbrev_to_status_id; 347 348 # reverse the statuses hash, resulting in a hash mapping statuses to 349 # arrayrefs containing abbreviations 350 my %statuses = reverse_hash(%abbrevs); 351 352 # look at existing statuses to see if we can avoid adding some new ones 353 my $i = retrieve_all WeBWorK::DBv3::Status; 354 while (my $Status = $i->next) { 355 if (exists $statuses{$Status->name}) { 356 debug("Status '", $Status->name, "' (ID $Status) already exists in the database -- skipping.\n"); 357 # add entries mapping abbreviations to the ID of this status 358 foreach my $abbrev (@{$statuses{$Status->name}}) { 359 $abbrev_to_status_id{$abbrev} = $Status->id; 360 } 361 362 delete $statuses{$Status->name}; 363 } 364 } 365 366 if ($opt_s) { 367 debug("Updating status table (as per -s switch).\n"); 368 foreach my $status (keys %statuses) { 369 my %flags; 370 %flags = %{ STATUS_MAP->{$status} } if exists STATUS_MAP->{$status}; 371 my $Status = create WeBWorK::DBv3::Status({name => $status, %flags}); 372 373 my @flags = grep { $flags{$_} } keys %flags; 374 debug("Added status '", $Status->name, "' (ID $Status) with flags '@flags'.\n"); 375 376 # add entries mapping abbreviations to the ID of this status 377 foreach my $abbrev (@{$statuses{$status}}) { 378 $abbrev_to_status_id{$abbrev} = $Status->id; 379 } 380 } 381 } else { 382 debug("Not updating status table (as per lack of -s switch).\n"); 383 debug("I might run into users with status abbreviations that don't map to statuses later.\n"); 384 } 385 386 return %abbrev_to_status_id; 387 } 388 389 ################################################################################ 390 391 sub copy_course_data { 392 my ($courseID, $abbrev_to_status_id, $level_to_role_id) = @_; 393 394 debug("Processing course '$courseID'...\n"); 395 396 my $course_ce = WeBWorK::CourseEnvironment->new({ 397 webwork_dir => $ENV{WEBWORK_ROOT}, 398 courseName => $courseID, 399 }); 400 401 my $course_db = WeBWorK::DB->new($course_ce->{dbLayout}); 402 403 debug("Adding course '$courseID' to v3 DB.\n"); 404 my $v3Course = eval { create WeBWorK::DBv3::Course({name => $courseID}) }; 405 $@ =~ /Duplicate entry/ and die "Course '$courseID' exists in v3 DB.\n"; 406 $@ and die $@; 407 408 copy_users($course_db, $v3Course, $abbrev_to_status_id, $level_to_role_id); 409 410 # { $globalSetID => [ $v3AbsSet->id, { $globalProblemID => $vAbsProb->id, ... }, ... } 411 my %global_set_id_to_abstract_set_data = copy_abstract_data($course_db, $v3Course); 412 } 413 414 ################################################################################ 415 416 sub copy_users { 417 my ($course_db, $v3Course, $abbrev_to_status_id, $level_to_role_id) = @_; 418 419 my $DefaultStatus = find_status(DEFAULT_STATUS, $abbrev_to_status_id); 420 die "Default status '", DEFAULT_STATUS, "' does not correspond to any v3 status.\n" 421 unless $DefaultStatus; 422 423 my $DefaultRole = find_role(DEFAULT_PERMISSION_LEVEL, $level_to_role_id); 424 die "Default permission level '", DEFAULT_PERMISSION_LEVEL, "' does not correspond to any v3 role.\n" 425 unless $DefaultRole; 426 427 my @userIDs = $course_db->listUsers; 428 my %Users; @Users{@userIDs} = $course_db->getUsers(@userIDs); 429 my %Passwords; @Passwords{@userIDs} = $course_db->getPasswords(@userIDs); 430 my %PermissionLevels; @PermissionLevels{@userIDs} = $course_db->getPermissionLevels(@userIDs); 431 432 foreach my $userID (keys %Users) { 433 my $User = $Users{$userID}; 434 my $Password = $Passwords{$userID}; 435 my $PermissionLevel = $PermissionLevels{$userID}; 436 437 unless (defined $User) { 438 debug("User record for user ID '$userID' not found -- skipping.\n"); 439 next; 440 } 441 442 debug("Processing user '$userID'...\n"); 443 444 # create/update user record 445 my ($v3User) = search WeBWorK::DBv3::User(login_id => $userID); 446 if ($v3User) { 447 debug("A user with login_id '$userID' exists in v3 database -- "); 448 if ($opt_u) { 449 # password record might not exist (annoying...) 450 my $password = defined $Password ? $Password->password : ""; 451 452 debug("updating (as per -u switch).\n"); 453 $v3User->first_name($User->first_name) unless is_empty($User->first_name); 454 $v3User->last_name($User->first_name) unless is_empty($User->last_name); 455 $v3User->email_address($User->email_address) unless is_empty($User->email_address); 456 $v3User->student_id($User->student_id) unless is_empty($User->student_id); 457 $v3User->password($password) unless is_empty($password); 458 $v3User->update; 459 } else { 460 debug("not updating (as per lack of -u switch).\n"); 461 } 462 } else { 463 # password record might not exist (annoying...) 464 my $password = defined $Password ? $Password->password : ""; 465 466 debug("No user with login_id '$userID' exists in v3 database -- adding.\n"); 467 $v3User = create WeBWorK::DBv3::User({ 468 first_name => $User->first_name, 469 last_name => $User->last_name, 470 email_address => $User->email_address, 471 student_id => $User->student_id, 472 login_id => $User->user_id, 473 password => $password, 474 }); 475 } 476 477 # get status 478 my $v3Status = find_status($User->status, $abbrev_to_status_id); 479 unless ($v3Status) { 480 debug("Using default status '", $DefaultStatus->name, "'.\n"); 481 $v3Status = $DefaultStatus; 482 } 483 484 # get role 485 my $level = defined $PermissionLevel ? $PermissionLevel->permission : ""; 486 my $v3Role = find_role($level, $level_to_role_id); 487 unless ($v3Role) { 488 debug("Using default role '", $DefaultRole->name, "'.\n"); 489 $v3Role = $DefaultRole; 490 } 491 492 # find/create section record 493 my $section = $User->section; 494 my $v3Section; 495 if (is_empty($section)) { 496 debug("This user has section '$section'.\n"); 497 ($v3Section) = search WeBWorK::DBv3::Section(course => $v3Course, name => $section); 498 if ($v3Section) { 499 debug("This corresponds to existing section ID $v3Section in v3 database.\n"); 500 } else { 501 debug("No corresponding section exists in v3 DB -- adding.\n"); 502 $v3Section = create WeBWorK::DBv3::Section({ 503 course => $v3Course, 504 name => $section, 505 }); 506 debug("Added section '", $v3Section->name, "' (ID $v3Section).\n"); 507 } 508 } else { 509 debug("This user has no section.\n"); 510 } 511 512 # find/create recitation record 513 my $recitation = $User->recitation; 514 my $v3Recitation; 515 if (is_empty($recitation)) { 516 debug("This user has recitation '$recitation'.\n"); 517 ($v3Recitation) = search WeBWorK::DBv3::Recitation(course => $v3Course, name => $User->recitation); 518 if ($v3Recitation) { 519 debug("This correponds to existing recitation ID $v3Recitation in v3 database.\n"); 520 } else { 521 debug("No corresponding recitation exists in v3 DB -- adding.\n"); 522 $v3Recitation = create WeBWorK::DBv3::Recitation({ 523 course => $v3Course, 524 name => $User->recitation, 525 }); 526 debug("Added recitation '", $v3Recitation->name, "' (ID $v3Recitation).\n"); 527 } 528 } else { 529 debug("This user has no recitation.\n"); 530 } 531 532 # create participant record 533 debug("Adding participant record for user '$userID'..."); 534 #my $sectionID = $v3Section->id if defined $v3Section; 535 #my $recitationID = $v3Recitation->id if defined $v3Recitation; 536 my $v3Participant = create WeBWorK::DBv3::Participant({ 537 course => $v3Course, 538 user => $v3User, 539 status => $v3Status, 540 role => $v3Role, 541 section => $v3Section, 542 recitation => $v3Recitation, 543 comment => $User->comment, 544 }); 545 debug(" added participant ID $v3Participant.\n"); 546 } 547 } 548 549 sub find_status { 550 my ($status, $abbrev_to_status_id) = @_; 551 552 return if is_empty($status); 553 554 my $v3Status_id = $abbrev_to_status_id->{$status}; 555 my $v3Status; 556 if (defined $v3Status_id) { 557 #debug("Status '$status' maps to v3 status ID '$v3Status_id'.\n"); 558 $v3Status = retrieve WeBWorK::DBv3::Status($v3Status_id); 559 } else { 560 #debug("Status '$status' doesn't map to any v3 status.\n"); 561 } 562 563 return $v3Status; 564 } 565 566 sub find_role { 567 my ($level, $level_to_role_id) = @_; 568 569 return if is_empty($level); 570 571 my $v3Role_id = $level_to_role_id->{$level}; 572 my $v3Role; 573 if (defined $v3Role_id) { 574 #debug("Permission level '$level' maps to v3 role ID '$v3Role_id'.\n"); 575 $v3Role = retrieve WeBWorK::DBv3::Role($v3Role_id); 576 } else { 577 #debug("Permission level '$level' doesn't map to any v3 role.\n"); 578 } 579 580 return $v3Role; 581 } 582 583 ################################################################################ 584 585 sub copy_abstract_data { 586 my ($course_db, $v3Course) = @_; 587 588 my %global_set_id_to_abstract_set_data; 589 590 my @globalSetIDs = $course_db->listGlobalSets; 591 my %GlobalSets; @GlobalSets{@globalSetIDs} = $course_db->getGlobalSets(@globalSetIDs); 592 593 foreach my $globalSetID (keys %GlobalSets) { 594 my $GlobalSet = $GlobalSets{$globalSetID}; 595 596 unless (defined $GlobalSet) { 597 debug("Global set record for global set ID '$globalSetID' not found -- skipping.\n"); 598 next; 599 } 600 601 debug("Processing global set '$globalSetID'...\n"); 602 603 # set up some fields that need setting up 604 # (if the conditional is false, the variable is left undefined) 605 606 # convert empty strings to undefined values 607 my $set_header = $GlobalSet->set_header unless is_empty($GlobalSet->set_header); 608 my $hardcopy_header = $GlobalSet->hardcopy_header unless is_empty($GlobalSet->hardcopy_header); 609 610 # convert 611 my $open_date = DateTime->from_epoch(epoch => $GlobalSet->open_date); 612 my $due_date = DateTime->from_epoch(epoch => $GlobalSet->due_date); 613 my $answer_date = DateTime->from_epoch(epoch => $GlobalSet->answer_date); 614 615 # create abstract_set record 616 debug("Adding abstract_set record for global set '$globalSetID'..."); 617 my $v3AbsSet = create WeBWorK::DBv3::AbstractSet({ 618 course => $v3Course, 619 name => $GlobalSet->set_id, 620 set_header => $set_header, 621 hardcopy_header => $hardcopy_header, 622 open_date => $open_date, 623 due_date => $due_date, 624 answer_date => $answer_date, 625 published => $GlobalSet->published, 626 }); 627 debug(" added abstract_set ID '$v3AbsSet'.\n"); 628 629 630 my %problem_mapping; 631 632 my @globalProblemIDs = sort { $a <=> $b } $course_db->listGlobalProblems($globalSetID); 633 warn "globalProblemIDs=@globalProblemIDs\n"; 634 my %GlobalProblems; @GlobalProblems{@globalProblemIDs} 635 = $course_db->getGlobalProblems(map { [ $globalSetID, $_ ] } @globalProblemIDs); 636 637 my @problem_order; 638 639 foreach my $globalProblemID (@globalProblemIDs) { 640 my $GlobalProblem = $GlobalProblems{$globalProblemID}; 641 642 unless (defined $GlobalProblem) { 643 warn "Global problem record for global problem ID '$globalProblemID' in set ID '$globalSetID' not found -- skipping.\n"; 644 next; 645 } 646 647 debug("Processing global problem '$globalProblemID'...\n"); 648 649 # convert max_attempts of -1 to undef 650 my $max_attempts_per_version = $GlobalProblem->max_attempts 651 if $GlobalProblem->max_attempts >= 0; 652 653 # create abstract_problem record 654 debug("Adding abstract_set record for global problem '$globalProblemID'..."); 655 my $v3AbsProb = create WeBWorK::DBv3::AbstractProblem({ 656 abstract_set => $v3AbsSet, 657 name => "Legacy problem $globalProblemID", 658 source_type => "file", 659 source_file => $GlobalProblem->source_file, 660 weight => $GlobalProblem->value, 661 max_attempts_per_version => $max_attempts_per_version, 662 version_creation_interval => undef, 663 versions_per_interval => 1, 664 version_due_date_offset => undef, 665 version_answer_date_offset => undef, 666 }); 667 debug(" added abstract_problem ID '$v3AbsProb'.\n"); 668 669 push @problem_order, $v3AbsProb->id; 670 671 $problem_mapping{$globalProblemID} = $v3AbsProb->id; 672 } 673 674 # update problem order 675 debug("Setting problem order to: '@problem_order'..."); 676 $v3AbsSet->problem_order_list(@problem_order); 677 $v3AbsSet->update; 678 debug(" done.\n"); 679 680 $global_set_id_to_abstract_set_data{$globalSetID} = [ $v3AbsSet->id, \%problem_mapping ]; 681 } 682 683 return %global_set_id_to_abstract_set_data; 684 } 685 686 ################################################################################ 687 688 sub copy_assignment_data { 689 my ($course_db, $v3Course, $global_set_id_to_abstract_set_data) = @_; 690 691 my $participant_iter = WeBWorK::DBv3::Participant->search(course => $v3Course); 692 693 while (my $Participant = $participant_iter->next) { 694 my @userSetIDs = $course_db->listUserSets($Participant->user->login_id); 695 $v3AbsSet-> 696 } 697 } 698 699 sub copy_single_assignment { 700 my ($course_db, $v3Course, $v3Participant, $v3AbsSet, $global_set_id_to_abstract_set_data) = @_; 701 702 703 }
| aubreyja at gmail dot com | ViewVC Help |
| Powered by ViewVC 1.0.9 |