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') ], - % ) - % { + % ) {
+ <%= 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' } =%> + | + % if ($courseID eq 'admin') { ++ <%= 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 =%> | + % } else { ++ <%= check_box selected_jobs => $jobID, id => "job_${jobID}_checkbox", + class => 'form-check-input', $selectedJobs->{$jobID} ? (checked => undef) : () =%> + | +<%= label_for "job_${jobID}_checkbox" => $jobID =%> | + % } + % if ($courseID eq 'admin') { +<%= $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') {
+
+
|
+
+ <%= 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.') =%> +
+