Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .typos.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[files]
extend-exclude = [
"lib/WeBWorK/Localize/*",
"courses.dist/modelCourse/html/achievements/*.png",
"htdocs/images/achievements/*.png",
]

[default]
Expand Down
9 changes: 8 additions & 1 deletion conf/defaults.config
Original file line number Diff line number Diff line change
Expand Up @@ -283,12 +283,15 @@ $webworkDirs{conf} = "$webworkDirs{root}/conf";
# Location of add-on configuration files.
$webworkDirs{addOnConf} = "$webworkDirs{conf}/addon";

# Location of assets (tex, pg, themes)
# Location of assets (tex, pg, themes, achievement evaluators)
$webworkDirs{assets} = "$webworkDirs{root}/assets";

# Location of the distribution hardcopy themes.
$webworkDirs{hardcopyThemes} = "$webworkDirs{assets}/hardcopyThemes";

# Location of system achievement evaluators.
$webworkDirs{achievementEvaluators} = "$webworkDirs{assets}/achievementEvaluators";

# Location of course directories.
$webworkDirs{courses} = "$webwork_courses_dir" || "$webworkDirs{root}/courses";

Expand Down Expand Up @@ -327,6 +330,10 @@ $webworkURLs{htdocs_temp} = "$webworkURLs{htdocs}/tmp";
# Location of theme templates.
$webworkDirs{themes} = "$webworkDirs{htdocs}/themes";

# Location of system achievement badge icons.
$webworkDirs{achievementIcons} = "$webworkDirs{htdocs}/images/achievements";
$webworkURLs{achievementIcons} = "$webworkURLs{htdocs}/images/achievements";

# Location of localization directory.
$webworkDirs{localize} = "$webworkDirs{root}/lib/WeBWorK/Localize";

Expand Down
1 change: 1 addition & 0 deletions courses.dist/modelCourse/html/achievements/README
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Place course specific achievement image icons/overrides here.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file added htdocs/images/achievements/back_for_more.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added htdocs/images/achievements/defaulticon.png
Binary file added htdocs/images/achievements/level_eight.png
Binary file added htdocs/images/achievements/level_five.png
Binary file added htdocs/images/achievements/level_four.png
Binary file added htdocs/images/achievements/level_one.png
Binary file added htdocs/images/achievements/level_seven.png
Binary file added htdocs/images/achievements/level_two.png
87 changes: 87 additions & 0 deletions htdocs/js/AchievementEditor/achievementeditor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
(() => {
// Action form validation.
// Store event listeners so they can be removed.
const event_listeners = {};

const show_errors = (ids, elements) => {
for (const id of ids) elements.push(document.getElementById(id));
for (const element of elements) {
if (element?.id.endsWith('_err_msg')) {
element.classList.remove('d-none');
} else if (element) {
element.classList.add('is-invalid');
if (!(element.id in event_listeners)) {
event_listeners[element.id] = hide_errors([], elements);
element.addEventListener('change', event_listeners[element.id]);
}
}
}
};

const hide_errors = (ids, elements) => {
return () => {
for (const id of ids) elements.push(document.getElementById(id));
for (const element of elements) {
if (element?.id.endsWith('_err_msg')) {
element.classList.add('d-none');
} else if (element) {
element.classList.remove('is-invalid');
if (element.id in event_listeners) {
element.removeEventListener('change', event_listeners[element.id]);
delete event_listeners[element.id];
}
}
}
};
};

document.getElementById('editor')?.addEventListener('submit', (e) => {
const action = document.getElementById('current_action')?.value || '';
if (action === 'save_as') {
const target_file_input = document.getElementById('action.save_as.target_file_id');
const filename = (target_file_input?.value || '') + (target_file_input?.value.endsWith('.at') ? '' : '.at');
if (filename.trim() === '' || filename.trim() == '.at') {
e.preventDefault();
e.stopPropagation();
show_errors(['blank_filename_err_msg'], [target_file_input]);
} else if (filename == target_file_input.dataset.originalName) {
e.preventDefault();
e.stopPropagation();
show_errors(['change_filename_err_msg'], [target_file_input]);
} else if (filename.includes('/')) {
e.preventDefault();
e.stopPropagation();
show_errors(['invalid_filename_err_msg'], [target_file_input]);
} else {
const selectedOption =
document.querySelector('input[name="action.save_as.saveMode"]:checked')?.value || '';
if (selectedOption === 'use_in_new') {
const newFilename = document.getElementById('action.save_as.new_id');
if (newFilename?.value.trim() === '') {
e.preventDefault();
e.stopPropagation();
show_errors(['blank_id_err_msg'], [newFilename]);
}
}
}
} else if (action === 'revert') {
const delete_confirm = document.getElementById('delete_select');
if (delete_confirm.value != 'yes') {
e.preventDefault();
e.stopPropagation();
show_errors(['delete_confirm_err_msg'], [delete_confirm]);
}
}
});

// Remove all error messages when changing tabs.
for (const tab of document.querySelectorAll('a[data-bs-toggle="tab"]')) {
tab.addEventListener('shown.bs.tab', () => {
if (Object.keys(event_listeners) != 0)
hide_errors(
[],
document.getElementById('editor')?.querySelectorAll('div[id$=_err_msg], .is-invalid')
)();
});
}
})();
32 changes: 32 additions & 0 deletions htdocs/js/AchievementList/achievementlist.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,38 @@
e.stopPropagation();
show_errors(['delete_confirm_err_msg'], [delete_confirm]);
}
} else if (action === 'save_edit') {
const invalid_inputs = Array.from(
document.querySelectorAll('input[name$=".test"], input[name$=".icon"]')
).filter((input) => input.value.includes('/'));
if (invalid_inputs.length) {
e.preventDefault();
e.stopPropagation();
const err_msg = document.getElementById('save_edit_filename_err_msg');
err_msg?.classList.remove('d-none');
for (const input of invalid_inputs) {
input.classList.add('is-invalid');
if (!(input.id in event_listeners)) {
event_listeners[input.id] = () => {
// Only clear this field's own invalid marking, and only once it is valid.
if (input.value.includes('/')) return;
input.classList.remove('is-invalid');
input.removeEventListener('change', event_listeners[input.id]);
delete event_listeners[input.id];

// Hide the shared error message once no fields remain invalid.
if (
!document.querySelector(
'input[name$=".test"].is-invalid, input[name$=".icon"].is-invalid'
)
) {
err_msg?.classList.add('d-none');
}
};
input.addEventListener('change', event_listeners[input.id]);
}
}
}
}
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
(() => {
// Action form validation.
// Store event listeners so they can be removed.
const event_listeners = {};

const show_errors = (ids, elements) => {
for (const id of ids) elements.push(document.getElementById(id));
for (const element of elements) {
if (element?.id.endsWith('_err_msg')) {
element.classList.remove('d-none');
} else if (element) {
element.classList.add('is-invalid');
if (!(element.id in event_listeners)) {
event_listeners[element.id] = hide_errors([], elements);
element.addEventListener('change', event_listeners[element.id]);
}
}
}
};

const hide_errors = (ids, elements) => {
return () => {
for (const id of ids) elements.push(document.getElementById(id));
for (const element of elements) {
if (element?.id.endsWith('_err_msg')) {
element.classList.add('d-none');
} else if (element) {
element.classList.remove('is-invalid');
if (element.id in event_listeners) {
element.removeEventListener('change', event_listeners[element.id]);
delete event_listeners[element.id];
}
}
}
};
};

document.getElementById('editor')?.addEventListener('submit', (e) => {
const action = document.getElementById('current_action')?.value || '';
if (action === 'save_as') {
const target_file_input = document.getElementById('action.save_as.target_file_id');
const filename = target_file_input?.value || '';
if (filename.trim() === '') {
e.preventDefault();
e.stopPropagation();
show_errors(['blank_filename_err_msg'], [target_file_input]);
} else if (filename == target_file_input.dataset.originalName) {
e.preventDefault();
e.stopPropagation();
show_errors(['change_filename_err_msg'], [target_file_input]);
} else if (filename.includes('/')) {
e.preventDefault();
e.stopPropagation();
show_errors(['invalid_filename_err_msg'], [target_file_input]);
}
}
});

// Remove all error messages when changing tabs.
for (const tab of document.querySelectorAll('a[data-bs-toggle="tab"]')) {
tab.addEventListener('shown.bs.tab', () => {
if (Object.keys(event_listeners).length)
hide_errors(
[],
document.getElementById('editor')?.querySelectorAll('div[id$=_err_msg], .is-invalid')
)();
});
}
})();
8 changes: 5 additions & 3 deletions lib/Mojolicious/WeBWorK/Tasks/AchievementNotification.pm
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ sub send_achievement_notification ($job, $ce, $db, $mail_data) {
die "User $mail_data->{recipient} does not have an email address -- skipping\n"
unless ($user_record->email_address =~ /\S/);

my $email_template = $mail_data->{achievement}{email_template};
die "Achievement notification template file \"$email_template\" contains a slash and cannot be used.\n"
if $email_template =~ /\//;

my $compartment = WeBWorK::WWSafe->new;
$compartment->share_from('main',
[qw(%Encode:: %Mojo::Base:: %Mojo::Exception:: %Mojo::Template:: %WeBWorK::SafeTemplate::)]);
Expand All @@ -74,9 +78,7 @@ sub send_achievement_notification ($job, $ce, $db, $mail_data) {
pointsEarned => $mail_data->{pointsEarned}
};

our $template =
Mojo::File->new("$ce->{courseDirs}{achievement_notifications}/$mail_data->{achievement}{email_template}")
->slurp;
our $template = Mojo::File->new("$ce->{courseDirs}{achievement_notifications}/$email_template")->slurp;
$compartment->share(qw($template $template_vars));

my $body = $compartment->reval(
Expand Down
25 changes: 19 additions & 6 deletions lib/WeBWorK/AchievementEvaluator.pm
Original file line number Diff line number Diff line change
Expand Up @@ -195,18 +195,31 @@ sub checkForAchievements ($problem_in, $c, %options) {
$counter = $userAchievement->counter;
$maxCounter = $achievement->max_counter;

# Determine location of achievement evaluator.
my $sourceFilePath;
my $sourceFile = $achievement->test;
if (!$sourceFile) {
warn qq{No achievement evaluator provided for "$achievement_id".};
next;
} elsif ($sourceFile =~ /\//) {
warn qq{Achievement evaluator "$sourceFile" for "$achievement_id" contains a slash.};
next;
} elsif (-f "$ce->{courseDirs}{achievements}/$sourceFile") {
$sourceFilePath = "$ce->{courseDirs}{achievements}/$sourceFile";
} elsif (-f "$ce->{webworkDirs}{achievementEvaluators}/$sourceFile") {
$sourceFilePath = "$ce->{webworkDirs}{achievementEvaluators}/$sourceFile";
} else {
warn qq{Could not find achievement evaluator file "$sourceFile" for "$achievement_id".};
next;
}

# Check the achievement using Safe.
my $sourceFilePath = $ce->{courseDirs}{achievements} . '/' . $achievement->test;
if (-e $sourceFilePath) {
{
local $/ = undef;
open(my $SOURCE, '<', $sourceFilePath);
$source = <$SOURCE>;
close($SOURCE);
} else {
warn('Couldnt find achievement evaluator $sourceFilePath');
next;
}

my $earned = $compartment->reval($preamble . "\n" . $source);
warn "There were errors in achievement $achievement_id\n" . $@ if $@;

Expand Down
Loading