Skip to content

Commit

Permalink
Add a page to manage jobs in the job queue.
Browse files Browse the repository at this point in the history
All jobs for a course are listed on this page. The table displays the
job id (this is used to reference specific jobs in action messages,
otherwise it would not be shown), task name, created time, started time,
finished time, and state.  Also a button that opens a popover containing
the job result is in the state column if the job has completed.

Note that the Minion job queue automatically removes jobs from the job
queue after two days (that is the default at least which we don't
change).  So the real importance of this page is to allow the instructor
to see the status of recently completed or in progress jobs.

At this point the actions available on the page are filter, sort, and
delete.  Jobs can be filtered by id, task name, or state. Jobs can be
sorted by clicking on the headers, or by using the sort form.  Jobs that
are not active can be deleted.

Minion does not allow deletion of active jobs.  Note that an active job
means a job that is currently running.  Also note that active jobs can
not be stopped (at least not immediately).  As such they can not be
selected to be deleted on this page.

An inactive job (i.e., a job that has been queued but has not started
running yet) can be selected and deleted.  However, it is possible that
the inactive job could start before the form is submitted.  In that case
the job can not be deleted, and so an alert will show that.

In order to reliably associate a course with a job there is a new rule
for tasks.  The job must pass the course id via the "notes" option of
the Minion enqueue method.  The existing tasks have been updated to do
this. There is also a backwards compatibility check to find jobs that
passed it one of the ways the two jobs did it before in the job
arguments.

Since the job fail/finish messages are now displayed in the UI, those
messages are now translated. That is all except the first few messages
in each task before the course environment is established, since a
course environment is required to perform translations.

The send_instructor_email task no longer sends an email to the
instructor after sending the emails to the students.  Instead the job
result contains all of the information that would have been in that
email. This is a far more reliable way of getting that information to
the instructor sending the email.  The instructor just needs to go to
the "Job Manager" page to see the result.  The message on the "Email"
page tells the instructor this.

This page is also available for the admin course.  In the admin course
all jobs for all courses are shown.  There is an additional column in
the jobs table that shows the course id for the course the job was
enqueued by.

The errors that are reported when sending emails are made less verbose
by calling the `message` method of the Mojo::Exception which does not
include the traceback.
  • Loading branch information
drgrice1 committed Oct 19, 2023
1 parent ab7cfef commit 47a1ce0
Show file tree
Hide file tree
Showing 19 changed files with 701 additions and 88 deletions.
45 changes: 45 additions & 0 deletions htdocs/js/JobManager/jobmanager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
(() => {
// 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 jobListForm = document.forms['joblist'];
const currentAction = document.getElementById('current_action');

if (jobListForm && 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';
jobListForm.append(sortInput);

jobListForm.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((popover) => {
new bootstrap.Popover(popover, { trigger: 'hover focus', customClass: 'job-queue-result-popover', html: true });
});
})();
8 changes: 8 additions & 0 deletions htdocs/js/JobManager/jobmanager.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.job-queue-result-popover {
--bs-popover-max-width: 500px;

.popover-body {
overflow-y: auto;
max-height: 25vh;
}
}
3 changes: 2 additions & 1 deletion lib/Mojolicious/WeBWorK.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
40 changes: 23 additions & 17 deletions lib/Mojolicious/WeBWorK/Tasks/LTIMassUpdate.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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;
94 changes: 43 additions & 51 deletions lib/Mojolicious/WeBWorK/Tasks/SendInstructorEmail.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion lib/WeBWorK/Authen/LTI/MassUpdate.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion lib/WeBWorK/ContentGenerator/Feedback.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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 : $_);
};
}

Expand Down
Loading

0 comments on commit 47a1ce0

Please sign in to comment.