diff --git a/htdocs/js/JobManager/jobmanager.js b/htdocs/js/JobManager/jobmanager.js new file mode 100644 index 0000000000..c64344835c --- /dev/null +++ b/htdocs/js/JobManager/jobmanager.js @@ -0,0 +1,47 @@ +(() => { + // Show/hide the filter elements depending on if the field matching option is selected. + const filter_select = document.getElementById('filter_select'); + const filter_elements = document.getElementById('filter_elements'); + if (filter_select && filter_elements) { + const toggle_filter_elements = () => { + if (filter_select.value === 'match_regex') filter_elements.style.display = 'block'; + else filter_elements.style.display = 'none'; + }; + filter_select.addEventListener('change', toggle_filter_elements); + toggle_filter_elements(); + } + + // Submit the job list form when a sort header is clicked or enter or space is pressed when it has focus. + const currentAction = document.getElementById('current_action'); + if (currentAction) { + for (const header of document.querySelectorAll('.sort-header')) { + const submitSortMethod = (e) => { + e.preventDefault(); + + currentAction.value = 'sort'; + + const sortInput = document.createElement('input'); + sortInput.name = 'labelSortMethod'; + sortInput.value = header.dataset.sortField; + sortInput.type = 'hidden'; + currentAction.form.append(sortInput); + + currentAction.form.submit(); + }; + + header.addEventListener('click', submitSortMethod); + header.addEventListener('keydown', (e) => { + if (e.key === ' ' || e.key === 'Enter') submitSortMethod(e); + }); + } + } + + // Activate the results popovers. + document.querySelectorAll('.result-popover-btn').forEach((popoverBtn) => { + new bootstrap.Popover(popoverBtn, { + trigger: 'hover focus', + customClass: 'job-queue-result-popover', + html: true + }); + }); +})(); diff --git a/htdocs/js/JobManager/jobmanager.scss b/htdocs/js/JobManager/jobmanager.scss new file mode 100644 index 0000000000..e5ed920f3a --- /dev/null +++ b/htdocs/js/JobManager/jobmanager.scss @@ -0,0 +1,8 @@ +.job-queue-result-popover { + --bs-popover-max-width: 500px; + + .popover-body { + overflow-y: auto; + max-height: 25vh; + } +} diff --git a/lib/Mojolicious/WeBWorK.pm b/lib/Mojolicious/WeBWorK.pm index 5d54412868..4079545e2b 100644 --- a/lib/Mojolicious/WeBWorK.pm +++ b/lib/Mojolicious/WeBWorK.pm @@ -84,7 +84,8 @@ sub startup ($app) { # Add the themes directory to the template search paths. push(@{ $app->renderer->paths }, $ce->{webworkDirs}{themes}); - # Setup the Minion job queue. + # Setup the Minion job queue. Make sure that any task added here is represented in the TASK_NAMES hash in + # WeBWorK::ContentGenerator::Instructor::JobManager. $app->plugin(Minion => { $ce->{job_queue}{backend} => $ce->{job_queue}{database_dsn} }); $app->minion->add_task(lti_mass_update => 'Mojolicious::WeBWorK::Tasks::LTIMassUpdate'); $app->minion->add_task(send_instructor_email => 'Mojolicious::WeBWorK::Tasks::SendInstructorEmail'); diff --git a/lib/Mojolicious/WeBWorK/Tasks/LTIMassUpdate.pm b/lib/Mojolicious/WeBWorK/Tasks/LTIMassUpdate.pm index 163f2eedd1..b43f8ddc03 100644 --- a/lib/Mojolicious/WeBWorK/Tasks/LTIMassUpdate.pm +++ b/lib/Mojolicious/WeBWorK/Tasks/LTIMassUpdate.pm @@ -22,27 +22,22 @@ use WeBWorK::CourseEnvironment; use WeBWorK::DB; # Perform a mass update of grades via LTI. -sub run ($job, $courseID, $userID = '', $setID = '') { - # Establish a lock guard that only allow 1 job at a time (technichally more than one could run at a time if a job +sub run ($job, $userID = '', $setID = '') { + # Establish a lock guard that only allows 1 job at a time (technically more than one could run at a time if a job # takes more than an hour to complete). As soon as a job completes (or fails) the lock is released and a new job - # can start. New jobs retry every minute until they can aquire their own lock. + # can start. New jobs retry every minute until they can acquire their own lock. return $job->retry({ delay => 60 }) unless my $guard = $job->minion->guard('lti_mass_update', 3600); + my $courseID = $job->info->{notes}{courseID}; + return $job->fail('The course id was not passed when this job was enqueued.') unless $courseID; + my $ce = eval { WeBWorK::CourseEnvironment->new({ courseName => $courseID }) }; - return $job->fail("Could not construct course environment for $courseID.") unless $ce; + return $job->fail('Could not construct course environment.') unless $ce; - my $db = WeBWorK::DB->new($ce->{dbLayout}); - return $job->fail("Could not obtain database connection for $courseID.") unless $db; + $job->{language_handle} = WeBWorK::Localize::getLoc($ce->{language} || 'en'); - if ($setID && $userID && $ce->{LTIGradeMode} eq 'homework') { - $job->app->log->info("LTI Mass Update: Starting grade update for user $userID and set $setID."); - } elsif ($setID && $ce->{LTIGradeMode} eq 'homework') { - $job->app->log->info("LTI Mass Update: Starting grade update for all users assigned to set $setID."); - } elsif ($userID) { - $job->app->log->info("LTI Mass Update: Starting grade update of all sets assigned to user $userID."); - } else { - $job->app->log->info('LTI Mass Update: Starting grade update for all sets and users.'); - } + my $db = WeBWorK::DB->new($ce->{dbLayout}); + return $job->fail($job->maketext('Could not obtain database connection.')) unless $db; # Pass a fake controller object that will work for the grader. my $grader = @@ -76,8 +71,19 @@ sub run ($job, $courseID, $userID = '', $setID = '') { } } - $job->app->log->info("Updated grades via LTI for course $courseID."); - return $job->finish("Updated grades via LTI for course $courseID."); + if ($setID && $userID && $ce->{LTIGradeMode} eq 'homework') { + return $job->finish($job->maketext('Updated grades via LTI for user [_1] and set [_2].', $userID, $setID)); + } elsif ($setID && $ce->{LTIGradeMode} eq 'homework') { + return $job->finish($job->maketext('Updated grades via LTI all users assigned to set [_1].', $setID)); + } elsif ($userID) { + return $job->finish($job->maketext('Updated grades via LTI of all sets assigned to user {_1]', $userID)); + } else { + return $job->finish($job->maketext('Updated grades via LTI for all sets and users')); + } +} + +sub maketext ($job, @args) { + return &{ $job->{language_handle} }(@args); } 1; diff --git a/lib/Mojolicious/WeBWorK/Tasks/SendInstructorEmail.pm b/lib/Mojolicious/WeBWorK/Tasks/SendInstructorEmail.pm index c3f65d91e4..9ae99c08eb 100644 --- a/lib/Mojolicious/WeBWorK/Tasks/SendInstructorEmail.pm +++ b/lib/Mojolicious/WeBWorK/Tasks/SendInstructorEmail.pm @@ -28,46 +28,46 @@ use WeBWorK::Utils qw/processEmailMessage createEmailSenderTransportSMTP/; # Send instructor email messages to students. # FIXME: This job currently allows multiple jobs to run at once. Should it be limited? sub run ($job, $mail_data) { - my $ce = eval { WeBWorK::CourseEnvironment->new({ courseName => $mail_data->{courseName} }) }; - return $job->fail("Could not construct course environment for $mail_data->{courseName}.") unless $ce; + my $courseID = $job->info->{notes}{courseID}; + return $job->fail('The course id was not passed when this job was enqueued.') unless $courseID; - my $db = WeBWorK::DB->new($ce->{dbLayout}); - return $job->fail("Could not obtain database connection for $mail_data->{courseName}.") unless $db; + my $ce = eval { WeBWorK::CourseEnvironment->new({ courseName => $courseID }) }; + return $job->fail('Could not construct course environment.') unless $ce; $job->{language_handle} = WeBWorK::Localize::getLoc($ce->{language} || 'en'); - my $result_message = eval { $job->mail_message_to_recipients($ce, $db, $mail_data) }; - if ($@) { - $result_message .= "An error occurred while trying to send email.\n" . "The error message is:\n\n$@\n\n"; - $job->app->log->error("An error occurred while trying to send email: $@\n"); - } + my $db = WeBWorK::DB->new($ce->{dbLayout}); + return $job->fail($job->maketext('Could not obtain database connection.')) unless $db; - eval { $job->email_notification($ce, $mail_data, $result_message) }; + my @result_messages = eval { $job->mail_message_to_recipients($ce, $db, $mail_data) }; if ($@) { - $job->app->log->error("An error occurred while trying to send the email notification: $@\n"); - return $job->fail("FAILURE: Unable to send email notifation to instructor."); + push(@result_messages, + $job->maketext('An error occurred while trying to send email.'), + $job->maketext('The error message is:'), + ref($@) ? $@->message : $@); + return $job->fail(\@result_messages); } - return $job->finish("SUCCESS: Email messages sent."); + return $job->finish(\@result_messages); } sub mail_message_to_recipients ($job, $ce, $db, $mail_data) { - my $result_message = ''; + my @result_messages; my $failed_messages = 0; - my $error_messages = ''; + my @error_messages; my @recipients = @{ $mail_data->{recipients} }; for my $recipient (@recipients) { - $error_messages = ''; + @error_messages = (); my $user_record = $db->getUser($recipient); unless ($user_record) { - $error_messages .= "Record for user $recipient not found\n"; + push(@error_messages, $job->maketext('Record for user [_1] not found.', $recipient)); next; } unless ($user_record->email_address =~ /\S/) { - $error_messages .= "User $recipient does not have an email address -- skipping\n"; + push(@error_messages, $job->maketext('User [_1] does not have an email address.', $recipient)); next; } @@ -86,52 +86,44 @@ sub mail_message_to_recipients ($job, $ce, $db, $mail_data) { transport => createEmailSenderTransportSMTP($ce), $ce->{mail}{set_return_path} ? (from => $ce->{mail}{set_return_path}) : () }); - debug 'email sent successfully to ' . $user_record->email_address; + debug 'Email successfully sent to ' . $user_record->email_address; }; if ($@) { - debug "Error sending email: $@"; - $error_messages .= "Error sending email: $@"; + my $exception_message = ref($@) ? $@->message : $@; + debug 'Error sending email to ' . $user_record->email_address . ": $exception_message"; + push( + @error_messages, + $job->maketext( + 'Error sending email to [_1]: [_2]', $user_record->email_address, $exception_message + ) + ); next; } - $result_message .= - $job->maketext('Message sent to [_1] at [_2].', $recipient, $user_record->email_address) . "\n" - unless $error_messages; + push(@result_messages, $job->maketext('Message sent to [_1] at [_2].', $recipient, $user_record->email_address)) + unless @error_messages; } continue { # Update failed messages before continuing loop. - if ($error_messages) { + if (@error_messages) { $failed_messages++; - $result_message .= $error_messages; + push(@result_messages, @error_messages); } } my $number_of_recipients = @recipients - $failed_messages; - return $job->maketext( - 'A message with the subject line "[_1]" has been sent to [quant,_2,recipient] in the class [_3]. ' - . 'There were [_4] message(s) that could not be sent.', - $mail_data->{subject}, $number_of_recipients, $mail_data->{courseName}, + return ( + $job->maketext( + 'A message with the subject line "[_1]" has been sent to [quant,_2,recipient].', + $mail_data->{subject}, $number_of_recipients + ), $failed_messages - ) - . "\n\n" - . $result_message; -} - -sub email_notification ($job, $ce, $mail_data, $result_message) { - my $email = - Email::Stuffer->to($mail_data->{defaultFrom})->from($mail_data->{defaultFrom})->subject('WeBWorK email sent') - ->text_body($result_message)->header('X-Remote-Host' => $mail_data->{remote_host}); - - eval { - $email->send_or_die({ - transport => createEmailSenderTransportSMTP($ce), - $ce->{mail}{set_return_path} ? (from => $ce->{mail}{set_return_path}) : () - }); - }; - $job->app->log->error("Error sending email: $@") if $@; - - $job->app->log->info("WeBWorK::Tasks::SendInstructorEmail: Instructor message sent from $mail_data->{defaultFrom}"); - - return; + ? ($job->maketext( + 'There [plural,_1,was,were] [quant,_1,message] that could not be sent.', + $failed_messages + )) + : (), + @result_messages + ); } sub maketext ($job, @args) { diff --git a/lib/WeBWorK/Authen/LTI/MassUpdate.pm b/lib/WeBWorK/Authen/LTI/MassUpdate.pm index cd4758b296..967b879e4a 100644 --- a/lib/WeBWorK/Authen/LTI/MassUpdate.pm +++ b/lib/WeBWorK/Authen/LTI/MassUpdate.pm @@ -63,7 +63,7 @@ sub mass_update ($c, $manual_update = 0, $userID = undef, $setID = undef) { } } - $c->minion->enqueue(lti_mass_update => [ $ce->{courseName}, $userID, $setID ]); + $c->minion->enqueue(lti_mass_update => [ $userID, $setID ], { notes => { courseID => $ce->{courseName} } }); return; } diff --git a/lib/WeBWorK/ContentGenerator/Feedback.pm b/lib/WeBWorK/ContentGenerator/Feedback.pm index dae2d403c7..2f21560811 100644 --- a/lib/WeBWorK/ContentGenerator/Feedback.pm +++ b/lib/WeBWorK/ContentGenerator/Feedback.pm @@ -250,7 +250,7 @@ $emailableURL $ce->{mail}{set_return_path} ? (from => $ce->{mail}{set_return_path}) : () }); } catch { - $c->stash->{send_error} = $c->maketext('Failed to send message: [_1]', $_); + $c->stash->{send_error} = $c->maketext('Failed to send message: [_1]', ref($_) ? $_->message : $_); }; } diff --git a/lib/WeBWorK/ContentGenerator/Instructor/JobManager.pm b/lib/WeBWorK/ContentGenerator/Instructor/JobManager.pm new file mode 100644 index 0000000000..cb8190c5c5 --- /dev/null +++ b/lib/WeBWorK/ContentGenerator/Instructor/JobManager.pm @@ -0,0 +1,205 @@ +################################################################################ +# WeBWorK Online Homework Delivery System +# Copyright © 2000-2021 The WeBWorK Project, https://github.com/openwebwork +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of either: (a) the GNU General Public License as published by the +# Free Software Foundation; either version 2, or (at your option) any later +# version, or (b) the "Artistic License" which comes with this package. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the +# Artistic License for more details. +################################################################################ + +package WeBWorK::ContentGenerator::Instructor::JobManager; +use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; + +=head1 NAME + +WeBWorK::ContentGenerator::Instructor::JobManager - Minion job queue job management + +=cut + +use WeBWorK::Utils qw(x); + +use constant ACTION_FORMS => [ [ filter => x('Filter') ], [ sort => x('Sort') ], [ delete => x('Delete') ] ]; + +# All tasks added in the Mojolicious::WeBWorK module need to be listed here. +use constant TASK_NAMES => { + lti_mass_update => x('LTI Mass Update'), + send_instructor_email => x('Send Instructor Email') +}; + +# This constant is not used. It is here so that gettext adds these strings to the translation files. +use constant JOB_STATES => [ x('inactive'), x('active'), x('finished'), x('failed') ]; + +use constant FIELDS => [ + [ id => x('Id') ], + [ courseID => x('Course Id') ], + [ task => x('Task') ], + [ created => x('Created') ], + [ started => x('Started') ], + [ finished => x('Finished') ], + [ state => x('State') ], +]; + +use constant SORT_SUBS => { + id => \&byJobID, + courseID => \&byCourseID, + task => \&byTask, + created => \&byCreatedTime, + started => \&byStartedTime, + finished => \&byFinishedTime, + state => \&byState +}; + +sub initialize ($c) { + $c->stash->{taskNames} = TASK_NAMES(); + $c->stash->{actionForms} = ACTION_FORMS(); + $c->stash->{fields} = $c->stash->{courseID} eq 'admin' ? FIELDS() : [ grep { $_ ne 'courseID' } @{ FIELDS() } ]; + $c->stash->{jobs} = {}; + $c->stash->{visibleJobs} = {}; + $c->stash->{selectedJobs} = {}; + $c->stash->{sortedJobs} = []; + $c->stash->{primarySortField} = $c->param('primarySortField') || 'created'; + $c->stash->{secondarySortField} = $c->param('secondarySortField') || 'task'; + $c->stash->{ternarySortField} = $c->param('ternarySortField') || 'state'; + + return unless $c->authz->hasPermissions($c->param('user'), 'access_instructor_tools'); + + # Get a list of all jobs. If this is not the admin course, then restrict to the jobs for this course. + my $jobs = $c->minion->jobs; + while (my $job = $jobs->next) { + # Get the course id from the job arguments for backwards compatibility with jobs before the job manager was + # added and the course id was moved to the notes. + unless (defined $job->{notes}{courseID}) { + if (ref($job->{args}[0]) eq 'HASH' && defined $job->{args}[0]{courseName}) { + $job->{notes}{courseID} = $job->{args}[0]{courseName}; + } else { + $job->{notes}{courseID} = $job->{args}[0]; + } + } + + # Copy the courseID from the notes hash directly to the job for convenience of access. Particularly, so that + # that the filter_handler method can access it the same as for other fields. + $job->{courseID} = $job->{notes}{courseID}; + + $c->stash->{jobs}{ $job->{id} } = $job + if $c->stash->{courseID} eq 'admin' || $job->{courseID} eq $c->stash->{courseID}; + } + + if (defined $c->param('visible_jobs')) { + $c->stash->{visibleJobs} = { map { $_ => 1 } @{ $c->every_param('visible_jobs') } }; + } elsif (defined $c->param('no_visible_jobs')) { + $c->stash->{visibleJobs} = {}; + } else { + $c->stash->{visibleJobs} = { map { $_ => 1 } keys %{ $c->stash->{jobs} } }; + } + + $c->stash->{selectedJobs} = { map { $_ => 1 } @{ $c->every_param('selected_jobs') // [] } }; + + my $actionID = $c->param('action'); + if ($actionID) { + my $actionHandler = "${actionID}_handler"; + die $c->maketext('Action [_1] not found', $actionID) unless $c->can($actionHandler); + $c->addgoodmessage($c->$actionHandler); + } + + # Sort jobs + my $primarySortSub = SORT_SUBS()->{ $c->stash->{primarySortField} }; + my $secondarySortSub = SORT_SUBS()->{ $c->stash->{secondarySortField} }; + my $ternarySortSub = SORT_SUBS()->{ $c->stash->{ternarySortField} }; + + # byJobID is included to ensure a definite sort order in case the + # first three sorts do not determine a proper order. + $c->stash->{sortedJobs} = [ + map { $_->{id} } + sort { &$primarySortSub || &$secondarySortSub || &$ternarySortSub || byJobID } + grep { $c->stash->{visibleJobs}{ $_->{id} } } (values %{ $c->stash->{jobs} }) + ]; + + return; +} + +sub filter_handler ($c) { + my $ce = $c->ce; + + my $scope = $c->param('action.filter.scope'); + if ($scope eq 'all') { + $c->stash->{visibleJobs} = { map { $_ => 1 } keys %{ $c->stash->{jobs} } }; + return $c->maketext('Showing all jobs.'); + } elsif ($scope eq 'selected') { + $c->stash->{visibleJobs} = $c->stash->{selectedJobs}; + return $c->maketext('Showing selected jobs.'); + } elsif ($scope eq 'match_regex') { + my $regex = $c->param('action.filter.text'); + my $field = $c->param('action.filter.field'); + $c->stash->{visibleJobs} = {}; + for my $jobID (keys %{ $c->stash->{jobs} }) { + $c->stash->{visibleJobs}{$jobID} = 1 if $c->stash->{jobs}{$jobID}{$field} =~ /^$regex/i; + } + return $c->maketext('Showing matching jobs.'); + } + + # This should never happen. As such it is not translated. + return 'Not filtering. Unknown filter given.'; +} + +sub sort_handler ($c) { + if (defined $c->param('labelSortMethod')) { + $c->stash->{ternarySortField} = $c->stash->{secondarySortField}; + $c->stash->{secondarySortField} = $c->stash->{primarySortField}; + $c->stash->{primarySortField} = $c->param('labelSortMethod'); + $c->param('action.sort.primary', $c->stash->{primarySortField}); + $c->param('action.sort.secondary', $c->stash->{secondarySortField}); + $c->param('action.sort.ternary', $c->stash->{ternarySortField}); + } else { + $c->stash->{primarySortField} = $c->param('action.sort.primary'); + $c->stash->{secondarySortField} = $c->param('action.sort.secondary'); + $c->stash->{ternarySortField} = $c->param('action.sort.ternary'); + } + + return $c->maketext( + 'Users sorted by [_1], then by [_2], then by [_3]', + $c->maketext((grep { $_->[0] eq $c->stash->{primarySortField} } @{ FIELDS() })[0][1]), + $c->maketext((grep { $_->[0] eq $c->stash->{secondarySortField} } @{ FIELDS() })[0][1]), + $c->maketext((grep { $_->[0] eq $c->stash->{ternarySortField} } @{ FIELDS() })[0][1]) + ); + +} + +sub delete_handler ($c) { + my $num = 0; + return $c->maketext('Deleted [quant,_1,job].', $num) if $c->param('action.delete.scope') eq 'none'; + + for my $jobID (keys %{ $c->stash->{selectedJobs} }) { + # If a job was inactive (not yet started) when the page was previously loaded, then it may be selected to be + # deleted. By the time the delete form is submitted the job may have started and may now be active. In that + # case it can not be deleted. + if ($c->stash->{jobs}{$jobID}{state} eq 'active') { + $c->addbadmessage( + $c->maketext('Unable to delete job [_1] as it has transitioned to an active state.', $jobID)); + next; + } + delete $c->stash->{jobs}{$jobID}; + delete $c->stash->{visibleJobs}{$jobID}; + delete $c->stash->{selectedJobs}{$jobID}; + $c->minion->job($jobID)->remove; + ++$num; + } + + return $c->maketext('Deleted [quant,_1,job].', $num); +} + +# Sort methods +sub byJobID { return $a->{id} <=> $b->{id} } +sub byCourseID { return lc $a->{courseID} cmp lc $b->{courseID} } +sub byTask { return $a->{task} cmp $b->{task} } +sub byCreatedTime { return $a->{created} <=> $b->{created} } +sub byStartedTime { return ($a->{started} || 0) <=> ($b->{started} || 0) } +sub byFinishedTime { return ($a->{finished} || 0) <=> ($b->{finished} || 0) } +sub byState { return $a->{state} cmp $b->{state} } + +1; diff --git a/lib/WeBWorK/ContentGenerator/Instructor/SendMail.pm b/lib/WeBWorK/ContentGenerator/Instructor/SendMail.pm index e4792cccd4..2ffe1d46bf 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/SendMail.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/SendMail.pm @@ -348,11 +348,10 @@ sub initialize ($c) { # we don't set the response until we're sure that email can be sent $c->{response} = 'send_email'; - # Do actual mailing in the after the response is sent, since it could take a long time - # FIXME we need to do a better job providing status notifications for long-running email jobs + # The emails are actually sent in the job queue, since it could take a long time. + # Note that the instructor can check the job manager page to see the status of the job. $c->minion->enqueue( send_instructor_email => [ { - courseName => $c->stash('courseID'), recipients => $c->{ra_send_to}, subject => $c->{subject}, text => ${ $c->{r_text} // \'' }, @@ -360,7 +359,8 @@ sub initialize ($c) { from => $c->{from}, defaultFrom => $c->{defaultFrom}, remote_host => $c->{remote_host}, - } ] + } ], + { notes => { courseID => $c->stash('courseID') } } ); } else { $c->addbadmessage($c->maketext(q{Didn't recognize action})); diff --git a/lib/WeBWorK/Utils/ProblemProcessing.pm b/lib/WeBWorK/Utils/ProblemProcessing.pm index 7f6fe132ed..d37c59c4ee 100644 --- a/lib/WeBWorK/Utils/ProblemProcessing.pm +++ b/lib/WeBWorK/Utils/ProblemProcessing.pm @@ -459,7 +459,7 @@ Comment: $comment }); debug('Successfully sent JITAR alert message'); } catch { - $c->log->error("Failed to send JITAR alert message: $_"); + $c->log->error('Failed to send JITAR alert message: ' . (ref($_) ? $_->message : $_)); }; return ''; diff --git a/lib/WeBWorK/Utils/Routes.pm b/lib/WeBWorK/Utils/Routes.pm index ce94a6b039..f08968c31a 100644 --- a/lib/WeBWorK/Utils/Routes.pm +++ b/lib/WeBWorK/Utils/Routes.pm @@ -103,6 +103,8 @@ PLEASE FOR THE LOVE OF GOD UPDATE THIS IF YOU CHANGE THE ROUTES BELOW!!! instructor_lti_update /$courseID/instructor/lti_update + instructor_job_manager /$courseID/instructor/job_manager + problem_list /$courseID/$setID problem_detail /$courseID/$setID/$problemID show_me_another /$courseID/$setID/$problemID/show_me_another @@ -327,6 +329,7 @@ my %routeParameters = ( instructor_progress instructor_problem_grader instructor_lti_update + instructor_job_manager ) ], module => 'Instructor::Index', path => '/instructor' @@ -480,6 +483,11 @@ my %routeParameters = ( module => 'Instructor::LTIUpdate', path => '/lti_update' }, + instructor_job_manager => { + title => x('Job Manager'), + module => 'Instructor::JobManager', + path => '/job_manager' + }, problem_list => { title => '[_2]', diff --git a/templates/ContentGenerator/Base/admin_links.html.ep b/templates/ContentGenerator/Base/admin_links.html.ep index 0739df6bb2..00d4116f89 100644 --- a/templates/ContentGenerator/Base/admin_links.html.ep +++ b/templates/ContentGenerator/Base/admin_links.html.ep @@ -11,14 +11,14 @@ % for ( % [ - % 'add_course', - % maketext('Add Courses'), - % { - % add_admin_users => 1, - % add_config_file => 1, - % add_dbLayout => 'sql_single', - % add_templates_course => $ce->{siteDefaults}{default_templates_course} || '' - % } + % 'add_course', + % maketext('Add Courses'), + % { + % add_admin_users => 1, + % add_config_file => 1, + % add_dbLayout => 'sql_single', + % add_templates_course => $ce->{siteDefaults}{default_templates_course} || '' + % } % ], % [ 'rename_course', maketext('Rename Courses') ], % [ 'delete_course', maketext('Delete Courses') ], @@ -27,8 +27,7 @@ % [ 'upgrade_course', maketext('Upgrade Courses') ], % [ 'hide_inactive_course', maketext('Hide Courses') ], % [ 'manage_locations', maketext('Manage Locations') ], - % ) - % { + % ) { + % } + % # Job Manager + % # File Manager % if ($authz->hasPermissions($userID, 'manage_course_files')) { diff --git a/templates/ContentGenerator/Instructor/JobManager.html.ep b/templates/ContentGenerator/Instructor/JobManager.html.ep new file mode 100644 index 0000000000..27a68ec442 --- /dev/null +++ b/templates/ContentGenerator/Instructor/JobManager.html.ep @@ -0,0 +1,180 @@ +% use WeBWorK::Utils qw(getAssetURL); +% +% content_for css => begin + <%= stylesheet getAssetURL($ce, 'js/JobManager/jobmanager.css') =%> +% end +% +% content_for js => begin + <%= javascript getAssetURL($ce, 'js/ActionTabs/actiontabs.js'), defer => undef =%> + <%= javascript getAssetURL($ce, 'js/SelectAll/selectall.js'), defer => undef =%> + <%= javascript getAssetURL($ce, 'js/JobManager/jobmanager.js'), defer => undef =%> +% end +% +% unless ($authz->hasPermissions(param('user'), 'access_instructor_tools')) { +
<%= maketext('You are not authorized to access instructor tools.') =%>
+ % last; +% } +% +% unless (keys %$jobs) { +
<%= maketext('No jobs in queue.') %>
+ % last; +% } +% +<%= form_for current_route, method => 'POST', name => 'joblist', begin =%> + <%= $c->hidden_authen_fields =%> + % + % if (keys %$visibleJobs) { + % for (keys %$visibleJobs) { + <%= hidden_field visible_jobs => $_ =%> + % } + % } else { + <%= hidden_field no_visible_jobs => '1' =%> + % } + % + <%= hidden_field primarySortField => $primarySortField =%> + <%= hidden_field secondarySortField => $secondarySortField =%> + <%= hidden_field ternarySortField => $ternarySortField =%> + % + % # Output action forms + % for my $form (@$actionForms) { + % my $active = $form->[0] eq 'filter' ? ' active' : ''; + % + % content_for 'tab-list' => begin + + % end + % + % content_for 'tab-content' => begin +
" id="<%= $form->[0] %>" + role="tabpanel" aria-labelledby="<%= $form->[0] %>-tab"> + <%= include "ContentGenerator/Instructor/JobManager/$form->[0]_form" =%> +
+ % end + % } + % + <%= hidden_field action => $actionForms->[0][0], id => 'current_action' =%> +
+ +
<%= content 'tab-content' %>
+
+ % +
+ <%= submit_button maketext($actionForms->[0][1]), id => 'take_action', class => 'btn btn-primary' =%> +
+ % + % # Show the jobs table +
+ + + + + + % if ($courseID eq 'admin') { + + % } + + + + + + + + + % for my $jobID (@$sortedJobs) { + + % if ($jobs->{$jobID}{state} eq 'active') { + % # Active jobs can not be deleted, and so a checkbox is not provided to select them. + + + % } else { + + + % } + % if ($courseID eq 'admin') { + + % } + + + + + + + % } + +
+ <%= check_box 'select-all' => 'on', id => 'select-all', + 'aria-label' => maketext('Select all jobs'), + data => { select_group => 'selected_jobs' }, + class => 'select-all form-check-input' =%> + + <%= label_for 'select-all' => + link_to maketext('Id') => '#', class => 'sort-header', data => { sort_field => 'id' } =%> + + <%= link_to maketext('Course Id') => '#', class => 'sort-header', + data => { sort_field => 'courseID' } =%> + + <%= link_to maketext('Task') => '#', class => 'sort-header', + data => { sort_field => 'task' } =%> + + <%= link_to maketext('Created') => '#', class => 'sort-header', + data => { sort_field => 'created' } =%> + + <%= link_to maketext('Started') => '#', class => 'sort-header', + data => { sort_field => 'started' } =%> + + <%= link_to maketext('Finished') => '#', class => 'sort-header', + data => { sort_field => 'finished' } =%> + + <%= link_to maketext('State') => '#', class => 'sort-header', + data => { sort_field => 'state' } =%> +
<%= $jobID =%> + <%= check_box selected_jobs => $jobID, id => "job_${jobID}_checkbox", + class => 'form-check-input', $selectedJobs->{$jobID} ? (checked => undef) : () =%> + <%= label_for "job_${jobID}_checkbox" => $jobID =%><%= $jobs->{$jobID}{courseID} =~ s/_/ /gr =%><%= maketext($taskNames->{ $jobs->{$jobID}{task} }) =%> + <%= $c->formatDateTime( + $jobs->{$jobID}{created}, '', 'datetime_format_medium', $ce->{language}) =%> + + % if ($jobs->{$jobID}{started}) { + <%= $c->formatDateTime( + $jobs->{$jobID}{started}, '', 'datetime_format_medium', $ce->{language}) =%> + % } + + % if ($jobs->{$jobID}{finished}) { + <%= $c->formatDateTime( + $jobs->{$jobID}{finished}, '', 'datetime_format_medium', $ce->{language}) =%> + % } + +
+ <%= maketext($jobs->{$jobID}{state}) =%> + % if (defined $jobs->{$jobID}{result}) { + % content_for "result_$jobID", begin + % if (ref($jobs->{$jobID}{result}) eq 'ARRAY') { +
    + % for (@{ $jobs->{$jobID}{result} } ) { +
  • <%= $_ %>
  • + % } +
+ % } else { + <%= $jobs->{$jobID}{result} =%> + % } + % end + xml_escape %>"> + + + <%= maketext('Result for job [_1]', $jobID) %> + + + % } +
+
+
+% end diff --git a/templates/ContentGenerator/Instructor/JobManager/delete_form.html.ep b/templates/ContentGenerator/Instructor/JobManager/delete_form.html.ep new file mode 100644 index 0000000000..944a84c44e --- /dev/null +++ b/templates/ContentGenerator/Instructor/JobManager/delete_form.html.ep @@ -0,0 +1,13 @@ +
+
+ <%= label_for delete_select => maketext('Delete which jobs?'), + class => 'col-form-label col-form-label-sm col-auto' =%> +
+ <%= select_field 'action.delete.scope' => [ + [ maketext('no jobs') => 'none', selected => undef ], + [ maketext('selected jobs') => 'selected' ] + ], + id => 'delete_select', class => 'form-select form-select-sm' =%> +
+
+
diff --git a/templates/ContentGenerator/Instructor/JobManager/filter_form.html.ep b/templates/ContentGenerator/Instructor/JobManager/filter_form.html.ep new file mode 100644 index 0000000000..eec3845e44 --- /dev/null +++ b/templates/ContentGenerator/Instructor/JobManager/filter_form.html.ep @@ -0,0 +1,38 @@ +
+
+ <%= label_for filter_select => maketext('Show which jobs?'), + class => 'col-form-label col-form-label-sm col-sm-auto' =%> +
+ <%= select_field 'action.filter.scope' => [ + [ maketext('all jobs') => 'all' ], + [ maketext('selected jobs') => 'selected' ], + [ maketext('jobs that match on selected field') => 'match_regex', selected => undef ] + ], + id => 'filter_select', class => 'form-select form-select-sm' =%> +
+
+
+
+ <%= label_for 'filter_type_select' => maketext('What field should filtered jobs match on?'), + class => 'col-form-label col-form-label-sm col-sm-auto' =%> +
+ <%= select_field 'action.filter.field' => [ + [ maketext('Id') => 'id', selected => undef ], + $courseID eq 'admin' ? [ maketext('Course Id') => 'courseID' ] : (), + [ maketext('Task') => 'task' ], + [ maketext('State') => 'state' ] + ], + id => 'filter_type_select', class => 'form-select form-select-sm' =%> +
+
+
+ <%= label_for 'filter_text', class => 'col-form-label col-form-label-sm col-sm-auto', begin =%> + <%= maketext('Filter by what text?') %>* + <% end =%> +
+ <%= text_field 'action.filter.text' => '', id => 'filter_text', 'aria-required' => 'true', + class => 'form-control form-control-sm' =%> +
+
+
+
diff --git a/templates/ContentGenerator/Instructor/JobManager/sort_form.html.ep b/templates/ContentGenerator/Instructor/JobManager/sort_form.html.ep new file mode 100644 index 0000000000..8f21fc6df5 --- /dev/null +++ b/templates/ContentGenerator/Instructor/JobManager/sort_form.html.ep @@ -0,0 +1,41 @@ +
+
+ <%= label_for sort_select_1 => maketext('Sort by') . ':', class => 'col-form-label col-form-label-sm', + style => 'width:4.5rem' =%> +
+ <%= select_field 'action.sort.primary' => [ + map { [ + maketext($_->[1]) => $_->[0], + $_->[0] eq 'created' ? (selected => undef) : () + ] } @$fields + ], + id => 'sort_select_1', class => 'form-select form-select-sm' =%> +
+
+
+ <%= label_for sort_select_2 => maketext('Then by') . ':', class => 'col-form-label col-form-label-sm', + style => 'width:4.5rem' =%> +
+ <%= select_field 'action.sort.secondary' => [ + map { [ + maketext($_->[1]) => $_->[0], + $_->[0] eq 'task' ? (selected => undef) : () + ] } @$fields + ], + id => 'sort_select_2', class => 'form-select form-select-sm' =%> +
+
+
+ <%= label_for sort_select_3 => maketext('Then by') . ':', class => 'col-form-label col-form-label-sm', + style => 'width:4.5rem' =%> +
+ <%= select_field 'action.sort.ternary' => [ + map { [ + maketext($_->[1]) => $_->[0], + $_->[0] eq 'state' ? (selected => undef) : () + ] } @$fields + ], + id => 'sort_select_3', class => 'form-select form-select-sm' =%> +
+
+
diff --git a/templates/ContentGenerator/Instructor/SendMail.html.ep b/templates/ContentGenerator/Instructor/SendMail.html.ep index 55f8819c74..66739fb6b0 100644 --- a/templates/ContentGenerator/Instructor/SendMail.html.ep +++ b/templates/ContentGenerator/Instructor/SendMail.html.ep @@ -23,8 +23,9 @@ % my $message = begin <%= maketext( - 'Email is being sent to [quant,_1,recipient]. You will be notified by email ' - . 'when the task is completed. This may take several minutes if the class is large.', + 'Email is being sent to [quant,_1,recipient]. ' + . 'This job may take several minutes to complete if the class is large. ' + . 'Go to the "Job Manager" to see the status of this job.', scalar(@{ $c->{ra_send_to} }) ) =%> diff --git a/templates/HelpFiles/InstructorJobManager.html.ep b/templates/HelpFiles/InstructorJobManager.html.ep new file mode 100644 index 0000000000..3ddf3325a0 --- /dev/null +++ b/templates/HelpFiles/InstructorJobManager.html.ep @@ -0,0 +1,80 @@ +%################################################################################ +%# WeBWorK Online Homework Delivery System +%# Copyright © 2000-2023 The WeBWorK Project, https://github.com/openwebwork +%# +%# This program is free software; you can redistribute it and/or modify it under +%# the terms of either: (a) the GNU General Public License as published by the +%# Free Software Foundation; either version 2, or (at your option) any later +%# version, or (b) the "Artistic License" which comes with this package. +%# +%# This program is distributed in the hope that it will be useful, but WITHOUT +%# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +%# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the +%# Artistic License for more details. +%################################################################################ +% +% layout 'help_macro'; +% title maketext('Job Manager Help'); +% +

+ <%= maketext('This page allows one to view and manage jobs in job queue. Note that completed jobs are ' + . 'automatically removed from the job queue after two days. So there is no real need to delete jobs. ' + . 'The importance of this page is to see the status of recently completed or in progress jobs.') =%> +

+

<%= maketext('Job Table Column Descriptions:') %>

+
+
<%= maketext('Id') %>
+
+ <%= maketext('The job id is an automatically incremented integer. It is used internally to uniquely identify ' + . 'jobs. It is also used to reference jobs in messages on this page.') =%> +
+
<%= maketext('Task') %>
+
+ <%= maketext('The name of the task.') =%> +
+
<%= maketext('Created') %>
+
+ <%= maketext('The time that the job was added to the queue.') =%> +
+
<%= maketext('Started') %>
+
+ <%= maketext('The time that execution of the job begins. If execution of the job has not started, ' + . 'then this will be empty.') =%> +
+
<%= maketext('Finished') %>
+
+ <%= maketext('The time that execution of the job completes. If execution of the job has not yet completed, ' + . 'then this will be empty.') =%> +
+
<%= maketext('State') %>
+
+ <%= maketext('The current state of the job. This will be one of "inactive", "active", "finished", or ' + . '"failed". If a job is "inactive" it means that the job has been added to the queue, but execution ' + . 'of the job has not yet started. If a job is "active" it means that the job is currently being ' + . 'executed. If a job is "finished" it means that the execution of the job has successfully ' + . 'completed. If a job is "failed" it means that the execution of job has completed, but there were ' + . 'errors in the execution of the job. If the job is in the "finished" or "failed" state, then there ' + . 'will also be a popover containing information about what happened when the job was executed.') =%> +
+
+% +

<%= maketext('Actions:') %>

+
+
<%= maketext('Filter') %>
+
+ <%= maketext('Filter jobs that are shown in the job table. Jobs can be filtered by Id, Task, or State, ' + . 'or by selection.') =%> +
+
<%= maketext('Sort') %>
+
+ <%= maketext('Sort jobs in the table by fields. The jobs in the table can also be sorted by clicking on ' + . 'column headers in the table.') =%> +
+
<%= maketext('Delete') %>
+
+ <%= maketext('Delete selected jobs. Note that jobs that are in the "active" state can not be deleted. ' + . 'Jobs that are in the "inactive" state can be deleted, but it is possible that by the time the request ' + . 'to delete the job occurs the job may have transitioned into the "active" state. In that case the job ' + . 'will not be deleted. Jobs that are in the "finished" or "failed" states can always be deleted.') =%> +
+