diff --git a/lib/WeBWorK/ContentGenerator/CourseAdmin.pm b/lib/WeBWorK/ContentGenerator/CourseAdmin.pm index 945bf0306c..43d08923bf 100644 --- a/lib/WeBWorK/ContentGenerator/CourseAdmin.pm +++ b/lib/WeBWorK/ContentGenerator/CourseAdmin.pm @@ -155,6 +155,24 @@ sub initialize ($c) { } else { $method_to_call = 'unarchive_course_form'; } + } elsif ($subDisplay eq 'clean_course') { + if (defined $c->param('clean_course')) { + @errors = $c->clean_course_validate; + if (@errors) { + $method_to_call = 'clean_course_form'; + } else { + $method_to_call = 'clean_course_confirm'; + } + } elsif (defined $c->param('confirm_clean_course')) { + @errors = $c->clean_course_validate; + if (@errors) { + $method_to_call = 'clean_course_form'; + } else { + $method_to_call = 'do_clean_course'; + } + } else { + $method_to_call = 'clean_course_form'; + } } elsif ($subDisplay eq 'upgrade_course') { if (defined $c->param('upgrade_course')) { @errors = $c->upgrade_course_validate; @@ -1195,7 +1213,7 @@ sub do_unarchive_course ($c) { my $unarchive_courseID = $c->param('unarchive_courseID') || ''; - eval { + my $dbRestoreType = eval { unarchiveCourse( newCourseID => $new_courseID, archivePath => "$ce->{webworkDirs}{courses}/$ce->{admin_course_id}/archives/$unarchive_courseID", @@ -1217,29 +1235,7 @@ sub do_unarchive_course ($c) { } else { writeLog($ce, 'hosted_courses', join("\t", "\tunarchived", '', '', "$unarchive_courseID to $new_courseID",)); - if ($c->param('clean_up_course')) { - my $ce_new = WeBWorK::CourseEnvironment->new({ courseName => $new_courseID }); - my $db_new = WeBWorK::DB->new($ce_new); - - for my $student_id ($db_new->listPermissionLevelsWhere({ permission => $ce->{userRoles}{student} })) { - $db_new->deleteUser($student_id->[0]); - } - - for my $file (values %{ $ce_new->{courseFiles}{logs} }) { - eval { Mojo::File->new($file)->remove }; - $c->addbadmessage($c->maketext('Failed to remove file [_1]: [_2]', $file, $@)) if $@; - } - - if (-d $ce_new->{courseDirs}{scoring}) { - eval { Mojo::File->new($ce_new->{courseDirs}{scoring})->remove_tree({ keep_root => 1 }) }; - $c->addbadmessage($c->maketext('Failed to remove scoring files: [_1]', $@)) if $@; - } - - if (-d $ce_new->{courseDirs}{tmpEditFileDir}) { - eval { Mojo::File->new($ce_new->{courseDirs}{tmpEditFileDir})->remove_tree({ keep_root => 1 }) }; - $c->addbadmessage($c->maketext('Failed to remove temporary edited files: [_1]', $@)) if $@; - } - } + $c->clean_up_course($new_courseID) if $c->param('clean_up_course') && $dbRestoreType eq 'dbdump'; return $c->c( $c->tag( @@ -1247,6 +1243,19 @@ sub do_unarchive_course ($c) { class => 'alert alert-success p-1 mb-2', $c->maketext('Successfully unarchived [_1] to the course [_2]', $unarchive_courseID, $new_courseID), ), + $c->param('clean_up_course') && $dbRestoreType ne 'dbdump' + ? $c->tag( + 'div', + class => 'alert alert-warning p-1 mb-2', + $dbRestoreType eq 'mysqldump' + ? $c->maketext( + 'The course could not be cleaned because it was archived with an older version of WeBWorK. ' + . 'Upgrade the course, and then use the "Clean Course" page to clean up the course.' + ) + : $c->maketext( + 'The course could not be cleaned because the archive does not contain the course database.') + ) + : '', $c->tag( 'div', class => 'd-flex justify-content-between', @@ -1290,6 +1299,112 @@ sub do_unarchive_course ($c) { } } +sub clean_course_form ($c) { + return $c->include( + 'ContentGenerator/CourseAdmin/clean_course_form', + courseIDs => [ + sort { lc($a) cmp lc($b) } + grep { $_ ne $c->stash('courseID') && $_ ne 'modelCourse' } listCourses($c->ce) + ] + ); +} + +sub clean_course_validate ($c) { + my $clean_courseID = $c->param('clean_courseID') || ''; + + return $c->maketext('You must specify a course name.') unless $clean_courseID; + return $c->maketext('You cannot clean the course you are currently using.') + if $clean_courseID eq $c->stash('courseID'); + return $c->maketext('The course [_1] does not exist.', $clean_courseID) + if $clean_courseID eq 'modelCourse' || !grep { $_ eq $clean_courseID } listCourses($c->ce); + + my (undef, $dbStatus) = + WeBWorK::Utils::CourseDBIntegrityCheck->new(WeBWorK::CourseEnvironment->new({ courseName => $clean_courseID })) + ->checkCourseTables($clean_courseID); + return $c->maketext( + 'The database for the course [_1] must be upgraded before the course can be cleaned. ' + . 'Use the "Upgrade Courses" page to upgrade the course.', + $clean_courseID + ) unless $c->formatReportOnDatabaseTables($dbStatus)->{all_tables_ok}; + + return; +} + +sub clean_course_confirm ($c) { + return $c->include('ContentGenerator/CourseAdmin/clean_course_confirm'); +} + +sub do_clean_course ($c) { + my $clean_courseID = $c->param('clean_courseID'); + + return $c->tag( + 'div', + class => 'alert alert-danger p-1 my-2', + $c->maketext('An error occurred while cleaning the course [_1].', $clean_courseID) + ) unless $c->clean_up_course($clean_courseID); + + writeLog($c->ce, 'hosted_courses', join("\t", "\tCleaned", '', '', $clean_courseID)); + + return $c->c( + $c->tag( + 'div', + class => 'alert alert-success p-1 my-2', + $c->maketext('Successfully cleaned the course [_1].', $clean_courseID), + ), + $c->form_for( + $c->current_route, + method => 'POST', + $c->c( + $c->hidden_authen_fields, + $c->hidden_fields('subDisplay'), + $c->tag( + 'div', + class => 'text-center', + $c->submit_button( + $c->maketext('OK'), + name => 'decline_clean_course', + class => 'btn btn-primary' + ) + ) + )->join('') + ) + )->join(''); +} + +# Remove student users, log files, scoring files, and temporary edited files from the course. The course database must +# be up to date when this is called. Returns 1 if the student users were deleted, and 0 otherwise. +sub clean_up_course ($c, $courseID) { + my $ce = WeBWorK::CourseEnvironment->new({ courseName => $courseID }); + my $db = WeBWorK::DB->new($ce); + + eval { + for my $student_id ($db->listPermissionLevelsWhere({ permission => $ce->{userRoles}{student} })) { + $db->deleteUser($student_id->[0]); + } + }; + if ($@) { + $c->addbadmessage($c->maketext('Failed to remove student users: [_1]', ref $@ ? $@->message : $@)); + return 0; + } + + for my $file (values %{ $ce->{courseFiles}{logs} }) { + eval { Mojo::File->new($file)->remove }; + $c->addbadmessage($c->maketext('Failed to remove file [_1]: [_2]', $file, $@)) if $@; + } + + if (-d $ce->{courseDirs}{scoring}) { + eval { Mojo::File->new($ce->{courseDirs}{scoring})->remove_tree({ keep_root => 1 }) }; + $c->addbadmessage($c->maketext('Failed to remove scoring files: [_1]', $@)) if $@; + } + + if (-d $ce->{courseDirs}{tmpEditFileDir}) { + eval { Mojo::File->new($ce->{courseDirs}{tmpEditFileDir})->remove_tree({ keep_root => 1 }) }; + $c->addbadmessage($c->maketext('Failed to remove temporary edited files: [_1]', $@)) if $@; + } + + return 1; +} + # Course upgrade methods sub upgrade_course_form ($c) { diff --git a/lib/WeBWorK/Utils/CourseManagement.pm b/lib/WeBWorK/Utils/CourseManagement.pm index 8b40912545..75b5850149 100644 --- a/lib/WeBWorK/Utils/CourseManagement.pm +++ b/lib/WeBWorK/Utils/CourseManagement.pm @@ -987,6 +987,14 @@ $ce is a WeBWorK::CourseEnvironment object that describes the some course's environment. (Usually this would be the admin course.) This is used to access the course database and get path information. +Returns a string indicating how the course database was restored. This is +'dbdump' if the database was restored from a JSON database dump, 'mysqldump' if +the database was restored from a legacy mysqldump archive, and 'none' if the +archive does not contain a database dump. If 'mysqldump' is returned, then the +database tables have not been upgraded and may not be compatible with the +current version of WeBWorK until the course is upgraded. If 'none' is returned, +then the course has no database tables. + If an error occurs, an exception is thrown. =cut @@ -1053,7 +1061,7 @@ sub unarchiveCourse { my $ce2 = WeBWorK::CourseEnvironment->new({ get_SeedCE($ce), courseName => $currCourseID }); # Step 5: Restore the database tables and delete the database dump directory. - my $no_database; + my $dbRestoreType = 'dbdump'; my $restore_db_result = 1; my $dbdump_dir = path("$ce2->{courseDirs}{DATA}/dbdump"); if (-e $dbdump_dir) { @@ -1065,10 +1073,11 @@ sub unarchiveCourse { if (-e $mysqldump_dir) { $restore_db_result = WeBWorK::DB->new($ce2)->restore_tables_mysql($mysqldump_dir); _archiveCourse_remove_dump_dir($ce, $mysqldump_dir); + $dbRestoreType = 'mysqldump'; } else { warn "course '$currCourseID' has no database dump in its data directory " . "(checked for $dbdump_dir and $mysqldump_dir). database tables will not be restored.\n"; - $no_database = 1; + $dbRestoreType = 'none'; } } @@ -1109,14 +1118,14 @@ sub unarchiveCourse { courseID => $currCourseID, ce => $ce2, newCourseID => $newCourseID, - skipDBRename => $no_database, + skipDBRename => $dbRestoreType eq 'none', ); } # Step 9: Return a conflicting course to its rightful place. _unarchiveCourse_move_back($restoreCourseData); - return; + return $dbRestoreType; } sub _unarchiveCourse_move_away { diff --git a/templates/ContentGenerator/Base/admin_links.html.ep b/templates/ContentGenerator/Base/admin_links.html.ep index 29ba88b506..f35f17b3a4 100644 --- a/templates/ContentGenerator/Base/admin_links.html.ep +++ b/templates/ContentGenerator/Base/admin_links.html.ep @@ -24,6 +24,7 @@ % [ 'delete_course', maketext('Delete Course') ], % [ 'archive_course', maketext('Archive Courses') ], % [ 'unarchive_course', maketext('Unarchive Course') ], + % [ 'clean_course', maketext('Clean Course') ], % [ 'upgrade_course', maketext('Upgrade Courses') ], % [ 'hide_inactive_course', maketext('Hide Courses') ], % [ 'manage_locations', maketext('Manage Locations') ], diff --git a/templates/ContentGenerator/CourseAdmin/clean_course_confirm.html.ep b/templates/ContentGenerator/CourseAdmin/clean_course_confirm.html.ep new file mode 100644 index 0000000000..37c2992ae7 --- /dev/null +++ b/templates/ContentGenerator/CourseAdmin/clean_course_confirm.html.ep @@ -0,0 +1,19 @@ +

<%= maketext('Clean Course') %> <%= $c->helpMacro('AdminCleanCourse') %>

+

+ <%== maketext( + 'Are you sure you want to clean the course [_1]? All student users and their associated data, the log ' + . 'files, any files in the scoring folder, and any temporary edited files will be deleted. ' + . 'There is no undo available.', + tag('b', param('clean_courseID') || '') + ) =%> +

+<%= form_for current_route, method => 'POST', begin =%> + <%= $c->hidden_authen_fields =%> + <%= $c->hidden_fields('subDisplay') =%> + <%= $c->hidden_fields('clean_courseID') =%> + % +
+ <%= submit_button maketext("Don't clean"), name => 'decline_clean_course', class => 'btn btn-primary' =%> + <%= submit_button maketext('Clean'), name => 'confirm_clean_course', class => 'btn btn-primary' =%> +
+<% end =%> diff --git a/templates/ContentGenerator/CourseAdmin/clean_course_form.html.ep b/templates/ContentGenerator/CourseAdmin/clean_course_form.html.ep new file mode 100644 index 0000000000..e2c35eef84 --- /dev/null +++ b/templates/ContentGenerator/CourseAdmin/clean_course_form.html.ep @@ -0,0 +1,28 @@ +

<%= maketext('Clean Course') %> <%= $c->helpMacro('AdminCleanCourse') %>

+% +% if (@$courseIDs) { + <%= form_for current_route, method => 'POST', begin =%> + <%= $c->hidden_authen_fields =%> + <%= $c->hidden_fields('subDisplay') =%> +
+ <%= maketext('Select a course to clean. This will remove student users and their associated data from ' + . 'the database. It will also remove the log files, any files in the scoring folder, and any ' + . 'temporary edited files.') =%> +
+
+ <%= label_for clean_courseID => maketext('Course ID:'), class => 'col-auto col-form-label fw-bold' =%> +
+ <%= select_field + clean_courseID => $courseIDs, + id => 'clean_courseID', + size => 15, + class => 'form-select' =%> +
+
+
+ <%= submit_button maketext('Clean Course'), name => 'clean_course', class => 'btn btn-primary' =%> +
+ <%= end =%> +% } else { +

<%= maketext('No courses found') %>

+% } diff --git a/templates/HelpFiles/AdminCleanCourse.html.ep b/templates/HelpFiles/AdminCleanCourse.html.ep new file mode 100644 index 0000000000..b7c97c60a0 --- /dev/null +++ b/templates/HelpFiles/AdminCleanCourse.html.ep @@ -0,0 +1,12 @@ +% layout 'help_macro'; +% title maketext('Clean Course Help'); +% +

+ <%= maketext('Select a course to clean. This will remove student users and their associated data from the ' + . 'database. It will also remove the log files, any files in the scoring folder, and any temporary ' + . 'edited files. Warning, this cannot be undone.') =%> +

+

+ <%= maketext('The course database must be up to date before the course can be cleaned. If the course was created ' + . 'with an older version of WeBWorK, use the "Upgrade Courses" page to upgrade the course first.') =%> +

diff --git a/templates/HelpFiles/AdminUnarchiveCourse.html.ep b/templates/HelpFiles/AdminUnarchiveCourse.html.ep index 5075386768..104d54e41b 100644 --- a/templates/HelpFiles/AdminUnarchiveCourse.html.ep +++ b/templates/HelpFiles/AdminUnarchiveCourse.html.ep @@ -14,5 +14,7 @@

<%= maketext('You may check the box to "clean" the unarchived course. This will remove student users ' . 'and their associated data from the database after the course is unarchived. It will also remove ' - . 'the log files, any files in the scoring folder, and any temporary edited files.') =%> + . 'the log files, any files in the scoring folder, and any temporary edited files. If the archive was ' + . 'created with an older version of WeBWorK, then the course cannot be cleaned when it is unarchived. ' + . 'In that case, upgrade the course and then use the "Clean Course" page to clean the course.') =%>

diff --git a/templates/HelpFiles/admin_links.html.ep b/templates/HelpFiles/admin_links.html.ep index 82dc17564a..f7f2170757 100644 --- a/templates/HelpFiles/admin_links.html.ep +++ b/templates/HelpFiles/admin_links.html.ep @@ -26,6 +26,10 @@
<%= maketext(q{Create a .tar.gz archive which includes the course's database and all course files.}) =%>
<%= maketext('Unarchive Course') %>
<%= maketext(q{Restore a .tar.gz archive.}) =%>
+
<%= maketext('Clean Course') %>
+
+ <%= maketext('Remove student users, log files, scoring files, and temporary edited files from a course.') =%> +
<%= maketext('Upgrade Courses') %>
<%= maketext('Upgrade courses from a previous version of WeBWorK.') =%>
<%= maketext('Hide Courses') %>