diff --git a/Dockerfile b/Dockerfile index 349bb61816..f735e07e69 100644 --- a/Dockerfile +++ b/Dockerfile @@ -71,6 +71,7 @@ RUN apt-get update \ imagemagick \ iputils-ping \ jq \ + libarchive-extract-perl \ libarchive-zip-perl \ libarray-utils-perl \ libc6-dev \ @@ -184,7 +185,7 @@ RUN apt-get update \ # ================================================================== # Phase 4 - Install additional Perl modules from CPAN that are not packaged for Ubuntu or are outdated in Ubuntu. -RUN cpanm install Statistics::R::IO DBD::MariaDB Mojo::SQLite@3.002 Perl::Tidy@20220613 \ +RUN cpanm install Statistics::R::IO DBD::MariaDB Mojo::SQLite@3.002 Perl::Tidy@20220613 Archive::Zip::SimpleZip \ && rm -fr ./cpanm /root/.cpanm /tmp/* # ================================================================== diff --git a/DockerfileStage1 b/DockerfileStage1 index d468ec0785..85357f3597 100644 --- a/DockerfileStage1 +++ b/DockerfileStage1 @@ -33,6 +33,7 @@ RUN apt-get update \ imagemagick \ iputils-ping \ jq \ + libarchive-extract-perl \ libarchive-zip-perl \ libarray-utils-perl \ libc6-dev \ @@ -146,7 +147,7 @@ RUN apt-get update \ # ================================================================== # Phase 3 - Install additional Perl modules from CPAN that are not packaged for Ubuntu or are outdated in Ubuntu. -RUN cpanm install -n Statistics::R::IO DBD::MariaDB Mojo::SQLite@3.002 Perl::Tidy@20220613 \ +RUN cpanm install -n Statistics::R::IO DBD::MariaDB Mojo::SQLite@3.002 Perl::Tidy@20220613 Archive::Zip::SimpleZip \ && rm -fr ./cpanm /root/.cpanm /tmp/* # ================================================================== diff --git a/bin/check_modules.pl b/bin/check_modules.pl index e27900f784..3c7d3c4dc1 100755 --- a/bin/check_modules.pl +++ b/bin/check_modules.pl @@ -66,6 +66,7 @@ =head1 DESCRIPTION my @modulesList = qw( Archive::Zip + Archive::Zip::SimpleZip Array::Utils Benchmark Carp diff --git a/bin/dev_scripts/update-localization-files b/bin/dev_scripts/update-localization-files index 37c62152ee..6b2193d97f 100755 --- a/bin/dev_scripts/update-localization-files +++ b/bin/dev_scripts/update-localization-files @@ -39,8 +39,8 @@ do esac done -if [ -z "$WEBWORK_ROOT" ] || [ -z "$PG_ROOT" ]; then - echo >&2 "You need to set both the WEBWORK_ROOT and PG_ROOT environment variables. Aborting." +if [ -z "$WEBWORK_ROOT" ]; then + echo >&2 "You need to set the WEBWORK_ROOT environment variable. Aborting." exit 1 fi @@ -53,9 +53,9 @@ LOCDIR=$WEBWORK_ROOT/lib/WeBWorK/Localize cd $LOCDIR -echo "Updating $WEBWORK_ROOT/webwork2.pot" +echo "Updating $LOCDIR/webwork2.pot" -xgettext.pl -o webwork2.pot -D $WEBWORK_ROOT/lib -D $PG_ROOT/lib -D $PG_ROOT/macros -D $WEBWORK_ROOT/templates \ +xgettext.pl -o webwork2.pot -D $WEBWORK_ROOT/lib -D $WEBWORK_ROOT/templates \ $WEBWORK_ROOT/conf/defaults.config $WEBWORK_ROOT/conf/LTIConfigValues.config if $UPDATE_PO; then diff --git a/bin/dev_scripts/webwork2-morbo b/bin/dev_scripts/webwork2-morbo index f951d94c35..37dfe8121e 100755 --- a/bin/dev_scripts/webwork2-morbo +++ b/bin/dev_scripts/webwork2-morbo @@ -87,9 +87,10 @@ push(@watch, "$webwork_root/lib", "$webwork_root/templates", "$webwork_root/htdocs/js", "$webwork_root/htdocs/themes", "$webwork_root/conf"); -# Add the pg lib and pg htdocs directory if they are readable. -push(@watch, "$config->{pg_dir}/lib") if -r "$config->{pg_dir}/lib"; -push(@watch, "$config->{pg_dir}/htdocs") if -r "$config->{pg_dir}/htdocs"; +# Add the pg lib and pg htdocs directory and the PG.pl macro if they are readable. +push(@watch, "$config->{pg_dir}/lib") if -r "$config->{pg_dir}/lib"; +push(@watch, "$config->{pg_dir}/htdocs") if -r "$config->{pg_dir}/htdocs"; +push(@watch, "$config->{pg_dir}/macros/PG.pl") if -r "$config->{pg_dir}/macros/PG.pl"; my $morbo = Mojo::Server::Morbo->new(silent => !$verbose); $morbo->daemon->listen(\@listen) if @listen; diff --git a/conf/defaults.config b/conf/defaults.config index 5c9cb4b210..96dbe4b120 100644 --- a/conf/defaults.config +++ b/conf/defaults.config @@ -1045,12 +1045,6 @@ $pg{options}{showEvaluatedAnswers} = 1; # propogate to the main process. So this really should never be set to 0. $pg{options}{catchWarnings} = 1; -# decorations for correct input blanks -- apparently you can't define and name attribute collections in a .css file -$pg{options}{correct_answer} = "{border-width:2;border-style:solid;border-color:#8F8}"; #matches resultsWithOutError class in math2.css - -# decorations for incorrect input blanks -$pg{options}{incorrect_answer} = "{border-width:2;border-style:solid;border-color:#F55}"; #matches resultsWithError class in math2.css - ##### Settings for various display modes # "images" mode has several settings: @@ -1258,7 +1252,7 @@ ${pg}{modules} = [ [qw(Applet MIME::Base64)], [qw(PGcore PGalias PGresource PGloadfiles PGanswergroup PGresponsegroup Tie::IxHash)], [qw(Locale::Maketext)], - [qw(WeBWorK::Localize)], + [qw(WeBWorK::PG::Localize)], [qw(JSON)], [qw(Rserve Class::Tiny IO::Handle)], [qw(DragNDrop)], diff --git a/conf/site.conf.dist b/conf/site.conf.dist index 4a00f93e23..9591b1e151 100644 --- a/conf/site.conf.dist +++ b/conf/site.conf.dist @@ -88,7 +88,7 @@ $externalPrograms{rm} = "/bin/rm"; $externalPrograms{mkdir} = "/bin/mkdir"; $externalPrograms{tar} = "/bin/tar"; $externalPrograms{gzip} = "/bin/gzip"; -$externalPrograms{git} = "/usr/bin/git"; +$externalPrograms{git} = "/usr/bin/git"; #################################################### # equation rendering/hardcopy utiltiies diff --git a/htdocs/generate-assets.js b/htdocs/generate-assets.js index d297e8b214..5f275e266f 100755 --- a/htdocs/generate-assets.js +++ b/htdocs/generate-assets.js @@ -212,8 +212,6 @@ if (argv.watchFiles) console.log('\x1b[32mEstablishing watches and performing in chokidar.watch(['js', 'themes'], { ignored: /layouts|\.min\.(js|css)$/, cwd: __dirname, // Make sure all paths are given relative to the htdocs directory. - usePolling: true, // Needed to get changes to symlinks. - interval: 500, awaitWriteFinish: { stabilityThreshold: 500 }, persistent: argv.watchFiles ? true : false }) diff --git a/htdocs/js/ActionTabs/actiontabs.js b/htdocs/js/ActionTabs/actiontabs.js index b7c7aca97f..50c43da164 100644 --- a/htdocs/js/ActionTabs/actiontabs.js +++ b/htdocs/js/ActionTabs/actiontabs.js @@ -1,11 +1,48 @@ (() => { const takeAction = document.getElementById('take_action'); + const currentAction = document.getElementById('current_action'); document.querySelectorAll('.action-link').forEach((actionLink) => { - const currentAction = document.getElementById('current_action'); actionLink.addEventListener('show.bs.tab', () => { if (takeAction) takeAction.value = actionLink.textContent; if (currentAction) currentAction.value = actionLink.dataset.action; }); }); + + // Submit the form when a sort header is clicked or enter or space is pressed when it has focus. + 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); + }); + + const orderToggleButton = header.parentElement.querySelector('button.sort-order'); + orderToggleButton?.addEventListener('click', () => { + currentAction.value = 'sort'; + + const sortOrderInput = document.createElement('input'); + sortOrderInput.name = 'labelSortOrder'; + sortOrderInput.value = orderToggleButton.dataset.sortPriority; + sortOrderInput.type = 'hidden'; + currentAction.form.append(sortOrderInput); + + currentAction.form.submit(); + }); + } + } })(); diff --git a/htdocs/js/FileManager/filemanager.js b/htdocs/js/FileManager/filemanager.js index 8f579d6bf8..4883ebd15a 100644 --- a/htdocs/js/FileManager/filemanager.js +++ b/htdocs/js/FileManager/filemanager.js @@ -12,31 +12,83 @@ document.getElementsByName('directory')[0]?.addEventListener('change', () => doAction('Go')); document.getElementsByName('dates')[0]?.addEventListener('click', () => doAction('Refresh')); - files?.addEventListener('dblclick', () => doAction('View')); + files?.addEventListener('dblclick', () => { + if (files.selectedOptions[0].dataset.type & 0b11010) doAction('View'); + else { + const container = document.createElement('div'); + container.classList.add('toast-container', 'top-50', 'start-50', 'translate-middle'); + + const toast = document.createElement('div'); + toast.classList.add('toast'); + toast.setAttribute('role', 'alert'); + toast.setAttribute('aria-live', 'assertive'); + toast.setAttribute('aria-atomit', 'true'); + const toastContent = document.createElement('div'); + toastContent.classList.add('d-flex', 'alert', 'alert-danger', 'mb-0', 'p-0'); + + const toastBody = document.createElement('div'); + toastBody.classList.add('toast-body'); + toastBody.textContent = + files.selectedOptions[0].dataset.type & 0b1 + ? files.dataset.linkMessage || 'Symbolic links can not be followed.' + : files.dataset.nonViewableMessage || 'This is not a viewable file type.'; + + const closeButton = document.createElement('button'); + closeButton.type = 'button'; + closeButton.classList.add('btn-close', 'me-2', 'm-auto'); + closeButton.dataset.bsDismiss = 'toast'; + closeButton.setAttribute('aria-label', files.dataset.closeTitle || 'Close'); + + toastContent.append(toastBody, closeButton); + + toast.append(toastContent); + container.append(toast); + document.body.append(container); + + const bsToast = new bootstrap.Toast(toast); + bsToast.show(); + toast.addEventListener('hidden.bs.toast', () => { + bsToast.dispose(); + container.remove(); + }); + } + }); // If on the confirmation page, then focus the "name" input. form.querySelector('input[name="name"]')?.focus(); } - const fileActionButtons = ['View', 'Edit', 'Download', 'Rename', 'Copy', 'Delete', 'MakeArchive'].map((buttonId) => - document.getElementById(buttonId) - ); + // The bits for types from least to most significant digit are set in the directoryListing method of + // lib/WeBWorK/ContentGenerator/Instructor/FileManager.pm to mean a file is a + // link, directory, regular file, text file, or image file. + const fileActions = [ + { id: 'View', types: 0b11010, multiple: 0 }, + { id: 'Edit', types: 0b01000, multiple: 0 }, + { id: 'Download', types: 0b100, multiple: 0 }, + { id: 'Rename', types: 0b111, multiple: 0 }, + { id: 'Copy', types: 0b100, multiple: 0 }, + { id: 'Delete', types: 0b111, multiple: 1 }, + { id: 'MakeArchive', types: 0b111, multiple: 1 } + ]; + fileActions.map((button) => (button.elt = document.getElementById(button.id))); const archiveButton = document.getElementById('MakeArchive'); const checkFiles = () => { - const state = files.selectedIndex < 0; + const selectedFiles = files.selectedOptions; - for (const button of fileActionButtons) { - if (button) button.disabled = state; + for (const button of fileActions) { + if (!button.elt) continue; + if (selectedFiles.length) { + if (selectedFiles.length == 1 && !button.multiple) + button.elt.disabled = !(button.types & selectedFiles[0].dataset.type); + else button.elt.disabled = !button.multiple; + } else { + button.elt.disabled = true; + } } - if (archiveButton && !state) { - const numSelected = files.querySelectorAll('option:checked').length; - if ( - numSelected === 0 || - numSelected > 1 || - !/\.(tar|tar\.gz|tgz)$/.test(files.children[files.selectedIndex].value) - ) + if (archiveButton && selectedFiles.length) { + if (selectedFiles.length > 1 || !/\.(tar|tar\.gz|tgz|zip)$/.test(selectedFiles[0].value)) archiveButton.value = archiveButton.dataset.archiveText; else archiveButton.value = archiveButton.dataset.unarchiveText; } @@ -45,6 +97,19 @@ files?.addEventListener('change', checkFiles); if (files) checkFiles(); + const archiveFilenameInput = document.getElementById('archive-filename'); + const archiveTypeSelect = document.getElementById('archive-type'); + if (archiveFilenameInput && archiveTypeSelect) { + archiveTypeSelect.addEventListener('change', () => { + if (archiveTypeSelect.value) { + archiveFilenameInput.value = archiveFilenameInput.value.replace( + /\.(zip|tgz|tar.gz)$/, + `.${archiveTypeSelect.value}` + ); + } + }); + } + const file = document.getElementById('file'); const uploadButton = document.getElementById('Upload'); const checkFile = () => (uploadButton.disabled = file.value === ''); diff --git a/htdocs/js/PGCodeMirror/pgeditor.js b/htdocs/js/PGCodeMirror/pgeditor.js index f57a8eeb0c..d00c18d6d5 100644 --- a/htdocs/js/PGCodeMirror/pgeditor.js +++ b/htdocs/js/PGCodeMirror/pgeditor.js @@ -105,6 +105,10 @@ = CodeMirror.fromTextArea(document.querySelector('.codeMirrorEditor'), options); cm.setSize('100%', '550px'); + // Refresh the CodeMirror instance anytime the containing div resizes so that if line wrapping changes, + // the mouse cursor will still go to the correct place when the user clicks on the CodeMirror window. + new ResizeObserver(() => cm.refresh()).observe(document.querySelector('.CodeMirror')); + const currentThemeFile = localStorage.getItem('WW_PGEditor_selected_theme') ?? 'default'; const currentThemeName = await loadConfig(currentThemeFile); cm.setOption('theme', currentThemeName); diff --git a/htdocs/js/ProblemGrader/problemgrader.js b/htdocs/js/ProblemGrader/problemgrader.js index fb86daa368..c1cd644e4e 100644 --- a/htdocs/js/ProblemGrader/problemgrader.js +++ b/htdocs/js/ProblemGrader/problemgrader.js @@ -120,15 +120,16 @@ } else { // Update the hidden problem status fields and score table for gateway quizzes if (saveData.versionId !== '0') { - document.gwquiz.elements['probstatus' + saveData.problemId].value = - parseInt(scoreInput.value) / 100; + const probStatus = document.gwquiz.elements[`probstatus${saveData.problemId}`]; + if (probStatus) probStatus.value = parseInt(scoreInput.value) / 100; let testValue = 0; for (const scoreCell of document.querySelectorAll('table.gwNavigation td.score')) { if (scoreCell.dataset.problemId == saveData.problemId) { scoreCell.textContent = scoreInput.value == '100' ? '\u{1F4AF}' : scoreInput.value; } - testValue += document.gwquiz.elements['probstatus' - + scoreCell.dataset.problemId].value * scoreCell.dataset.problemValue; + testValue += + (document.gwquiz.elements[`probstatus${scoreCell.dataset.problemId}`]?.value ?? 0) * + scoreCell.dataset.problemValue; } const recordedScore = document.getElementById('test-recorded-score'); if (recordedScore) { diff --git a/htdocs/js/RenderProblem/renderproblem.js b/htdocs/js/RenderProblem/renderproblem.js index 726ff789a7..3041912cf1 100644 --- a/htdocs/js/RenderProblem/renderproblem.js +++ b/htdocs/js/RenderProblem/renderproblem.js @@ -28,7 +28,7 @@ send_pg_flags: 1, extra_header_text: '', ...renderOptions }; diff --git a/htdocs/js/ShowHide/show_hide.js b/htdocs/js/ShowHide/show_hide.js deleted file mode 100644 index 0604716470..0000000000 --- a/htdocs/js/ShowHide/show_hide.js +++ /dev/null @@ -1,10 +0,0 @@ -/* This Javascript attaches the proper event handler to the "Show/Hide Description" button */ - -(() => { - const showHide = document.getElementById('show_hide'); - showHide?.addEventListener('click', () => { - const description = document.getElementById("site_description"); - if (description.style.display === "none") description.style.display = "block"; - else description.style.display = "none"; - }); -})(); diff --git a/htdocs/js/TagWidget/tagwidget.js b/htdocs/js/TagWidget/tagwidget.js index 3a54609b53..27cbd206c1 100644 --- a/htdocs/js/TagWidget/tagwidget.js +++ b/htdocs/js/TagWidget/tagwidget.js @@ -317,7 +317,7 @@ if (!response.ok) return showMessage('Unable to save problem tags.'); const data = await response.json(); if (data.error) return showMessage(data.error); - showMessage(data.server_response); + showMessage(data.server_response, true); } } diff --git a/htdocs/js/UserList/userlist.js b/htdocs/js/UserList/userlist.js index ec41c40e0f..cbda5ab719 100644 --- a/htdocs/js/UserList/userlist.js +++ b/htdocs/js/UserList/userlist.js @@ -1,15 +1,14 @@ (() => { + // Show/hide the filter elements depending on if the field matching option is selected. const filter_select = document.getElementById('filter_select'); - if (filter_select) { - const classlist_add_filter_elements = () => { - const filter_elements = document.getElementById('filter_elements'); - - if (filter_select.selectedIndex === 3) filter_elements.style.display = 'block'; + 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', classlist_add_filter_elements); - classlist_add_filter_elements(); + filter_select.addEventListener('change', toggle_filter_elements); + toggle_filter_elements(); } const export_select_target = document.getElementById('export_select_target'); @@ -25,31 +24,4 @@ export_select_target.addEventListener('change', classlist_add_export_elements); classlist_add_export_elements(); } - - // Submit the user list form when a sort header is clicked or enter or space is pressed when it has focus. - const userListForm = document.forms['userlist']; - const currentAction = document.getElementById('current_action'); - - if (userListForm && currentAction) { - for (const header of document.querySelectorAll('.sort-header')) { - const submitSortMethod = (e) => { - e.preventDefault(); - - currentAction.value = ''; - - const sortInput = document.createElement('input'); - sortInput.name = 'labelSortMethod'; - sortInput.value = header.dataset.sortField; - sortInput.type = 'hidden'; - userListForm.append(sortInput); - - userListForm.submit(); - }; - - header.addEventListener('click', submitSortMethod); - header.addEventListener('keydown', (e) => { - if (e.key === ' ' || e.key === 'Enter') submitSortMethod(e); - }); - } - } })(); diff --git a/htdocs/package-lock.json b/htdocs/package-lock.json index 8e81a4a6ea..2f3dc8c4ba 100644 --- a/htdocs/package-lock.json +++ b/htdocs/package-lock.json @@ -23,7 +23,7 @@ "autoprefixer": "^10.4.13", "chokidar": "^3.5.3", "cssnano": "^6.0.0", - "postcss": "^8.4.21", + "postcss": "^8.4.31", "rtlcss": "^4.0.0", "sass": "^1.57.1", "terser": "^5.16.1", @@ -290,9 +290,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001517", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001517.tgz", - "integrity": "sha512-Vdhm5S11DaFVLlyiKu4hiUTkpZu+y1KA/rZZqVQfOD5YdDT/eQKlkt7NaE0WGOFgX32diqt9MiP9CAiFeRklaA==", + "version": "1.0.30001547", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001547.tgz", + "integrity": "sha512-W7CrtIModMAxobGhz8iXmDfuJiiKg1WADMO/9x7/CLNin5cpSbuBjooyoIUVB5eyCc36QuTVlkVa1iB2S5+/eA==", "dev": true, "funding": [ { @@ -837,10 +837,16 @@ "dev": true }, "node_modules/nanoid": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", - "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -903,9 +909,9 @@ } }, "node_modules/postcss": { - "version": "8.4.21", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", - "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "dev": true, "funding": [ { @@ -915,10 +921,14 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ], "dependencies": { - "nanoid": "^3.3.4", + "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" }, @@ -1813,9 +1823,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001517", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001517.tgz", - "integrity": "sha512-Vdhm5S11DaFVLlyiKu4hiUTkpZu+y1KA/rZZqVQfOD5YdDT/eQKlkt7NaE0WGOFgX32diqt9MiP9CAiFeRklaA==", + "version": "1.0.30001547", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001547.tgz", + "integrity": "sha512-W7CrtIModMAxobGhz8iXmDfuJiiKg1WADMO/9x7/CLNin5cpSbuBjooyoIUVB5eyCc36QuTVlkVa1iB2S5+/eA==", "dev": true }, "chokidar": { @@ -2198,9 +2208,9 @@ "dev": true }, "nanoid": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", - "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", "dev": true }, "node-releases": { @@ -2243,12 +2253,12 @@ "dev": true }, "postcss": { - "version": "8.4.21", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", - "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "dev": true, "requires": { - "nanoid": "^3.3.4", + "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } diff --git a/htdocs/package.json b/htdocs/package.json index af460d4ef8..c5dd1673b3 100644 --- a/htdocs/package.json +++ b/htdocs/package.json @@ -27,7 +27,7 @@ "autoprefixer": "^10.4.13", "chokidar": "^3.5.3", "cssnano": "^6.0.0", - "postcss": "^8.4.21", + "postcss": "^8.4.31", "rtlcss": "^4.0.0", "sass": "^1.57.1", "terser": "^5.16.1", diff --git a/htdocs/themes/math4/math4.js b/htdocs/themes/math4/math4.js index 7787fd155e..4a29c2ac2a 100644 --- a/htdocs/themes/math4/math4.js +++ b/htdocs/themes/math4/math4.js @@ -80,11 +80,6 @@ (el) => new bootstrap.Tooltip(el, {trigger: 'hover', fallbackPlacements: []}) ); - // Set up popovers in the attemptResults table. - document.querySelectorAll('table.attemptResults td div.answer-preview').forEach((popover) => { - if (popover.dataset.bsContent) new bootstrap.Popover(popover, {trigger: 'click', html: true, sanitize: false}); - }); - // Sets up problems to rescale the image accoring to attr height width and not native height width. const rescaleImage = (_index, element) => { if (element.height != element.naturalHeight || element.width != element.naturalWidth) { diff --git a/htdocs/themes/math4/math4.scss b/htdocs/themes/math4/math4.scss index f0fee9b40f..07844b815b 100644 --- a/htdocs/themes/math4/math4.scss +++ b/htdocs/themes/math4/math4.scss @@ -456,7 +456,7 @@ h2.page-title { gap: 0.25rem; margin: 0 0 0.5rem; - p { + div { margin: 0; } } @@ -687,14 +687,6 @@ ul.courses-list { } } -div.AuthorComment { - background-color: #00e0e0; - color: black; - padding: 0.25rem; - border: 1px solid transparent; - border-radius: 0.25rem; -} - /* Footer */ #footer { font-size: 0.8em; @@ -901,22 +893,22 @@ input.changed[type=text] { /* orange */ min-width: 2.5em; text-align: center; } -} -span { - &.correct { - color: inherit; /* green */ - background-color: #8f8; - } + span { + &.correct { + color: inherit; + background-color: #8f8; + } - &.incorrect { - color: #bf5454; /* red */ - background-color: inherit; - } + &.incorrect { + color: #bf5454; /* red */ + background-color: inherit; + } - &.unattempted { - color: inherit; - background-color: #88ecff; + &.unattempted { + color: inherit; + background-color: #88ecff; + } } } diff --git a/lib/FormatRenderedProblem.pm b/lib/FormatRenderedProblem.pm index 005b9c2c37..db96efad8e 100644 --- a/lib/FormatRenderedProblem.pm +++ b/lib/FormatRenderedProblem.pm @@ -29,7 +29,6 @@ use Digest::SHA qw(sha1_base64); use Mojo::Util qw(xml_escape); use Mojo::DOM; -use WeBWorK::HTML::AttemptsTable; use WeBWorK::Utils qw(getAssetURL); use WeBWorK::Utils::LanguageAndDirection; @@ -145,33 +144,30 @@ sub formatRenderedProblem { my $showCorrectMode = defined($ws->{inputs_ref}{WWcorrectAns}) || 0; # A problemUUID should be added to the request as a parameter. It is used by PG to create a proper UUID for use in # aliases for resources. It should be unique for a course, user, set, problem, and version. - my $problemUUID = $ws->{inputs_ref}{problemUUID} // ''; - my $problemResult = $rh_result->{problem_result} // {}; - my $showSummary = $ws->{inputs_ref}{showSummary} // 1; - my $showAnswerNumbers = $ws->{inputs_ref}{showAnswerNumbers} // 1; - - # Attempts table - my $answerTemplate = ''; - - # Do not produce an AttemptsTable when we had a rendering error. - if (!$renderErrorOccurred) { - my $tbl = WeBWorK::HTML::AttemptsTable->new( - $rh_result->{answers} // {}, $ws->c, - answersSubmitted => $ws->{inputs_ref}{answersSubmitted} // 0, - answerOrder => $rh_result->{flags}{ANSWER_ENTRY_ORDER} // [], - displayMode => $displayMode, - showAnswerNumbers => $showAnswerNumbers, - ce => $ce, - showAttemptPreviews => $previewMode || $submitMode || $showCorrectMode, - showAttemptResults => $submitMode || $showCorrectMode, - showCorrectAnswers => $showCorrectMode, - showMessages => $previewMode || $submitMode || $showCorrectMode, - showSummary => (($showSummary && ($submitMode || $showCorrectMode)) // 0) ? 1 : 0, - maketext => WeBWorK::Localize::getLoc($formLanguage), - summary => $problemResult->{summary} // '', # can be set by problem grader - ); - $answerTemplate = $tbl->answerTemplate; - $tbl->imgGen->render(refresh => 1) if $tbl->displayMode eq 'images'; + my $problemUUID = $ws->{inputs_ref}{problemUUID} // ''; + my $problemResult = $rh_result->{problem_result} // {}; + my $showSummary = $ws->{inputs_ref}{showSummary} // 1; + + # Result summary + my $resultSummary = ''; + + my $lh = WeBWorK::Localize::getLangHandle($formLanguage); + + # Do not produce a result summary when we had a rendering error. + if (!$renderErrorOccurred + && $showSummary + && !$previewMode + && ($submitMode || $showCorrectMode) + && $problemResult->{summary}) + { + $resultSummary = $ws->c->c( + $ws->c->tag( + 'h2', + class => 'fs-3 mb-2', + $ws->c->maketext('Results for this submission') + ) + . $ws->c->tag('div', role => 'alert', $ws->c->b($problemResult->{summary})) + )->join(''); } # Answer hash in XML format used by the PTX format. @@ -214,7 +210,7 @@ sub formatRenderedProblem { $output->{input} = $ws->{input}; # The following could be constructed from the above, but this is a convenience - $output->{answerTemplate} = $answerTemplate->to_string if $answerTemplate; + $output->{resultSummary} = $resultSummary->to_string if $resultSummary; $output->{lang} = $PROBLEM_LANG_AND_DIR{lang}; $output->{dir} = $PROBLEM_LANG_AND_DIR{dir}; $output->{extra_css_files} = \@extra_css_files; @@ -242,7 +238,7 @@ sub formatRenderedProblem { formatName => $formatName, ws => $ws, ce => $ce, - lh => WeBWorK::Localize::getLangHandle($ws->{inputs_ref}{language} // 'en'), + lh => $lh, rh_result => $rh_result, SITE_URL => $SITE_URL, FORM_ACTION_URL => $SITE_URL . $ws->c->webwork_url . '/render_rpc', @@ -263,7 +259,7 @@ sub formatRenderedProblem { extra_js_files => \@extra_js_files, problemText => $problemText, extra_header_text => $ws->{inputs_ref}{extra_header_text} // '', - answerTemplate => $answerTemplate, + resultSummary => $resultSummary, showScoreSummary => $submitMode && !$renderErrorOccurred && $problemResult, answerhashXML => $answerhashXML, LTIGradeMessage => $LTIGradeMessage, @@ -275,9 +271,8 @@ sub formatRenderedProblem { isInstructor => $ws->{inputs_ref}{isInstructor} // '', forceScaffoldsOpen => $ws->{inputs_ref}{forceScaffoldsOpen} // '', showSummary => $showSummary, - showHints => $ws->{inputs_ref}{showHints} // '', - showSolutions => $ws->{inputs_ref}{showSolutions} // '', - showAnswerNumbers => $showAnswerNumbers, + showHints => $ws->{inputs_ref}{showHints} // '', + showSolutions => $ws->{inputs_ref}{showSolutions} // '', showPreviewButton => $ws->{inputs_ref}{showPreviewButton} // '', showCheckAnswersButton => $ws->{inputs_ref}{showCheckAnswersButton} // '', showCorrectAnswersButton => $ws->{inputs_ref}{showCorrectAnswersButton} // '', @@ -403,20 +398,28 @@ EOS # Nice output for debugging sub pretty_print { my ($r_input, $level) = @_; + return 'undef' unless defined $r_input; + $level //= 4; $level--; - return '' unless $level > 0; # Only print three levels of hashes (safety feature) - my $out = ''; - if (!ref $r_input) { - $out = $r_input if defined $r_input; - $out =~ s/}; - + return 'too deep' unless $level > 0; + + my $ref = ref($r_input); + + if (!$ref) { + return xml_escape($r_input); + } elsif (eval { %$r_input || 1 }) { + # `eval { %$r_input || 1 }` will pick up all objectes that can be accessed like a hash and so works better than + # `ref $r_input`. Do not use `"$r_input" =~ /hash/i` because that will pick up strings containing the word + # hash, and that will cause an error below. + my $out = + '
' + . ($ref eq 'HASH' + ? '' + : '
' + . "$ref
") + . '
'; for my $key (sort keys %$r_input) { # Safety feature - we do not want to display the contents of %seed_ce which # contains the database password and lots of other things, and explicitly hide @@ -429,24 +432,24 @@ sub pretty_print { || ($key eq "externalPrograms") || ($key eq "permissionLevels") || ($key eq "seed_ce")); - $out .= "$key=> " . pretty_print($r_input->{$key}, $level) . ""; + $out .= + '
' + . xml_escape($key) + . '
' + . qq{
=>
} + . qq{
} + . pretty_print($r_input->{$key}, $level) + . '
'; } - $out .= ''; - } elsif (ref $r_input eq 'ARRAY') { - my @array = @$r_input; - $out .= '( '; - while (@array) { - $out .= pretty_print(shift @array, $level) . ' , '; - } - $out .= ' )'; - } elsif (ref $r_input eq 'CODE') { - $out = "$r_input"; + $out .= '
'; + return $out; + } elsif ($ref eq 'ARRAY') { + return '[ ' . join(', ', map { pretty_print($_, $level) } @$r_input) . ' ]'; + } elsif ($ref eq 'CODE') { + return 'CODE'; } else { - $out = $r_input; - $out =~ s/ 'ResurrectHW', name => x('Scroll of Resurrection'), - description => x('Opens any homework set for 24 hours.') + description => x("Reopens one closed homework set for 24 hours and rerandomizes all problems."), }, $class; } diff --git a/lib/WeBWorK/ContentGenerator.pm b/lib/WeBWorK/ContentGenerator.pm index 9c0d7dad01..992597c747 100644 --- a/lib/WeBWorK/ContentGenerator.pm +++ b/lib/WeBWorK/ContentGenerator.pm @@ -265,8 +265,9 @@ message() template escape handler. sub addgoodmessage ($c, $message) { $c->addmessage($c->tag( - 'p', + 'div', class => 'alert alert-success alert-dismissible fade show ps-1 py-1', + role => 'alert', $c->c( $message, $c->tag( @@ -290,8 +291,9 @@ message() template escape handler. sub addbadmessage ($c, $message) { $c->addmessage($c->tag( - 'p', + 'div', class => 'alert alert-danger alert-dismissible fade show ps-1 py-1', + role => 'alert', $c->c( $message, $c->tag( diff --git a/lib/WeBWorK/ContentGenerator/CourseAdmin.pm b/lib/WeBWorK/ContentGenerator/CourseAdmin.pm index 2c36bc6dba..eea7f41b73 100644 --- a/lib/WeBWorK/ContentGenerator/CourseAdmin.pm +++ b/lib/WeBWorK/ContentGenerator/CourseAdmin.pm @@ -1563,8 +1563,8 @@ sub do_upgrade_course ($c) { 'li', $c->tag( 'span', - class => $_->[2] ? 'text-success' : 'text-danger', - $_->[1] + class => $_->[1] ? 'text-success' : 'text-danger', + $_->[0] ) ) } @$dir_update_messages diff --git a/lib/WeBWorK/ContentGenerator/GatewayQuiz.pm b/lib/WeBWorK/ContentGenerator/GatewayQuiz.pm index bf49e59896..f30e552205 100644 --- a/lib/WeBWorK/ContentGenerator/GatewayQuiz.pm +++ b/lib/WeBWorK/ContentGenerator/GatewayQuiz.pm @@ -38,7 +38,6 @@ use WeBWorK::Utils::Tasks qw(fake_set fake_set_version fake_problem); use WeBWorK::Debug; use WeBWorK::Authen::LTIAdvanced::SubmitGrade; use WeBWorK::Authen::LTIAdvantage::SubmitGrade; -use WeBWorK::HTML::AttemptsTable; use PGrandom; use Caliper::Sensor; use Caliper::Entity; @@ -238,42 +237,10 @@ sub can_useMathQuill ($c) { } # Output utility -sub attemptResults ($c, $pg, $showCorrectAnswers, $showAttemptResults, $showSummary) { - my $ce = $c->ce; - - # Create AttemptsTable object - my $tbl = WeBWorK::HTML::AttemptsTable->new( - $pg->{answers}, - $c, - answersSubmitted => 1, - answerOrder => $pg->{flags}{ANSWER_ENTRY_ORDER}, - displayMode => $c->{displayMode}, - showHeadline => 0, - showAnswerNumbers => 0, - showAttemptAnswers => $ce->{pg}{options}{showEvaluatedAnswers}, - showAttemptPreviews => 1, - showAttemptResults => $showAttemptResults, - showCorrectAnswers => $showCorrectAnswers, - showMessages => 1, - showSummary => $showSummary, - imgGen => WeBWorK::PG::ImageGenerator->new( - tempDir => $ce->{webworkDirs}{tmp}, - latex => $ce->{externalPrograms}{latex}, - dvipng => $ce->{externalPrograms}{dvipng}, - useCache => 1, - cacheDir => $ce->{webworkDirs}{equationCache}, - cacheURL => $ce->{webworkURLs}{equationCache}, - cacheDB => $ce->{webworkFiles}{equationCacheDB}, - useMarkers => 1, - dvipng_align => $ce->{pg}{displayModeOptions}{images}{dvipng_align}, - dvipng_depth_db => $ce->{pg}{displayModeOptions}{images}{dvipng_depth_db}, - ), - ); - - my $answerTemplate = $tbl->answerTemplate; - $tbl->imgGen->render(body_text => $answerTemplate) if $tbl->displayMode eq 'images'; - - return $answerTemplate; +sub attemptResults ($c, $pg) { + return ($c->{can}{showProblemScores} && $pg->{result}{summary}) + ? $c->tag('div', role => 'alert', $c->b($pg->{result}{summary})) + : ''; } sub get_instructor_comment ($c, $problem) { @@ -1482,18 +1449,6 @@ sub warningMessage ($c) { # hash of parameters from the input form that need to be passed to the translator, and $mergedProblem # is what we'd expect. async sub getProblemHTML ($c, $effectiveUser, $set, $formFields, $mergedProblem) { - my $setID = $set->set_id; - my $setVersionNumber = $set->version_id; - - # Figure out solutions are allowed and call renderPG accordingly. - my $showCorrectAnswers = $c->{will}{showCorrectAnswers}; - my $showHints = $c->{will}{showHints}; - my $showSolutions = $c->{will}{showSolutions}; - my $processAnswers = $c->{will}{checkAnswers}; - - # FIXME: I'm not sure that problem_id is what we want here. - my $problemNumber = $mergedProblem->problem_id; - my $pg = await renderPG( $c, $effectiveUser, @@ -1502,17 +1457,26 @@ async sub getProblemHTML ($c, $effectiveUser, $set, $formFields, $mergedProblem) $set->psvn, $formFields, { - displayMode => $c->{displayMode}, - showHints => $showHints, - showSolutions => $showSolutions, - refreshMath2img => $showHints || $showSolutions, - processAnswers => 1, - QUIZ_PREFIX => 'Q' . sprintf('%04d', $problemNumber) . '_', - useMathQuill => $c->{will}{useMathQuill}, - useMathView => $c->{will}{useMathView}, - forceScaffoldsOpen => 1, - isInstructor => $c->authz->hasPermissions($c->{userID}, 'view_answers'), - debuggingOptions => getTranslatorDebuggingOptions($c->authz, $c->{userID}) + displayMode => $c->{displayMode}, + showHints => $c->{will}{showHints}, + showSolutions => $c->{will}{showSolutions}, + refreshMath2img => $c->{will}{showHints} || $c->{will}{showSolutions}, + processAnswers => 1, + QUIZ_PREFIX => 'Q' . sprintf('%04d', $mergedProblem->problem_id) . '_', + useMathQuill => $c->{will}{useMathQuill}, + useMathView => $c->{will}{useMathView}, + forceScaffoldsOpen => 1, + isInstructor => $c->authz->hasPermissions($c->{userID}, 'view_answers'), + showFeedback => $c->{submitAnswers} || $c->{previewAnswers} || $c->{will}{checkAnswers}, + showAttemptAnswers => $c->ce->{pg}{options}{showEvaluatedAnswers}, + showAttemptPreviews => 1, + showAttemptResults => !$c->{previewAnswers} && $c->{can}{showProblemScores}, + forceShowAttemptResults => $c->{will}{showProblemGrader}, + showMessages => 1, + showCorrectAnswers => ($c->{submitAnswers} || $c->{will}{checkAnswers} || $c->{will}{showProblemGrader}) + ? $c->{will}{showCorrectAnswers} + : 0, + debuggingOptions => getTranslatorDebuggingOptions($c->authz, $c->{userID}) }, ); @@ -1523,7 +1487,7 @@ async sub getProblemHTML ($c, $effectiveUser, $set, $formFields, $mergedProblem) if ($pg->{flags}{error_flag}) { push @{ $c->{errors} }, { - set => "$setID,v$setVersionNumber", + set => $set->set_id . ',v' . $set->version_id, problem => $mergedProblem->problem_id, message => $pg->{errors}, context => $pg->{body_text}, diff --git a/lib/WeBWorK/ContentGenerator/Instructor/AchievementList.pm b/lib/WeBWorK/ContentGenerator/Instructor/AchievementList.pm index 1e2c7b2c60..43aec79032 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/AchievementList.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/AchievementList.pm @@ -124,12 +124,10 @@ sub initialize ($c) { my $actionHandler = "${actionID}_handler"; my ($success, $action_result) = $c->$actionHandler; if ($success) { - $c->addgoodmessage($c->b($c->maketext('Result of last action performed: [_1]', $action_result))); + $c->addgoodmessage($c->b($action_result)); } else { - $c->addbadmessage($c->b($c->maketext('Result of last action performed: [_1]', $action_result))); + $c->addbadmessage($c->b($action_result)); } - } else { - $c->addgoodmessage($c->maketext('Please select action to be performed.')); } $c->stash->{formsToShow} = $c->{editMode} ? EDIT_FORMS() : $c->{exportMode} ? EXPORT_FORMS() : VIEW_FORMS(); @@ -153,9 +151,9 @@ sub edit_handler ($c) { my $scope = $c->param('action.edit.scope'); if ($scope eq "all") { $c->{selectedAchievementIDs} = $c->{allAchievementIDs}; - $result = $c->maketext("editing all achievements"); + $result = $c->maketext('Editing all achievements.'); } elsif ($scope eq "selected") { - $result = $c->maketext("editing selected achievements"); + $result = $c->maketext('Editing selected achievements.'); } $c->{editMode} = 1; @@ -219,7 +217,7 @@ sub assign_handler ($c) { } } - return (1, $c->maketext('Assigned achievements to users')); + return (1, $c->maketext('Assigned achievements to users.')); } # Handler for scoring @@ -311,7 +309,7 @@ sub score_handler ($c) { return ( 1, $c->b($c->maketext( - 'Achievement scores saved to [_1]', + 'Achievement scores saved to [_1].', $c->link_to( $scoreFileName => $c->systemLink( $c->url_for('instructor_file_manager'), @@ -351,7 +349,7 @@ sub delete_handler ($c) { $c->{selectedAchievementIDs} = [ keys %selectedAchievementIDs ]; my $num = @achievementIDsToDelete; - return (1, $c->maketext('Deleted [quant,_1,achievement]', $num)); + return (1, $c->maketext('Deleted [quant,_1,achievement].', $num)); } # Handler for creating an ahcievement @@ -364,7 +362,7 @@ sub create_handler ($c) { my $newAchievementID = $c->param('action.create.id'); return (0, $c->maketext("Failed to create new achievement: no achievement ID specified!")) unless $newAchievementID =~ /\S/; - return (0, $c->maketext("Achievement [_1] exists. No achievement created", $newAchievementID)) + return (0, $c->maketext("Achievement [_1] exists. No achievement created.", $newAchievementID)) if $db->existsAchievement($newAchievementID); my $newAchievementRecord = $db->newAchievement; my $oldAchievementID = $c->{selectedAchievementIDs}->[0]; @@ -468,7 +466,7 @@ sub import_handler ($c) { $c->{allAchievementIDs} = [ keys %allAchievementIDs ]; - return (1, $c->maketext('Imported [quant,_1,achievement]', $count)); + return (1, $c->maketext('Imported [quant,_1,achievement].', $count)); } # Export handler @@ -478,10 +476,10 @@ sub export_handler ($c) { my $scope = $c->param('action.export.scope'); if ($scope eq "all") { - $result = $c->maketext("exporting all achievements"); + $result = $c->maketext('Exporting all achievements.'); $c->{selectedAchievementIDs} = $c->{allAchievementIDs}; } elsif ($scope eq "selected") { - $result = $c->maketext("exporting selected achievements"); + $result = $c->maketext('Exporting selected achievements.'); $c->{selectedAchievementIDs} = [ $c->param('selected_achievements') ]; } $c->{exportMode} = 1; @@ -493,7 +491,7 @@ sub export_handler ($c) { sub cancel_export_handler ($c) { $c->{exportMode} = 0; - return (0, $c->maketext('export abandoned')); + return (0, $c->maketext('Export abandoned.')); } # Handler actually exporting achievements. @@ -517,7 +515,7 @@ sub save_export_handler ($c) { $FilePath = WeBWorK::Utils::surePathToFile($ce->{courseDirs}{achievements}, $FilePath); my $fh = Mojo::File->new($FilePath)->open('>:encoding(UTF-8)') - or return (0, $c->maketext('Failed to open [_1]', $FilePath)); + or return (0, $c->maketext('Failed to open [_1].', $FilePath)); my $csv = Text::CSV->new({ eol => "\n" }); @@ -538,13 +536,13 @@ sub save_export_handler ($c) { $c->{exportMode} = 0; - return (1, $c->maketext('Exported achievements to [_1]', $FileName)); + return (1, $c->maketext('Exported achievements to [_1].', $FileName)); } # Handler for cancelling edits. sub cancel_edit_handler ($c) { $c->{editMode} = 0; - return (1, $c->maketext('changes abandoned')); + return (1, $c->maketext('Changes abandoned.')); } # Handler for saving edits. @@ -579,7 +577,7 @@ sub save_edit_handler ($c) { $c->{editMode} = 0; - return (1, $c->maketext('changes saved')); + return (1, $c->maketext('Changes saved.')); } # Get list of files that can be imported. diff --git a/lib/WeBWorK/ContentGenerator/Instructor/FileManager.pm b/lib/WeBWorK/ContentGenerator/Instructor/FileManager.pm index 5869750e92..d5d480b530 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/FileManager.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/FileManager.pm @@ -22,12 +22,16 @@ WeBWorK::ContentGenerator::Instructor::FileManager.pm -- simple directory manage =cut +use Mojo::File; use File::Path; use File::Copy; use File::Spec; use String::ShellQuote; +use Archive::Tar; +use Archive::Zip qw(:ERROR_CODES); +use Archive::Zip::SimpleZip qw($SimpleZipError); -use WeBWorK::Utils qw(readDirectory readFile sortByName listFilesRecursive); +use WeBWorK::Utils qw(readDirectory readFile sortByName listFilesRecursive min); use WeBWorK::Upload; use WeBWorK::Utils::CourseManagement qw(archiveCourse); @@ -75,6 +79,22 @@ sub pre_header_initialize ($c) { $c->{courseRoot} = $c->ce->{courseDirs}{root}; $c->{courseName} = $c->stash('courseID'); + if ($c->{pwd} && $action && $action eq 'Edit') { + my @files = $c->param('files'); + if (@files == 1 && $files[0] =~ /\.pg$/) { + my $file = "$c->{courseRoot}/$c->{pwd}/$files[0]"; + if (-f $file && -T $file) { + return $c->reply_with_redirect($c->systemLink( + $c->url_for('instructor_problem_editor'), + params => { + file_type => 'source_path_for_problem_file', + sourceFilePath => $file + } + )); + } + } + } + return; } @@ -172,16 +192,13 @@ sub View ($c) { # Edit a file sub Edit ($c) { my $filename = $c->getFile('edit'); - return '' unless $filename; - my $file = "$c->{courseRoot}/$c->{pwd}/$filename"; - my $userID = $c->param('user'); - my $ce = $c->ce; - my $authz = $c->authz; + return $c->Refresh unless $filename; + my $file = "$c->{courseRoot}/$c->{pwd}/$filename"; # If its a restricted file, dont allow the web editor to edit it unless that option has been set for the course. - for my $restrictedFile (@{ $ce->{uneditableCourseFiles} }) { + for my $restrictedFile (@{ $c->ce->{uneditableCourseFiles} }) { if (File::Spec->canonpath($file) eq File::Spec->canonpath("$c->{courseRoot}/$restrictedFile") - && !$authz->hasPermissions($userID, 'edit_restricted_files')) + && !$c->authz->hasPermissions($c->param('user'), 'edit_restricted_files')) { $c->addbadmessage($c->maketext('You do not have permission to edit this file.')); return $c->Refresh; @@ -344,7 +361,7 @@ sub Delete ($c) { } } -# Make a gzipped tar archive +# Make a gzipped tar or zip archive sub MakeArchive ($c) { my @files = $c->param('files'); if (scalar(@files) == 0) { @@ -352,26 +369,90 @@ sub MakeArchive ($c) { return $c->Refresh; } - my $dir = "$c->{courseRoot}/$c->{pwd}"; - my $archive = uniqueName($dir, (scalar(@files) == 1) ? $files[0] . '.tgz' : "$c->{courseName}.tgz"); - my $tar = 'cd ' . shell_quote($dir) . " && $c->{ce}{externalPrograms}{tar} -cvzf " . shell_quote($archive, @files); - @files = readpipe $tar . ' 2>&1'; - if ($? == 0) { - my $n = scalar(@files); - $c->addgoodmessage($c->maketext('Archive "[_1]" created successfully ([quant,_2,file])', $archive, $n)); + my $dir = $c->{pwd} eq '.' ? $c->{courseRoot} : "$c->{courseRoot}/$c->{pwd}"; + + if ($c->param('confirmed')) { + my $action = $c->param('action') || 'Cancel'; + return $c->Refresh if $action eq 'Cancel' || $action eq $c->maketext('Cancel'); + + unless ($c->param('archive_filename')) { + $c->addbadmessage($c->maketext('The archive filename cannot be empty.')); + return $c->include('ContentGenerator/Instructor/FileManager/archive', dir => $dir, files => \@files); + } + + my $archive_type = + $c->param('archive_type') || ($c->param('archive_filename') =~ /\.(zip|tgz|tar.gz)$/ ? $1 : 'zip'); + + my $archive = $c->param('archive_filename'); + + # Add the correct extension to the archive filename unless it already has it. If the extension for + # the other archive type is given, then change it to the extension for this archive type. + if ($archive_type eq 'zip') { + $archive =~ s/(\.(tgz|tar.gz))?$/.zip/ unless $archive =~ /\.zip$/; + } else { + $archive =~ s/(\.zip)?$/.tgz/ unless $archive =~ /\.(tgz|tar.gz)$/; + } + + # Check filename validity. + if ($archive =~ m!/!) { + $c->addbadmessage($c->maketext('The archive filename may not contain a path component')); + return $c->include('ContentGenerator/Instructor/FileManager/archive', dir => $dir, files => \@files); + } + if ($archive =~ m!^\.! || $archive =~ m![^-_.a-zA-Z0-9 ]!) { + $c->addbadmessage($c->maketext('The archive filename contains illegal characters')); + return $c->include('ContentGenerator/Instructor/FileManager/archive', dir => $dir, files => \@files); + } + + if (-e "$dir/$archive" && !$c->param('overwrite')) { + $c->addbadmessage($c->maketext( + 'The file [_1] exists. Check "Overwrite existing archive" to force this file to be replaced.', + $archive + )); + return $c->include('ContentGenerator/Instructor/FileManager/archive', dir => $dir, files => \@files); + } + + unless (@files > 0) { + $c->addbadmessage($c->maketext('At least one file must be selected')); + return $c->include('ContentGenerator/Instructor/FileManager/archive', dir => $dir, files => \@files); + } + + my ($error, $ok); + if ($archive_type eq 'zip') { + if (my $zip = Archive::Zip::SimpleZip->new("$dir/$archive")) { + for (@files) { + $zip->add("$dir/$_", Name => $_, storelinks => 1); + } + $ok = $zip->close; + } + $error = $SimpleZipError unless $ok; + } else { + my $tar = Archive::Tar->new; + $tar->add_files(map {"$dir/$_"} @files); + # Make file names in the archive relative to the current working directory. + for ($tar->get_files) { + $tar->rename($_->full_path, $_->full_path =~ s!^$dir/!!r); + } + $ok = $tar->write("$dir/$archive", COMPRESS_GZIP); + $error = $tar->error unless $ok; + } + if ($ok) { + $c->addgoodmessage( + $c->maketext('Archive "[_1]" created successfully ([quant,_2,file])', $archive, scalar(@files))); + } else { + $c->addbadmessage($c->maketext(q{Can't create archive "[_1]": [_2]}, $archive, $error)); + } + return $c->Refresh; } else { - $c->addbadmessage( - $c->maketext(q{Can't create archive "[_1]": command returned [_2]}, $archive, systemError($?))); + return $c->include('ContentGenerator/Instructor/FileManager/archive', dir => $dir, files => \@files); } - return $c->Refresh; } # Unpack a gzipped tar archive sub UnpackArchive ($c) { my $archive = $c->getFile('unpack'); return '' unless $archive; - if ($archive !~ m/\.(tar|tar\.gz|tgz)$/) { - $c->addbadmessage($c->maketext('You can only unpack files ending in ".tgz", ".tar" or ".tar.gz"')); + if ($archive !~ m/\.(tar|tar\.gz|tgz|zip)$/) { + $c->addbadmessage($c->maketext('You can only unpack files ending in ".zip", ".tgz", ".tar" or ".tar.gz"')); } else { $c->unpack_archive($archive); } @@ -379,19 +460,132 @@ sub UnpackArchive ($c) { } sub unpack_archive ($c, $archive) { - my $z = $archive =~ m/\.tar$/ ? '' : 'z'; - my $dir = "$c->{courseRoot}/$c->{pwd}"; - my $tar = 'cd ' . shell_quote($dir) . " && $c->{ce}{externalPrograms}{tar} -vx${z}f " . shell_quote($archive); - my @files = readpipe "$tar 2>&1"; - - if ($? == 0) { - my $n = scalar(@files); - $c->addgoodmessage($c->maketext('[quant,_1,file] unpacked successfully', $n)); - return 1; + my $dir = Mojo::File->new($c->{courseRoot}, $c->{pwd}); + + my (@members, @existing_files, @outside_files); + my $num_extracted = 0; + + if ($archive =~ m/\.zip$/) { + my $zip = Archive::Zip->new($dir->child($archive)->to_string); + unless ($zip) { + $c->addbadmessage($c->maketext(q{Unable to read zip archive file "[_1]".}, $dir->child($archive))); + return 0; + } + + Archive::Zip::setErrorHandler(sub ($error) { + chomp $error; + $c->addbadmessage($error); + }); + + @members = $zip->members; + for (@members) { + my $out_file = $dir->child($_->fileName)->realpath; + if ($out_file !~ /^$dir/) { + push(@outside_files, $_->fileName); + next; + } + + if (!$c->param('overwrite') && -e $out_file) { + push(@existing_files, $_->fileName); + next; + } + ++$num_extracted if $zip->extractMember($_ => $out_file->to_string) == AZ_OK; + } + + Archive::Zip::setErrorHandler(); + } elsif ($archive =~ m/\.(tar(\.gz)?|tgz)$/) { + local $Archive::Tar::WARN = 0; + + my $tar = Archive::Tar->new($dir->child($archive)->to_string); + unless ($tar) { + $c->addbadmessage($c->maketext(q{Unable to read tar archive file "[_1]".}, $dir->child($archive))); + return 0; + } + + $tar->setcwd($dir->to_string); + + @members = $tar->list_files; + for (@members) { + my $out_file = $dir->child($_)->realpath; + if ($out_file !~ /^$dir/) { + push(@outside_files, $_); + next; + } + + if (!$c->param('overwrite') && -e $dir->child($_)) { + push(@existing_files, $_); + next; + } + + unless ($tar->extract_file($_)) { + $c->addbadmessage($tar->error); + next; + } + ++$num_extracted; + } } else { - $c->addbadmessage($c->maketext(q{Can't unpack "[_1]": command returned [_2]}, $archive, systemError($?))); + $c->addbadmessage($c->maketext('Unsupported archive type in file "[_1]"', $archive)); return 0; } + + if (@outside_files) { + $c->addbadmessage( + $c->tag( + 'p', + $c->maketext( + 'The following [plural,_1,file is,files are] outside the current working directory ' + . 'and can not be safely unpacked.', + scalar(@outside_files), + ) + ) + . $c->tag( + 'div', + $c->tag( + 'ul', + $c->c( + (map { $c->tag('li', $_) } @outside_files[ 0 .. min(29, $#outside_files) ]), + ( + @outside_files > 30 + ? $c->tag('li', + $c->maketext('[quant,_1,more file,more files] not shown', @outside_files - 30)) + : () + ) + )->join('') + ) + ) + ); + } + + if (@existing_files) { + $c->addbadmessage( + $c->tag( + 'p', + $c->maketext( + 'The following [plural,_1,file already exists,files already exist]. ' + . 'Check "Overwrite existing files silently" to unpack [plural,_1,this file,these files].', + scalar(@existing_files), + ) + ) + . $c->tag( + 'div', + $c->tag( + 'ul', + $c->c( + (map { $c->tag('li', $_) } @existing_files[ 0 .. min(29, $#existing_files) ]), + ( + @existing_files > 30 + ? $c->tag('li', + $c->maketext('[quant,_1,more file,more files] not shown', @existing_files - 30)) + : () + ) + )->join('') + ) + ) + ); + } + + $c->addgoodmessage($c->maketext('[quant,_1,file] unpacked successfully', $num_extracted)) if $num_extracted; + return $num_extracted == @members; } # Make a new file and edit it @@ -472,9 +666,8 @@ sub Upload ($c) { $c->Confirm( $c->tag( 'p', - $c->b( - $c->maketext('File [_1] already exists. Overwrite it, or rename it as:', $name) - ) + $c->b($c->maketext( + 'File [_1] already exists. Overwrite it, or rename it as:', $name)) ), uniqueName($dir, $name), $c->maketext('Rename'), @@ -520,7 +713,7 @@ sub Upload ($c) { if (-e $file) { $c->addgoodmessage($c->maketext('File "[_1]" uploaded successfully', $name)); - if ($name =~ m/\.(tar|tar\.gz|tgz)$/ && $c->getFlag('unpack')) { + if ($name =~ m/\.(tar|tar\.gz|tgz|zip)$/ && $c->getFlag('unpack')) { if ($c->unpack_archive($name) && $c->getFlag('autodelete')) { if (unlink($file)) { $c->addgoodmessage($c->maketext('Archive "[_1]" deleted', $name)) } else { $c->addbadmessage($c->maketext(q{Can't delete archive "[_1]": [_2]}, $name, $!)) } @@ -597,12 +790,22 @@ sub directoryListing ($c, $pwd) { my @names = sortByName(undef, grep {/^[^.]/} readDirectory($dir)); for my $name (@names) { unless ($name eq 'DATA') { #FIXME don't view the DATA directory - my $file = "$dir/$name"; + my $file = "$dir/$name"; + + my $type = 0; + $type |= 1 if -l $file; # Symbolic link + $type |= 2 if !-l $file && -d $file; # Directory + $type |= 4 if -f $file; # Regular file + $type |= 8 if -T $file; # Text file + $type |= 16 if $file =~ m/\.(gif|jpg|png)$/i; # Image file + my $label = $name; - $label .= '@' if (-l $file); - $label .= '/' if (-d $file && !-l $file); + $label .= '@' if $type & 1; + $label .= '/' if $type & 2; + $len = length($label) if length($label) > $len; - push(@values, [ $label => $name ]); + + push(@values, [ $label => $name, data => { type => $type } ]); } } if ($c->getFlag('dates')) { @@ -610,9 +813,8 @@ sub directoryListing ($c, $pwd) { for my $name (@values) { my $file = "$dir/$name->[1]"; my ($size, $date) = (lstat($file))[ 7, 9 ]; - $name->[0] = - $c->b( - sprintf("%-${len}s%-16s%10s", $name->[0], -d $file ? ('', '') : (getDate($date), getSize($size))) + $name->[0] = $c->b( + sprintf("%-${len}s%-16s%10s", $name->[0], -d $file ? ('', '') : (getDate($date), getSize($size))) =~ s/\s/ /gr); } } diff --git a/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetList.pm b/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetList.pm index 903e2df08d..3cd7f02f70 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetList.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetList.pm @@ -78,14 +78,12 @@ Delete sets: use Mojo::File; use WeBWorK::Debug; -use WeBWorK::Utils qw(timeToSec listFilesRecursive jitar_id_to_seq seq_to_jitar_id x - format_set_name_internal format_set_name_display); -use WeBWorK::Utils::Instructor qw(assignSetToUser assignSetToAllUsers addProblemToSet); +use WeBWorK::Utils qw(x format_set_name_internal format_set_name_display); +use WeBWorK::Utils::Instructor qw(assignSetToUser); +use WeBWorK::File::SetDef qw(importSetsFromDef exportSetsToDef); -use constant HIDE_SETS_THRESHOLD => 500; -use constant DEFAULT_VISIBILITY_STATE => 1; -use constant DEFAULT_ENABLED_REDUCED_SCORING_STATE => 0; -use constant ONE_WEEK => 60 * 60 * 24 * 7; +use constant HIDE_SETS_THRESHOLD => 500; +use constant ONE_WEEK => 60 * 60 * 24 * 7; use constant EDIT_FORMS => [qw(save_edit cancel_edit)]; use constant VIEW_FORMS => [qw(filter sort edit publish import export score create delete)]; @@ -138,6 +136,15 @@ use constant FIELD_TYPES => { enable_reduced_scoring => 'check' }; +use constant SORTABLE_FIELDS => { + set_id => 1, + open_date => 1, + reduced_scoring_date => 1, + due_date => 1, + answer_date => 1, + visible => 1 +}; + sub pre_header_initialize ($c) { my $db = $c->db; my $authz = $c->authz; @@ -185,12 +192,13 @@ sub initialize ($c) { my $user = $c->param('user'); # Make sure these are defined for the templats. - $c->stash->{fieldNames} = VIEW_FIELD_ORDER(); - $c->stash->{formsToShow} = VIEW_FORMS(); - $c->stash->{formTitles} = FORM_TITLES(); - $c->stash->{formPerms} = FORM_PERMS(); - $c->stash->{fieldTypes} = FIELD_TYPES(); - $c->stash->{sets} = []; + $c->stash->{fieldNames} = VIEW_FIELD_ORDER(); + $c->stash->{formsToShow} = VIEW_FORMS(); + $c->stash->{formTitles} = FORM_TITLES(); + $c->stash->{formPerms} = FORM_PERMS(); + $c->stash->{fieldTypes} = FIELD_TYPES(); + $c->stash->{sortableFields} = SORTABLE_FIELDS(); + $c->stash->{sets} = []; # Determine if the user has permisson to do anything here. return unless $authz->hasPermissions($user, 'access_instructor_tools'); @@ -222,8 +230,10 @@ sub initialize ($c) { $c->{selectedSetIDs} = []; } - $c->{primarySortField} = $c->param("primarySortField") || "due_date"; - $c->{secondarySortField} = $c->param("secondarySortField") || "open_date"; + $c->{primarySortField} = $c->param('primarySortField') || 'due_date'; + $c->{primarySortOrder} = $c->param('primarySortOrder') || 'ASC'; + $c->{secondarySortField} = $c->param('secondarySortField') || 'open_date'; + $c->{secondarySortOrder} = $c->param('secondarySortOrder') || 'ASC'; # Call action handler my $actionID = $c->param("action"); @@ -237,15 +247,13 @@ sub initialize ($c) { my $actionHandler = "${actionID}_handler"; my ($success, $action_result) = $c->$actionHandler; if ($success) { - $c->addgoodmessage($c->b($c->maketext('Result of last action performed: [_1]', $action_result))); + $c->addgoodmessage($c->b($action_result)); } else { - $c->addbadmessage($c->b($c->maketext('Result of last action performed: [_1]', $action_result))); + $c->addbadmessage($c->b($action_result)); } } else { $c->addbadmessage($c->maketext('You are not authorized to perform this action.')); } - } else { - $c->addgoodmessage($c->maketext("Please select action to be performed.")); } $c->stash->{fieldNames} = @@ -255,12 +263,21 @@ sub initialize ($c) { [ grep { !/enable_reduced_scoring|reduced_scoring_date/ } @{ $c->stash->{fieldNames} } ]; } + # A scalar reference must be used for the order by clause in getGlobalSetsWhere due to a very limited override of + # the SQL::Abstract _order_by method in WeBWorK::DB::Utils::SQLAbstractIdentTrans. Since scalar references bypass + # the SQL::Abstract injection guard, care must be taken to ensure that only the allowed values are used. + die 'Possible SQL injection attempt detected.' + unless SORTABLE_FIELDS()->{ $c->{primarySortField} } + && SORTABLE_FIELDS()->{ $c->{secondarySortField} } + && ($c->{primarySortOrder} eq 'ASC' || $c->{primarySortOrder} eq 'DESC') + && ($c->{secondarySortOrder} eq 'ASC' || $c->{secondarySortOrder} eq 'DESC'); + $c->stash->{formsToShow} = $c->{editMode} ? EDIT_FORMS() : $c->{exportMode} ? EXPORT_FORMS() : VIEW_FORMS(); # Get requested sets in the requested order. $c->stash->{sets} = [ @{ $c->{visibleSetIDs} } ? $db->getGlobalSetsWhere({ set_id => $c->{visibleSetIDs} }, - [ $c->{primarySortField}, $c->{secondarySortField} ]) + \("$c->{primarySortField} $c->{primarySortOrder}, $c->{secondarySortField} $c->{secondarySortOrder}")) : () ]; @@ -284,16 +301,16 @@ sub filter_handler ($c) { my $scope = $c->param('action.filter.scope'); if ($scope eq "all") { - $result = $c->maketext("showing all sets"); + $result = $c->maketext('Showing all sets.'); $c->{visibleSetIDs} = $c->{allSetIDs}; } elsif ($scope eq "none") { - $result = $c->maketext("showing no sets"); + $result = $c->maketext('Showing no sets.'); $c->{visibleSetIDs} = []; } elsif ($scope eq "selected") { - $result = $c->maketext("showing selected sets"); + $result = $c->maketext('Showing selected sets.'); $c->{visibleSetIDs} = [ $c->param('selected_sets') ]; } elsif ($scope eq "match_ids") { - $result = $c->maketext("showing matching sets"); + $result = $c->maketext('Showing matching sets.'); my @searchTerms = map { format_set_name_internal($_) } split /\s*,\s*/, $c->param('action.filter.set_ids'); my $regexTerms = join('|', @searchTerms); my @setIDs = grep {/$regexTerms/i} @{ $c->{allSetIDs} }; @@ -310,21 +327,50 @@ sub filter_handler ($c) { } sub sort_handler ($c) { - my $primary = $c->param('action.sort.primary'); - my $secondary = $c->param('action.sort.secondary'); + if (defined $c->param('labelSortMethod') || defined $c->param('labelSortOrder')) { + if (defined $c->param('labelSortOrder')) { + $c->{ $c->param('labelSortOrder') . 'SortOrder' } = + $c->{ $c->param('labelSortOrder') . 'SortOrder' } eq 'ASC' ? 'DESC' : 'ASC'; + } elsif ($c->param('labelSortMethod') eq $c->{primarySortField}) { + $c->{primarySortOrder} = $c->{primarySortOrder} eq 'ASC' ? 'DESC' : 'ASC'; + } else { + $c->{secondarySortField} = $c->{primarySortField}; + $c->{secondarySortOrder} = $c->{primarySortOrder}; + $c->{primarySortField} = $c->param('labelSortMethod'); + $c->{primarySortOrder} = 'ASC'; + } - $c->{primarySortField} = $primary; - $c->{secondarySortField} = $secondary; + $c->param('action.sort.primary', $c->{primarySortField}); + $c->param('action.sort.primary.order', $c->{primarySortOrder}); + $c->param('action.sort.secondary', $c->{secondarySortField}); + $c->param('action.sort.secondary.order', $c->{secondarySortOrder}); + } else { + $c->{primarySortField} = $c->param('action.sort.primary'); + $c->{primarySortOrder} = $c->param('action.sort.primary.order'); + $c->{secondarySortField} = $c->param('action.sort.secondary'); + $c->{secondarySortOrder} = $c->param('action.sort.secondary.order'); + } my %names = ( - set_id => $c->maketext("Set Name"), - open_date => $c->maketext("Open Date"), - due_date => $c->maketext("Close Date"), - answer_date => $c->maketext("Answer Date"), - visible => $c->maketext("Visibility"), + set_id => $c->maketext("Set Name"), + open_date => $c->maketext("Open Date"), + reduced_scoring_date => $c->maketext("Reduced Scoring Date"), + due_date => $c->maketext("Close Date"), + answer_date => $c->maketext("Answer Date"), + visible => $c->maketext("Visibility"), ); - return (1, $c->maketext("Sort by [_1] and then by [_2]", $names{$primary}, $names{$secondary})); + return ( + 1, + $c->maketext( + 'Sets sorted by [_1] in [plural,_2,ascending,descending] order, ' + . 'and then by [_3] in [plural,_4,ascending,descending] order.', + $names{ $c->{primarySortField} }, + $c->{primarySortOrder} eq 'ASC' ? 1 : 2, + $names{ $c->{secondarySortField} }, + $c->{secondarySortOrder} eq 'ASC' ? 1 : 2 + ) + ); } sub edit_handler ($c) { @@ -332,13 +378,13 @@ sub edit_handler ($c) { my $scope = $c->param('action.edit.scope'); if ($scope eq "all") { - $result = $c->maketext("editing all sets"); + $result = $c->maketext('Editing all sets.'); $c->{visibleSetIDs} = $c->{allSetIDs}; } elsif ($scope eq "visible") { - $result = $c->maketext("editing listed sets"); + $result = $c->maketext('Editing listed sets.'); # leave visibleSetIDs alone } elsif ($scope eq "selected") { - $result = $c->maketext("editing selected sets"); + $result = $c->maketext('Editing selected sets.'); $c->{visibleSetIDs} = [ $c->param('selected_sets') ]; } $c->{editMode} = 1; @@ -358,25 +404,25 @@ sub publish_handler ($c) { if ($scope eq "none") { @setIDs = (); - @result = (0, $c->maketext("No change made to any set")); + @result = (0, $c->maketext('No change made to any set.')); } elsif ($scope eq "all") { @setIDs = @{ $c->{allSetIDs} }; @result = $value - ? (1, $c->maketext("All sets made visible for all students")) - : (1, $c->maketext("All sets hidden from all students")); + ? (1, $c->maketext('All sets made visible for all students.')) + : (1, $c->maketext('All sets hidden from all students.')); } elsif ($scope eq "visible") { @setIDs = @{ $c->{visibleSetIDs} }; @result = $value - ? (1, $c->maketext("All listed sets were made visible for all the students")) - : (1, $c->maketext("All listed sets were hidden from all the students")); + ? (1, $c->maketext('All listed sets were made visible for all the students.')) + : (1, $c->maketext('All listed sets were hidden from all the students.')); } elsif ($scope eq "selected") { @setIDs = $c->param('selected_sets'); @result = $value - ? (1, $c->maketext("All selected sets made visible for all students")) - : (1, $c->maketext("All selected sets hidden from all students")); + ? (1, $c->maketext('All selected sets made visible for all students.')) + : (1, $c->maketext('All selected sets hidden from all students.')); } # Can we use UPDATE here, instead of fetch/change/store? @@ -419,7 +465,7 @@ sub delete_handler ($c) { $c->{selectedSetIDs} = [ keys %selectedSetIDs ]; my $num = @setIDsToDelete; - return (1, $c->maketext('deleted [_1] sets', $num)); + return (1, $c->maketext('Deleted [_1] sets.', $num)); } sub create_handler ($c) { @@ -474,8 +520,8 @@ sub create_handler ($c) { $newSetRecord->reduced_scoring_date($dueDate - 60 * $ce->{pg}{ansEvalDefaults}{reducedScoringPeriod}); $newSetRecord->due_date($dueDate); $newSetRecord->answer_date($dueDate + 60 * $ce->{pg}{answersOpenAfterDueDate}); - $newSetRecord->visible(DEFAULT_VISIBILITY_STATE()); # don't want students to see an empty set - $newSetRecord->enable_reduced_scoring(DEFAULT_ENABLED_REDUCED_SCORING_STATE()); + $newSetRecord->visible(1); # don't want students to see an empty set + $newSetRecord->enable_reduced_scoring(0); $newSetRecord->assignment_type('default'); $db->addGlobalSet($newSetRecord); } elsif ($type eq "copy") { @@ -533,28 +579,29 @@ sub create_handler ($c) { } sub import_handler ($c) { - my ($added, $skipped) = $c->importSetsFromDef( - $c->param('action.import.number') > 1 - ? '' # Cannot assign set names to multiple imports. - : format_set_name_internal($c->param('action.import.name')), + my ($added, $skipped, $errors) = importSetsFromDef( + $c->ce, + $c->db, + [ $c->param('action.import.source') ], + $c->{allSetIDs}, $c->param('action.import.assign'), $c->param('action.import.start.date') // 0, - $c->param('action.import.source') + # Cannot assign set names to multiple imports. + $c->param('action.import.number') > 1 ? '' : format_set_name_internal($c->param('action.import.name')), ); # Make new sets visible. push @{ $c->{visibleSetIDs} }, @$added; push @{ $c->{allSetIDs} }, @$added; - my $numAdded = @$added; - my $numSkipped = @$skipped; - return ( - 1, - $c->maketext( - '[_1] sets added, [_2] sets skipped. Skipped sets: ([_3])', $numAdded, - $numSkipped, join(', ', @$skipped) - ) + @$skipped ? 0 : 1, + $c->c( + $c->maketext('[quant,_1,set] added, [quant,_2,set] skipped.', scalar(@$added), scalar(@$skipped)), + @$errors + ? $c->tag('ul', class => 'my-1', $c->c(map { $c->tag('li', $c->maketext(@$_)) } @$errors)->join('')) + : '' + )->join('') ); } @@ -588,36 +635,31 @@ sub cancel_export_handler ($c) { } $c->{exportMode} = 0; - return (0, $c->maketext('export abandoned')); + return (0, $c->maketext('Export abandoned.')); } sub save_export_handler ($c) { - my @setIDsToExport = @{ $c->{selectedSetIDs} }; - - my %filenames = map { $_ => ($c->param("set.$_") || $_) } @setIDsToExport; + my ($exported, $skipped, $reason) = + exportSetsToDef($c->ce, $c->db, @{ $c->{selectedSetIDs} }); - my ($exported, $skipped, $reason) = $c->exportSetsToDef(%filenames); - - if (defined $c->param("prev_visible_sets")) { - $c->{visibleSetIDs} = [ $c->param("prev_visible_sets") ]; - } elsif (defined $c->param("no_prev_visble_sets")) { + if (defined $c->param('prev_visible_sets')) { + $c->{visibleSetIDs} = [ $c->param('prev_visible_sets') ]; + } elsif (defined $c->param('no_prev_visble_sets')) { $c->{visibleSetIDs} = []; } $c->{exportMode} = 0; - my $numExported = @$exported; - my $numSkipped = @$skipped; - - my @reasons = map { "set $_ - " . $reason->{$_} } keys %$reason; - return ( - !$numSkipped, - $c->b($c->maketext( - '[_1] sets exported, [_2] sets skipped. Skipped sets: ([_3])', - $numExported, $numSkipped, - $numSkipped ? $c->tag('ul', $c->c(map { $c->tag('li', $_) } @reasons)->join('')) : '' - )) + @$skipped ? 0 : 1, + $c->c( + $c->maketext('[quant,_1,set] exported, [quant,_2,set] skipped.', scalar(@$exported), scalar(@$skipped)), + @$skipped ? $c->tag( + 'ul', + class => 'my-1', + $c->c(map { $c->tag('li', "set $_ - " . $c->maketext(@{ $reason->{$_} })) } keys %$reason)->join('') + ) : '' + )->join('') ); } @@ -631,7 +673,7 @@ sub cancel_edit_handler ($c) { } $c->{editMode} = 0; - return (0, $c->maketext('changes abandoned')); + return (0, $c->maketext('Changes abandoned.')); } sub save_edit_handler ($c) { @@ -668,20 +710,20 @@ sub save_edit_handler ($c) { my $curr_time = time; my $seconds_per_year = 31_556_926; my $cutoff = $curr_time + $seconds_per_year * 10; - return (0, $c->maketext("Error: open date cannot be more than 10 years from now in set [_1]", $setID)) + return (0, $c->maketext('Error: Open date cannot be more than 10 years from now in set [_1].', $setID)) if $Set->open_date > $cutoff; - return (0, $c->maketext("Error: close date cannot be more than 10 years from now in set [_1]", $setID)) + return (0, $c->maketext('Error: Close date cannot be more than 10 years from now in set [_1].', $setID)) if $Set->due_date > $cutoff; - return (0, $c->maketext("Error: answer date cannot be more than 10 years from now in set [_1]", $setID)) + return (0, $c->maketext('Error: Answer date cannot be more than 10 years from now in set [_1].', $setID)) if $Set->answer_date > $cutoff; # Check that the open, due and answer dates are in increasing order. # Bail if this is not correct. if ($Set->open_date > $Set->due_date) { - return (0, $c->maketext("Error: Close date must come after open date in set [_1]", $setID)); + return (0, $c->maketext('Error: Close date must come after open date in set [_1].', $setID)); } if ($Set->due_date > $Set->answer_date) { - return (0, $c->maketext("Error: Answer date must come after close date in set [_1]", $setID)); + return (0, $c->maketext('Error: Answer date must come after close date in set [_1].', $setID)); } # check that the reduced scoring date is in the right place @@ -701,7 +743,7 @@ sub save_edit_handler ($c) { return ( 0, $c->maketext( - "Error: Reduced scoring date must come between the open date and close date in set [_1]", + 'Error: Reduced scoring date must come between the open date and close date in set [_1].', $setID ) ); @@ -720,810 +762,7 @@ sub save_edit_handler ($c) { $c->{editMode} = 0; - return (1, $c->maketext("changes saved")); -} - -# Utilities - -sub importSetsFromDef ($c, $newSetName, $assign, $startdate, @setDefFiles) { - my $ce = $c->ce; - my $db = $c->db; - my $dir = $ce->{courseDirs}{templates}; - my $mindate = 0; - - # If the user includes "following files" in a multiple selection - # it shows up here as "" which causes the importing to die. - # So, we select on filenames containing non-whitespace. - @setDefFiles = grep {/\S/} @setDefFiles; - - # FIXME: do we really want everything to fail on one bad file name? - foreach my $fileName (@setDefFiles) { - die $c->maketext("won't be able to read from file [_1]/[_2]: does it exist? is it readable?", $dir, $fileName) - unless -r "$dir/$fileName"; - } - - # Get a list of set ids of existing sets in the course. This is used to - # ensure that an imported set does not already exist. - my %allSets = map { $_ => 1 } @{ $c->{allSetIDs} }; - - my (@added, @skipped); - - foreach my $set_definition_file (@setDefFiles) { - - debug("$set_definition_file: reading set definition file"); - # read data in set definition file - my ( - $setName, $paperHeaderFile, $screenHeaderFile, $openDate, - $dueDate, $answerDate, $ra_problemData, $assignmentType, - $enableReducedScoring, $reducedScoringDate, $attemptsPerVersion, $timeInterval, - $versionsPerInterval, $versionTimeLimit, $problemRandOrder, $problemsPerPage, - $hideScore, $hideScoreByProblem, $hideWork, $timeCap, - $restrictIP, $restrictLoc, $relaxRestrictIP, $description, - $emailInstructor, $restrictProbProgression - ) = $c->readSetDef($set_definition_file); - my @problemList = @{$ra_problemData}; - - # Use the original name if form doesn't specify a new one. - # The set acquires the new name specified by the form. A blank - # entry on the form indicates that the imported set name will be used. - $setName = $newSetName if $newSetName; - - if ($allSets{$setName}) { - # this set already exists!! - push @skipped, $setName; - next; - } else { - push @added, $setName; - } - - # keep track of which as the earliest answer date - if ($mindate > $openDate || $mindate == 0) { - $mindate = $openDate; - } - - debug("$set_definition_file: adding set"); - # add the data to the set record - my $newSetRecord = $db->newGlobalSet; - $newSetRecord->set_id($setName); - $newSetRecord->set_header($screenHeaderFile); - $newSetRecord->hardcopy_header($paperHeaderFile); - $newSetRecord->open_date($openDate); - $newSetRecord->due_date($dueDate); - $newSetRecord->answer_date($answerDate); - $newSetRecord->visible(DEFAULT_VISIBILITY_STATE); - $newSetRecord->reduced_scoring_date($reducedScoringDate); - $newSetRecord->enable_reduced_scoring($enableReducedScoring); - $newSetRecord->description($description); - $newSetRecord->email_instructor($emailInstructor); - $newSetRecord->restrict_prob_progression($restrictProbProgression); - - # gateway/version data. these should are all initialized to '' - # by readSetDef, so for non-gateway/versioned sets they'll just - # be stored as null - $newSetRecord->assignment_type($assignmentType); - $newSetRecord->attempts_per_version($attemptsPerVersion); - $newSetRecord->time_interval($timeInterval); - $newSetRecord->versions_per_interval($versionsPerInterval); - $newSetRecord->version_time_limit($versionTimeLimit); - $newSetRecord->problem_randorder($problemRandOrder); - $newSetRecord->problems_per_page($problemsPerPage); - $newSetRecord->hide_score($hideScore); - $newSetRecord->hide_score_by_problem($hideScoreByProblem); - $newSetRecord->hide_work($hideWork); - $newSetRecord->time_limit_cap($timeCap); - $newSetRecord->restrict_ip($restrictIP); - $newSetRecord->relax_restrict_ip($relaxRestrictIP); - - #create the set - eval { $db->addGlobalSet($newSetRecord) }; - die $c->maketext("addGlobalSet [_1] in ProblemSetList: [_2]", $setName, $@) if $@; - - #do we need to add locations to the set_locations table? - if ($restrictIP ne 'No' && $restrictLoc) { - if ($db->existsLocation($restrictLoc)) { - if (!$db->existsGlobalSetLocation($setName, $restrictLoc)) { - my $newSetLocation = $db->newGlobalSetLocation; - $newSetLocation->set_id($setName); - $newSetLocation->location_id($restrictLoc); - eval { $db->addGlobalSetLocation($newSetLocation) }; - warn($c->maketext( - "error adding set location [_1] for set [_2]: [_3]", - $restrictLoc, $setName, $@ - )) - if $@; - } else { - # this should never happen. - warn( - $c->maketext( - "input set location [_1] already exists for set [_2].", $restrictLoc, $setName - ) - . "\n" - ); - } - } else { - warn( - $c->maketext("restriction location [_1] does not exist. IP restrictions have been ignored.", - $restrictLoc) - . "\n" - ); - $newSetRecord->restrict_ip('No'); - $newSetRecord->relax_restrict_ip('No'); - eval { $db->putGlobalSet($newSetRecord) }; - # we ignore error messages here; if the set - # added without error before, we assume - # (ha) that it will put without trouble - } - } - - debug("$set_definition_file: adding problems to database"); - # add problems - my $freeProblemID = WeBWorK::Utils::max($db->listGlobalProblems($setName)) + 1; - foreach my $rh_problem (@problemList) { - addProblemToSet( - $db, $ce->{problemDefaults}, - setName => $setName, - sourceFile => $rh_problem->{source_file}, - problemID => $rh_problem->{problemID} ? $rh_problem->{problemID} : $freeProblemID++, - value => $rh_problem->{value}, - maxAttempts => $rh_problem->{max_attempts}, - showMeAnother => $rh_problem->{showMeAnother}, - showHintsAfter => $rh_problem->{showHintsAfter}, - prPeriod => $rh_problem->{prPeriod}, - attToOpenChildren => $rh_problem->{attToOpenChildren}, - countsParentGrade => $rh_problem->{countsParentGrade} - ); - } - - if ($assign eq "all") { - assignSetToAllUsers($db, $ce, $setName); - } else { - my $userName = $c->param('user'); - assignSetToUser($db, $userName, $newSetRecord); ## always assign set to instructor - } - } - - #if there is a start date we have to reopen all of the sets that were added and shift the dates - if ($startdate) { - #the shift for all of the dates is from the min date to the start date - my $dateshift = $startdate - $mindate; - - foreach my $setID (@added) { - my $setRecord = $db->getGlobalSet($setID); - $setRecord->open_date($setRecord->open_date + $dateshift); - $setRecord->reduced_scoring_date($setRecord->reduced_scoring_date + $dateshift); - $setRecord->due_date($setRecord->due_date + $dateshift); - $setRecord->answer_date($setRecord->answer_date + $dateshift); - $db->putGlobalSet($setRecord); - } - } - - return \@added, \@skipped; -} - -sub readSetDef ($c, $fileName) { - my $ce = $c->ce; - my $templateDir = $ce->{courseDirs}{templates}; - my $filePath = "$templateDir/$fileName"; - my $weight_default = $ce->{problemDefaults}{value}; - my $max_attempts_default = $ce->{problemDefaults}{max_attempts}; - my $att_to_open_children_default = $ce->{problemDefaults}{att_to_open_children}; - my $counts_parent_grade_default = $ce->{problemDefaults}{counts_parent_grade}; - my $showMeAnother_default = $ce->{problemDefaults}{showMeAnother}; - my $showHintsAfter_default = $ce->{problemDefaults}{showHintsAfter}; - my $prPeriod_default = $ce->{problemDefaults}{prPeriod}; - - my $setName = ''; - - if ($fileName =~ m|^(.*/)?set([.\w-]+)\.def$|) { - $setName = $2; - } else { - $c->addbadmessage( - qq{The setDefinition file name must begin with set and must end with }, - qq{.def. Every thing in between becomes the name of the set. For example }, - qq{set1.def, setExam.def, and setsample7.def define }, - qq{sets named 1, Exam, and sample7 respectively. }, - qq{The filename "$fileName" you entered is not legal\n } - ); - - } - - my ($name, $weight, $attemptLimit, $continueFlag); - my $paperHeaderFile = ''; - my $screenHeaderFile = ''; - my $description = ''; - my ($dueDate, $openDate, $reducedScoringDate, $answerDate); - my @problemData; - - # added fields for gateway test/versioned set definitions: - my ( - $assignmentType, $attemptsPerVersion, $timeInterval, $enableReducedScoring, - $versionsPerInterval, $versionTimeLimit, $problemRandOrder, $problemsPerPage, - $restrictLoc, $emailInstructor, $restrictProbProgression, $countsParentGrade, - $attToOpenChildren, $problemID, $showMeAnother, $showHintsAfter, - $prPeriod, $listType - ) = ('') x 18; # initialize these to '' - my ($timeCap, $restrictIP, $relaxRestrictIP) = (0, 'No', 'No'); - # additional fields currently used only by gateways; later, the world? - my ($hideScore, $hideScoreByProblem, $hideWork,) = ('N', 'N', 'N'); - - my %setInfo; - if (my $SETFILENAME = Mojo::File->new($filePath)->open('<')) { - # Read and check set data - while (my $line = <$SETFILENAME>) { - - chomp $line; - $line =~ s|(#.*)||; # Don't read past comments - unless ($line =~ /\S/) { next; } # Skip blank lines - $line =~ s|\s*$||; # Trim trailing spaces - $line =~ m|^\s*(\w+)\s*=?\s*(.*)|; - - # Sanity check entries - my $item = $1; - $item = '' unless defined $item; - my $value = $2; - $value = '' unless defined $value; - - if ($item eq 'setNumber') { - next; - } elsif ($item eq 'paperHeaderFile') { - $paperHeaderFile = $value; - } elsif ($item eq 'screenHeaderFile') { - $screenHeaderFile = $value; - } elsif ($item eq 'dueDate') { - $dueDate = $value; - } elsif ($item eq 'openDate') { - $openDate = $value; - } elsif ($item eq 'answerDate') { - $answerDate = $value; - } elsif ($item eq 'enableReducedScoring') { - $enableReducedScoring = $value; - } elsif ($item eq 'reducedScoringDate') { - $reducedScoringDate = $value; - } elsif ($item eq 'assignmentType') { - $assignmentType = $value; - } elsif ($item eq 'attemptsPerVersion') { - $attemptsPerVersion = $value; - } elsif ($item eq 'timeInterval') { - $timeInterval = $value; - } elsif ($item eq 'versionsPerInterval') { - $versionsPerInterval = $value; - } elsif ($item eq 'versionTimeLimit') { - $versionTimeLimit = $value; - } elsif ($item eq 'problemRandOrder') { - $problemRandOrder = $value; - } elsif ($item eq 'problemsPerPage') { - $problemsPerPage = $value; - } elsif ($item eq 'hideScore') { - $hideScore = ($value) ? $value : 'N'; - } elsif ($item eq 'hideScoreByProblem') { - $hideScoreByProblem = ($value) ? $value : 'N'; - } elsif ($item eq 'hideWork') { - $hideWork = ($value) ? $value : 'N'; - } elsif ($item eq 'capTimeLimit') { - $timeCap = ($value) ? 1 : 0; - } elsif ($item eq 'restrictIP') { - $restrictIP = ($value) ? $value : 'No'; - } elsif ($item eq 'restrictLocation') { - $restrictLoc = ($value) ? $value : ''; - } elsif ($item eq 'relaxRestrictIP') { - $relaxRestrictIP = ($value) ? $value : 'No'; - } elsif ($item eq 'emailInstructor') { - $emailInstructor = ($value) ? $value : 0; - } elsif ($item eq 'restrictProbProgression') { - $restrictProbProgression = ($value) ? $value : 0; - } elsif ($item eq 'description') { - $value =~ s//\n/g; - $description = $value; - } elsif ($item eq 'problemList' - || $item eq 'problemListV2') - { - $listType = $item; - last; - } else { - warn $c->maketext("readSetDef error, can't read the line: ||[_1]||", $line); - } - } - - # Check and format dates - my ($time1, $time2, $time3) = map { $c->parseDateTime($_); } ($openDate, $dueDate, $answerDate); - - unless ($time1 <= $time2 and $time2 <= $time3) { - warn $c->maketext('The open date: [_1], close date: [_2], and answer date: [_3] ' - . 'must be defined and in chronological order.', - $openDate, $dueDate, $answerDate); - } - - # validate reduced credit date - - # Special handling for values which seem to roughly correspond to epoch 0. - # namely if the date string contains 12/31/1969 or 01/01/1970 - if ($reducedScoringDate) { - if (($reducedScoringDate =~ m+12/31/1969+) || ($reducedScoringDate =~ m+01/01/1970+)) { - my $origReducedScoringDate = $reducedScoringDate; - $reducedScoringDate = $c->parseDateTime($reducedScoringDate); - if ($reducedScoringDate != 0) { - # In this case we want to treat it BY FORCE as if the value did correspond to epoch 0. - warn $c->maketext( - 'The reduced credit date [_1] in the file probably was generated from ' - . 'the Unix epoch 0 value and is being treated as if it was Unix epoch 0.', - $origReducedScoringDate - ); - $reducedScoringDate = 0; - } - } else { - # Original behavior, which may cause problems for some time-zones when epoch 0 was set and does not - # parse back to 0. - $reducedScoringDate = $c->parseDateTime($reducedScoringDate); - } - } - - if ($reducedScoringDate) { - if ($reducedScoringDate < $time1 || $reducedScoringDate > $time2) { - warn $c->maketext("The reduced credit date should be between the open date [_1] and close date [_2]", - $openDate, $dueDate); - } elsif ($reducedScoringDate == 0 && $enableReducedScoring ne 'Y') { - # In this case - the date in the file was Unix epoch 0 (or treated as such), - # and unless $enableReducedScoring eq 'Y' we will leave it as 0. - } - } else { - $reducedScoringDate = $time2 - 60 * $ce->{pg}{ansEvalDefaults}{reducedScoringPeriod}; - } - - if ($enableReducedScoring ne '' && $enableReducedScoring eq 'Y') { - $enableReducedScoring = 1; - } elsif ($enableReducedScoring ne '' && $enableReducedScoring eq 'N') { - $enableReducedScoring = 0; - } elsif ($enableReducedScoring ne '') { - warn( - $c->maketext("The value [_1] for enableReducedScoring is not valid; it will be replaced with 'N'.", - $enableReducedScoring) - . "\n" - ); - $enableReducedScoring = 0; - } else { - $enableReducedScoring = DEFAULT_ENABLED_REDUCED_SCORING_STATE; - } - - # Check header file names - $paperHeaderFile =~ s/(.*?)\s*$/$1/; # Remove trailing white space - $screenHeaderFile =~ s/(.*?)\s*$/$1/; # Remove trailing white space - - # Gateway/version variable cleanup: convert times into seconds - $assignmentType ||= 'default'; - - $timeInterval = WeBWorK::Utils::timeToSec($timeInterval) - if ($timeInterval); - $versionTimeLimit = WeBWorK::Utils::timeToSec($versionTimeLimit) - if ($versionTimeLimit); - - # Check that the values for hideWork and hideScore are valid. - if ($hideScore ne 'N' - && $hideScore ne 'Y' - && $hideScore ne 'BeforeAnswerDate') - { - warn( - $c->maketext("The value [_1] for the hideScore option is not valid; it will be replaced with 'N'.", - $hideScore) - . "\n" - ); - $hideScore = 'N'; - } - if ($hideScoreByProblem ne 'N' - && $hideScoreByProblem ne 'Y' - && $hideScoreByProblem ne 'BeforeAnswerDate') - { - warn( - $c->maketext("The value [_1] for the hideScore option is not valid; it will be replaced with 'N'.", - $hideScoreByProblem) - . "\n" - ); - $hideScoreByProblem = 'N'; - } - if ($hideWork ne 'N' - && $hideWork ne 'Y' - && $hideWork ne 'BeforeAnswerDate') - { - warn( - $c->maketext("The value [_1] for the hideWork option is not valid; it will be replaced with 'N'.", - $hideWork) - . "\n" - ); - $hideWork = 'N'; - } - if ($timeCap ne '0' && $timeCap ne '1') { - warn( - $c->maketext( - "The value [_1] for the capTimeLimit option is not valid; it will be replaced with '0'.", - $timeCap) - . "\n" - ); - $timeCap = '0'; - } - if ($restrictIP ne 'No' - && $restrictIP ne 'DenyFrom' - && $restrictIP ne 'RestrictTo') - { - warn( - $c->maketext( - "The value [_1] for the restrictIP option is not valid; it will be replaced with 'No'.", - $restrictIP) - . "\n" - ); - $restrictIP = 'No'; - $restrictLoc = ''; - $relaxRestrictIP = 'No'; - } - if ($relaxRestrictIP ne 'No' - && $relaxRestrictIP ne 'AfterAnswerDate' - && $relaxRestrictIP ne 'AfterVersionAnswerDate') - { - warn( - $c->maketext( - "The value [_1] for the relaxRestrictIP option is not valid; it will be replaced with 'No'.", - $relaxRestrictIP) - . "\n" - ); - $relaxRestrictIP = 'No'; - } - # to verify that restrictLoc is valid requires a database - # call, so we defer that until we return to add the set - - # Read and check list of problems for the set - - # NOTE: There are now two versions of problemList, the first is an unlabeled - # list which may or may not contain a showMeAnother variable. This is supported - # but the unlabeled list is hard to work with. The new version prints a - # labeled list of values similar to how its done for the set variables - - if ($listType eq 'problemList') { - - while (my $line = <$SETFILENAME>) { - chomp $line; - $line =~ s/(#.*)//; ## don't read past comments - unless ($line =~ /\S/) { next; } ## skip blank lines - - # commas are valid in filenames, so we have to handle commas - # using backslash escaping, so \X will be replaced with X - my @line = (); - my $curr = ''; - for (my $i = 0; $i < length $line; $i++) { - my $c = substr($line, $i, 1); - if ($c eq '\\') { - $curr .= substr($line, ++$i, 1); - } elsif ($c eq ',') { - push @line, $curr; - $curr = ''; - } else { - $curr .= $c; - } - } - # anything left? - push(@line, $curr) if ($curr); - - # read the line and only look for $showMeAnother if it has the correct number of entries - # otherwise the default value will be used - if (scalar(@line) == 4) { - ($name, $weight, $attemptLimit, $showMeAnother, $continueFlag) = @line; - } else { - ($name, $weight, $attemptLimit, $continueFlag) = @line; - } - - # clean up problem values - $name =~ s/\s*//g; - $weight = "" unless defined($weight); - $weight =~ s/[^\d\.]*//g; - unless ($weight =~ /\d+/) { $weight = $weight_default; } - $attemptLimit = "" unless defined($attemptLimit); - $attemptLimit =~ s/[^\d-]*//g; - unless ($attemptLimit =~ /\d+/) { $attemptLimit = $max_attempts_default; } - $continueFlag = "0" unless (defined($continueFlag) && @problemData); - # can't put continuation flag onto the first problem - push( - @problemData, - { - source_file => $name, - value => $weight, - max_attempts => $attemptLimit, - showMeAnother => $showMeAnother, - continuation => $continueFlag, - # Use defaults for these since they are not going to be in the file. - prPeriod => $prPeriod_default, - showHintsAfter => $showHintsAfter_default, - } - ); - } - } else { - # This is the new version, it looks for pairs of entries - # of the form field name = value - while (my $line = <$SETFILENAME>) { - - chomp $line; - $line =~ s|(#.*)||; # Don't read past comments - unless ($line =~ /\S/) { next; } # Skip blank lines - $line =~ s|\s*$||; # Trim trailing spaces - $line =~ m|^\s*(\w+)\s*=?\s*(.*)|; - - # sanity check entries - my $item = $1; - $item = '' unless defined $item; - my $value = $2; - $value = '' unless defined $value; - - if ($item eq 'problem_start') { - next; - } elsif ($item eq 'source_file') { - warn($c->maketext('No source_file for problem in .def file')) unless $value; - $name = $value; - } elsif ($item eq 'value') { - $weight = ($value) ? $value : $weight_default; - } elsif ($item eq 'max_attempts') { - $attemptLimit = ($value) ? $value : $max_attempts_default; - } elsif ($item eq 'showMeAnother') { - $showMeAnother = ($value) ? $value : 0; - } elsif ($item eq 'showHintsAfter') { - $showHintsAfter = ($value) ? $value : -2; - } elsif ($item eq 'prPeriod') { - $prPeriod = ($value) ? $value : 0; - } elsif ($item eq 'restrictProbProgression') { - $restrictProbProgression = ($value) ? $value : 'No'; - } elsif ($item eq 'problem_id') { - $problemID = ($value) ? $value : ''; - } elsif ($item eq 'counts_parent_grade') { - $countsParentGrade = ($value) ? $value : 0; - } elsif ($item eq 'att_to_open_children') { - $attToOpenChildren = ($value) ? $value : 0; - } elsif ($item eq 'problem_end') { - - # clean up problem values - $name =~ s/\s*//g; - $weight = "" unless defined($weight); - $weight =~ s/[^\d\.]*//g; - unless ($weight =~ /\d+/) { $weight = $weight_default; } - $attemptLimit = "" unless defined($attemptLimit); - $attemptLimit =~ s/[^\d-]*//g; - unless ($attemptLimit =~ /\d+/) { $attemptLimit = $max_attempts_default; } - - unless ($countsParentGrade =~ /(0|1)/) { $countsParentGrade = $counts_parent_grade_default; } - $countsParentGrade =~ s/[^\d-]*//g; - - unless ($showMeAnother =~ /-?\d+/) { $showMeAnother = $showMeAnother_default; } - $showMeAnother =~ s/[^\d-]*//g; - - unless ($showHintsAfter =~ /-?\d+/) { $showHintsAfter = $showHintsAfter_default; } - $showHintsAfter =~ s/[^\d-]*//g; - - unless ($prPeriod =~ /-?\d+/) { $prPeriod = $prPeriod_default; } - $prPeriod =~ s/[^\d-]*//g; - - unless ($attToOpenChildren =~ /\d+/) { $attToOpenChildren = $att_to_open_children_default; } - $attToOpenChildren =~ s/[^\d-]*//g; - - if ($assignmentType eq 'jitar') { - unless ($problemID =~ /[\d\.]+/) { $problemID = ''; } - $problemID =~ s/[^\d\.-]*//g; - $problemID = seq_to_jitar_id(split(/\./, $problemID)); - } else { - unless ($problemID =~ /\d+/) { $problemID = ''; } - $problemID =~ s/[^\d-]*//g; - } - - # can't put continuation flag onto the first problem - push( - @problemData, - { - source_file => $name, - problemID => $problemID, - value => $weight, - max_attempts => $attemptLimit, - showMeAnother => $showMeAnother, - showHintsAfter => $showHintsAfter, - prPeriod => $prPeriod, - attToOpenChildren => $attToOpenChildren, - countsParentGrade => $countsParentGrade, - } - ); - - # reset the various values - $name = ''; - $problemID = ''; - $weight = ''; - $attemptLimit = ''; - $showMeAnother = ''; - $showHintsAfter = ''; - $attToOpenChildren = ''; - $countsParentGrade = ''; - - } else { - warn $c->maketext("readSetDef error, can't read the line: ||[_1]||", $line); - } - } - - } - - $SETFILENAME->close; - return ( - $setName, $paperHeaderFile, $screenHeaderFile, $time1, - $time2, $time3, \@problemData, $assignmentType, - $enableReducedScoring, $reducedScoringDate, $attemptsPerVersion, $timeInterval, - $versionsPerInterval, $versionTimeLimit, $problemRandOrder, $problemsPerPage, - $hideScore, $hideScoreByProblem, $hideWork, $timeCap, - $restrictIP, $restrictLoc, $relaxRestrictIP, $description, - $emailInstructor, $restrictProbProgression - ); - } else { - warn $c->maketext("Can't open file [_1]", $filePath) . "\n"; - return; - } -} - -sub exportSetsToDef ($c, %filenames) { - my $ce = $c->ce; - my $db = $c->db; - - my (@exported, @skipped, %reason); - -SET: foreach my $set (keys %filenames) { - - my $fileName = $filenames{$set}; - $fileName .= ".def" unless $fileName =~ m/\.def$/; - $fileName = "set" . $fileName unless $fileName =~ m/^set/; - # files can be exported to sub directories but not parent directories - if ($fileName =~ /\.\./) { - push @skipped, $set; - $reason{$set} = $c->maketext("Illegal filename contains '..'"); - next SET; - } - - my $setRecord = $db->getGlobalSet($set); - unless (defined $setRecord) { - push @skipped, $set; - $reason{$set} = $c->maketext("No record found."); - next SET; - } - my $filePath = $ce->{courseDirs}->{templates} . '/' . $fileName; - - # back up existing file - if (-e $filePath) { - rename($filePath, "$filePath.bak") - or $reason{$set} = $c->maketext("Existing file [_1] could not be backed up and was lost.", $filePath); - } - - my $openDate = $c->formatDateTime($setRecord->open_date); - my $dueDate = $c->formatDateTime($setRecord->due_date); - my $answerDate = $c->formatDateTime($setRecord->answer_date); - my $reducedScoringDate = $c->formatDateTime($setRecord->reduced_scoring_date); - my $description = $setRecord->description; - if ($description) { - $description =~ s/\r?\n//g; - } - - my $assignmentType = $setRecord->assignment_type; - my $enableReducedScoring = $setRecord->enable_reduced_scoring ? 'Y' : 'N'; - my $setHeader = $setRecord->set_header; - my $paperHeader = $setRecord->hardcopy_header; - my $emailInstructor = $setRecord->email_instructor; - my $restrictProbProgression = $setRecord->restrict_prob_progression; - - my @problemList = $db->getGlobalProblemsWhere({ set_id => $set }, 'problem_id'); - - my $problemList = ''; - for my $problemRecord (@problemList) { - my $problem_id = $problemRecord->problem_id(); - - if ($setRecord->assignment_type eq 'jitar') { - $problem_id = join('.', jitar_id_to_seq($problem_id)); - } - - my $source_file = $problemRecord->source_file(); - my $value = $problemRecord->value(); - my $max_attempts = $problemRecord->max_attempts(); - my $showMeAnother = $problemRecord->showMeAnother(); - my $showHintsAfter = $problemRecord->showHintsAfter(); - my $prPeriod = $problemRecord->prPeriod(); - my $countsParentGrade = $problemRecord->counts_parent_grade(); - my $attToOpenChildren = $problemRecord->att_to_open_children(); - - # backslash-escape commas in fields - $source_file =~ s/([,\\])/\\$1/g; - $value =~ s/([,\\])/\\$1/g; - $max_attempts =~ s/([,\\])/\\$1/g; - $showMeAnother =~ s/([,\\])/\\$1/g; - $showHintsAfter =~ s/([,\\])/\\$1/g; - $prPeriod =~ s/([,\\])/\\$1/g; - - # This is the new way of saving problem information - # the labelled list makes it easier to add variables and - # easier to tell when they are missing - $problemList .= "problem_start\n"; - $problemList .= "problem_id = $problem_id\n"; - $problemList .= "source_file = $source_file\n"; - $problemList .= "value = $value\n"; - $problemList .= "max_attempts = $max_attempts\n"; - $problemList .= "showMeAnother = $showMeAnother\n"; - $problemList .= "showHintsAfter = $showHintsAfter\n"; - $problemList .= "prPeriod = $prPeriod\n"; - $problemList .= "counts_parent_grade = $countsParentGrade\n"; - $problemList .= "att_to_open_children = $attToOpenChildren \n"; - $problemList .= "problem_end\n"; - } - - # gateway fields - my $gwFields = ''; - if ($assignmentType =~ /gateway/) { - my $attemptsPerV = $setRecord->attempts_per_version; - my $timeInterval = $setRecord->time_interval; - my $vPerInterval = $setRecord->versions_per_interval; - my $vTimeLimit = $setRecord->version_time_limit; - my $probRandom = $setRecord->problem_randorder; - my $probPerPage = $setRecord->problems_per_page; - my $hideScore = $setRecord->hide_score; - my $hideScoreByProblem = $setRecord->hide_score_by_problem; - my $hideWork = $setRecord->hide_work; - my $timeCap = $setRecord->time_limit_cap; - $gwFields = <restrict_ip; - my $restrictFields = ''; - if ($restrictIP && $restrictIP ne 'No') { - # only store the first location - my $restrictLoc = ($db->listGlobalSetLocations($setRecord->set_id))[0]; - my $relaxRestrict = $setRecord->relax_restrict_ip; - $restrictLoc || ($restrictLoc = ''); - $restrictFields = - "restrictIP = $restrictIP" - . "\nrestrictLocation = $restrictLoc\n" - . "relaxRestrictIP = $relaxRestrict\n"; - } - - my $fileContents = <{courseDirs}->{templates}, $filePath); - eval { - open(my $SETDEF, '>', $filePath) or die $c->maketext("Failed to open [_1]", $filePath); - print $SETDEF $fileContents; - close $SETDEF; - }; - - if ($@) { - push @skipped, $set; - $reason{$set} = $@; - } else { - push @exported, $set; - } - - } - - return \@exported, \@skipped, \%reason; + return (1, $c->maketext('Changes saved.')); } 1; diff --git a/lib/WeBWorK/ContentGenerator/Instructor/UserList.pm b/lib/WeBWorK/ContentGenerator/Instructor/UserList.pm index 03a072d5ba..2f0a9f9a0a 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/UserList.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/UserList.pm @@ -100,16 +100,16 @@ use constant FORM_PERMS => { }; use constant SORT_SUBS => { - user_id => \&byUserID, - first_name => \&byFirstName, - last_name => \&byLastName, - email_address => \&byEmailAddress, - student_id => \&byStudentID, - status => \&byStatus, - section => \&bySection, - recitation => \&byRecitation, - comment => \&byComment, - permission => \&byPermission, + user_id => { ASC => \&byUserID, DESC => \&byDescUserID }, + first_name => { ASC => \&byFirstName, DESC => \&byDescFirstName }, + last_name => { ASC => \&byLastName, DESC => \&byDescLastName }, + email_address => { ASC => \&byEmailAddress, DESC => \&byDescEmailAddress }, + student_id => { ASC => \&byStudentID, DESC => \&byDescStudentID }, + status => { ASC => \&byStatus, DESC => \&byDescStatus }, + section => { ASC => \&bySection, DESC => \&byDescSection }, + recitation => { ASC => \&byRecitation, DESC => \&byDescRecitation }, + comment => { ASC => \&byComment, DESC => \&byDescComment }, + permission => { ASC => \&byPermission, DESC => \&byDescPermission } }; use constant FIELDS => [ @@ -207,15 +207,12 @@ sub pre_header_initialize ($c) { { map { $allUsers{$_}{permission} > $c->{userPermission} ? () : ($_ => 1) } (keys %allUsers) }; # Always have a definite sort order. - if (defined $c->param('labelSortMethod')) { - $c->{primarySortField} = $c->param('labelSortMethod'); - $c->{secondarySortField} = $c->param('primarySortField') || 'last_name'; - $c->{ternarySortField} = $c->param('secondarySortField') || 'first_name'; - } else { - $c->{primarySortField} = $c->param('primarySortField') || 'last_name'; - $c->{secondarySortField} = $c->param('secondarySortField') || 'first_name'; - $c->{ternarySortField} = $c->param('ternarySortField') || 'student_id'; - } + $c->{primarySortField} = $c->param('primarySortField') || 'last_name'; + $c->{primarySortOrder} = $c->param('primarySortOrder') || 'ASC'; + $c->{secondarySortField} = $c->param('secondarySortField') || 'first_name'; + $c->{secondarySortOrder} = $c->param('secondarySortOrder') || 'ASC'; + $c->{ternarySortField} = $c->param('ternarySortField') || 'student_id'; + $c->{ternarySortOrder} = $c->param('ternarySortOrder') || 'ASC'; my $actionID = $c->param('action'); if ($actionID) { @@ -225,18 +222,16 @@ sub pre_header_initialize ($c) { if (!FORM_PERMS()->{$actionID} || $authz->hasPermissions($user, FORM_PERMS()->{$actionID})) { # Call the action handler my $actionHandler = "${actionID}_handler"; - $c->addgoodmessage($c->maketext('Result of last action performed: [_1]', $c->tag('i', $c->$actionHandler))); + $c->addgoodmessage($c->$actionHandler); } else { $c->addbadmessage($c->maketext('You are not authorized to perform this action.')); } - } else { - $c->addgoodmessage($c->maketext("Please select action to be performed.")); } # Sort all users - my $primarySortSub = SORT_SUBS()->{ $c->{primarySortField} }; - my $secondarySortSub = SORT_SUBS()->{ $c->{secondarySortField} }; - my $ternarySortSub = SORT_SUBS()->{ $c->{ternarySortField} }; + my $primarySortSub = SORT_SUBS()->{ $c->{primarySortField} }{ $c->{primarySortOrder} }; + my $secondarySortSub = SORT_SUBS()->{ $c->{secondarySortField} }{ $c->{secondarySortOrder} }; + my $ternarySortSub = SORT_SUBS()->{ $c->{ternarySortField} }{ $c->{ternarySortOrder} }; $c->{allUserIDs} = [ keys %allUsers ]; @@ -274,16 +269,16 @@ sub filter_handler ($c) { my $scope = $c->param('action.filter.scope'); if ($scope eq 'all') { - $result = $c->maketext('showing all users'); + $result = $c->maketext('Showing all users.'); $c->{visibleUserIDs} = { map { $_ => 1 } @{ $c->{allUserIDs} } }; } elsif ($scope eq 'none') { - $result = $c->maketext('showing no users'); + $result = $c->maketext('Showing no users.'); $c->{visibleUserIDs} = {}; } elsif ($scope eq 'selected') { - $result = $c->maketext('showing selected users'); + $result = $c->maketext('Showing selected users.'); $c->{visibleUserIDs} = $c->{selectedUserIDs}; } elsif ($scope eq 'match_regex') { - $result = $c->maketext('showing matching users'); + $result = $c->maketext('Showing matching users.'); my $regex = $c->param('action.filter.user_ids'); my $field = $c->param('action.filter.field'); my %allUsers = %{ $c->{allUsers} }; @@ -307,15 +302,46 @@ sub filter_handler ($c) { } sub sort_handler ($c) { - $c->{primarySortField} = $c->param('action.sort.primary'); - $c->{secondarySortField} = $c->param('action.sort.secondary'); - $c->{ternarySortField} = $c->param('action.sort.ternary'); + if (defined $c->param('labelSortMethod') || defined $c->param('labelSortOrder')) { + if (defined $c->param('labelSortOrder')) { + $c->{ $c->param('labelSortOrder') . 'SortOrder' } = + $c->{ $c->param('labelSortOrder') . 'SortOrder' } eq 'ASC' ? 'DESC' : 'ASC'; + } elsif ($c->param('labelSortMethod') eq $c->{primarySortField}) { + $c->{primarySortOrder} = $c->{primarySortOrder} eq 'ASC' ? 'DESC' : 'ASC'; + } else { + $c->{ternarySortField} = $c->{secondarySortField}; + $c->{ternarySortOrder} = $c->{secondarySortOrder}; + $c->{secondarySortField} = $c->{primarySortField}; + $c->{secondarySortOrder} = $c->{primarySortOrder}; + $c->{primarySortField} = $c->param('labelSortMethod'); + $c->{primarySortOrder} = 'ASC'; + } + + $c->param('action.sort.primary', $c->{primarySortField}); + $c->param('action.sort.primary.order', $c->{primarySortOrder}); + $c->param('action.sort.secondary', $c->{secondarySortField}); + $c->param('action.sort.secondary.order', $c->{secondarySortOrder}); + $c->param('action.sort.ternary', $c->{ternarySortField}); + $c->param('action.sort.ternary.order', $c->{ternarySortOrder}); + } else { + $c->{primarySortField} = $c->param('action.sort.primary'); + $c->{primarySortOrder} = $c->param('action.sort.primary.order'); + $c->{secondarySortField} = $c->param('action.sort.secondary'); + $c->{secondarySortOrder} = $c->param('action.sort.secondary.order'); + $c->{ternarySortField} = $c->param('action.sort.ternary'); + $c->{ternarySortOrder} = $c->param('action.sort.ternary.order'); + } return $c->maketext( - 'Users sorted by [_1], then by [_2], then by [_3]', + 'Sets sorted by [_1] in [plural,_2,ascending,descending] order, ' + . 'then by [_3] in [plural,_4,ascending,descending] order,' + . 'and then by [_5] in [plural,_6,ascending,descending] order.', $c->maketext(FIELD_PROPERTIES()->{ $c->{primarySortField} }{name}), + $c->{primarySortOrder} eq 'ASC' ? 1 : 2, $c->maketext(FIELD_PROPERTIES()->{ $c->{secondarySortField} }{name}), - $c->maketext(FIELD_PROPERTIES()->{ $c->{ternarySortField} }{name}) + $c->{secondarySortOrder} eq 'ASC' ? 1 : 2, + $c->maketext(FIELD_PROPERTIES()->{ $c->{ternarySortField} }{name}), + $c->{ternarySortOrder} eq 'ASC' ? 1 : 2 ); } @@ -325,13 +351,13 @@ sub edit_handler ($c) { my $scope = $c->param('action.edit.scope'); if ($scope eq 'all') { - $result = $c->maketext('editing all users'); + $result = $c->maketext('Editing all users.'); @usersToEdit = grep { $c->{userIsEditable}{$_} } @{ $c->{allUserIDs} }; } elsif ($scope eq 'visible') { - $result = $c->maketext('editing visible users'); + $result = $c->maketext('Editing visible users.'); @usersToEdit = grep { $c->{userIsEditable}{$_} } (keys %{ $c->{visibleUserIDs} }); } elsif ($scope eq 'selected') { - $result = $c->maketext('editing selected users'); + $result = $c->maketext('Editing selected users.'); @usersToEdit = grep { $c->{userIsEditable}{$_} } (keys %{ $c->{selectedUserIDs} }); } $c->{visibleUserIDs} = { map { $_ => 1 } @usersToEdit }; @@ -346,13 +372,13 @@ sub password_handler ($c) { my $scope = $c->param('action.password.scope'); if ($scope eq 'all') { - $result = $c->maketext('giving new passwords to all users'); + $result = $c->maketext('Giving new passwords to all users.'); @usersToEdit = grep { $c->{userIsEditable}{$_} } @{ $c->{allUserIDs} }; } elsif ($scope eq 'visible') { - $result = $c->maketext('giving new passwords to visible users'); + $result = $c->maketext('Giving new passwords to visible users.'); @usersToEdit = grep { $c->{userIsEditable}{$_} } (keys %{ $c->{visibleUserIDs} }); } elsif ($scope eq 'selected') { - $result = $c->maketext('giving new passwords to selected users'); + $result = $c->maketext('Giving new passwords to selected users.'); @usersToEdit = grep { $c->{userIsEditable}{$_} } (keys %{ $c->{selectedUserIDs} }); } $c->{visibleUserIDs} = { map { $_ => 1 } @usersToEdit }; @@ -485,7 +511,7 @@ sub cancel_edit_handler ($c) { } $c->{editMode} = 0; - return $c->maketext('Changes abandoned'); + return $c->maketext('Changes abandoned.'); } sub save_edit_handler ($c) { @@ -526,7 +552,7 @@ sub save_edit_handler ($c) { $c->{editMode} = 0; - return $c->maketext('Changes saved'); + return $c->maketext('Changes saved.'); } sub cancel_password_handler ($c) { @@ -537,7 +563,7 @@ sub cancel_password_handler ($c) { } $c->{passwordMode} = 0; - return $c->maketext('Changes abandoned'); + return $c->maketext('Changes abandoned.'); } sub save_password_handler ($c) { @@ -574,16 +600,17 @@ sub save_password_handler ($c) { $c->{passwordMode} = 0; - return $c->maketext('New passwords saved'); + return $c->maketext('New passwords saved.'); } -# Sort methods +# Sort methods (ascending) sub byUserID { return lc $a->user_id cmp lc $b->user_id } sub byFirstName { return (defined $a->first_name && defined $b->first_name) ? lc $a->first_name cmp lc $b->first_name : 0; } + sub byLastName { return (defined $a->last_name && defined $b->last_name) ? lc $a->last_name cmp lc $b->last_name : 0; } sub byEmailAddress { return lc $a->email_address cmp lc $b->email_address } sub byStudentID { return lc $a->student_id cmp lc $b->student_id } @@ -595,6 +622,18 @@ sub byComment { return lc $a->comment cmp lc $b->comment } # Permission level is added to the user record hash so we can sort by it if necessary. sub byPermission { return $a->{permission} <=> $b->{permission}; } +# Sort methods (descending) +sub byDescUserID { local ($b, $a) = ($a, $b); return byUserID() } +sub byDescFirstName { local ($b, $a) = ($a, $b); return byFirstName() } +sub byDescLastName { local ($b, $a) = ($a, $b); return byLastName() } +sub byDescEmailAddress { local ($b, $a) = ($a, $b); return byEmailAddress() } +sub byDescStudentID { local ($b, $a) = ($a, $b); return byStudentID() } +sub byDescStatus { local ($b, $a) = ($a, $b); return byStatus() } +sub byDescSection { local ($b, $a) = ($a, $b); return bySection() } +sub byDescRecitation { local ($b, $a) = ($a, $b); return byRecitation() } +sub byDescComment { local ($b, $a) = ($a, $b); return byC mment() } +sub byDescPermission { local ($b, $a) = ($a, $b); return byPermission() } + # Utilities # generate labels for section/recitation popup menus diff --git a/lib/WeBWorK/ContentGenerator/Problem.pm b/lib/WeBWorK/ContentGenerator/Problem.pm index 3cbf75816b..ce5451a027 100644 --- a/lib/WeBWorK/ContentGenerator/Problem.pm +++ b/lib/WeBWorK/ContentGenerator/Problem.pm @@ -36,7 +36,6 @@ use WeBWorK::Localize; use WeBWorK::Utils::Tasks qw(fake_set fake_problem); use WeBWorK::Utils::LanguageAndDirection qw(get_problem_lang_and_dir); use WeBWorK::AchievementEvaluator; -use WeBWorK::HTML::AttemptsTable; # GET/POST Parameters for this module # @@ -246,42 +245,11 @@ sub can_showMeAnother ($c, $user, $effectiveUser, $set, $problem, $submitAnswers return 0; } -sub attemptResults ($c, $pg, $showCorrectAnswers, $showAttemptResults, $showSummary) { - my $ce = $c->ce; - - # Create AttemptsTable object - my $tbl = WeBWorK::HTML::AttemptsTable->new( - $pg->{answers}, - $c, - answersSubmitted => 1, - answerOrder => $pg->{flags}{ANSWER_ENTRY_ORDER}, - displayMode => $c->{displayMode}, - showAnswerNumbers => 0, - showAttemptAnswers => $ce->{pg}{options}{showEvaluatedAnswers}, - showAttemptPreviews => 1, - showAttemptResults => $showAttemptResults, - showCorrectAnswers => $showCorrectAnswers, - showMessages => 1, - showSummary => $showSummary, - imgGen => WeBWorK::PG::ImageGenerator->new( - tempDir => $ce->{webworkDirs}{tmp}, - latex => $ce->{externalPrograms}{latex}, - dvipng => $ce->{externalPrograms}{dvipng}, - useCache => 1, - cacheDir => $ce->{webworkDirs}{equationCache}, - cacheURL => $ce->{webworkURLs}{equationCache}, - cacheDB => $ce->{webworkFiles}{equationCacheDB}, - useMarkers => 1, - dvipng_align => $ce->{pg}{displayModeOptions}{images}{dvipng_align}, - dvipng_depth_db => $ce->{pg}{displayModeOptions}{images}{dvipng_depth_db}, - ), - ); - - # Render equation images - my $answerTemplate = $tbl->answerTemplate; - $tbl->imgGen->render(body_text => \$answerTemplate) if $tbl->displayMode eq 'images'; - - return $answerTemplate; +sub attemptResults ($c, $pg) { + return $pg->{result}{summary} + ? $c->c($c->tag('h2', class => 'fs-3 mb-2', $c->maketext('Results for this submission')) + . $c->tag('div', role => 'alert', $c->b($pg->{result}{summary})))->join('') + : ''; } async sub pre_header_initialize ($c) { @@ -407,8 +375,8 @@ async sub pre_header_initialize ($c) { } $c->addmessage($c->{set}->visible - ? $c->tag('p', class => 'font-visible', $c->maketext('This set is visible to students.')) - : $c->tag('p', class => 'font-hidden', $c->maketext('This set is hidden from students.'))); + ? $c->tag('p', class => 'font-visible m-0', $c->maketext('This set is visible to students.')) + : $c->tag('p', class => 'font-hidden m-0', $c->maketext('This set is hidden from students.'))); } else { # Test for additional problem validity if it's not already invalid. @@ -451,6 +419,7 @@ async sub pre_header_initialize ($c) { { $c->{submitAnswers} = 0; $c->{resubmitDetected} = 1; + delete $formFields->{submitAnswers}; } $c->{displayMode} = $displayMode; @@ -581,6 +550,24 @@ async sub pre_header_initialize ($c) { # Final values for options my %will = map { $_ => $can{$_} && ($want{$_} || $must{$_}) } keys %must; + if ($prEnabled && $problem->{prCount} >= $rerandomizePeriod && !after($c->{set}->due_date, $c->submitTime)) { + $showMeAnother{active} = 0; + $must{requestNewSeed} = 1; + $can{requestNewSeed} = 1; + $want{requestNewSeed} = 1; + $will{requestNewSeed} = 1; + $c->{showCorrectOnRandomize} = $ce->{pg}{options}{showCorrectOnRandomize}; + # If this happens, it means that the page was refreshed. So prevent the answers from + # being recorded and the number of attempts from being increased. + if ($problem->{prCount} > $rerandomizePeriod) { + $c->{resubmitDetected} = 1; + $must{recordAnswers} = 0; + $can{recordAnswers} = 0; + $want{recordAnswers} = 0; + $will{recordAnswers} = 0; + } + } + # Sticky answers if (!($c->{submitAnswers} || $previewAnswers || $checkAnswers) && $will{showOldAnswers}) { my %oldAnswers = decodeAnswers($problem->last_answer); @@ -615,7 +602,16 @@ async sub pre_header_initialize ($c) { useMathView => $will{useMathView}, forceScaffoldsOpen => 0, isInstructor => $authz->hasPermissions($userID, 'view_answers'), - debuggingOptions => getTranslatorDebuggingOptions($authz, $userID) + showFeedback => $c->{submitAnswers} || $c->{previewAnswers}, + showAttemptAnswers => $ce->{pg}{options}{showEvaluatedAnswers}, + showAttemptPreviews => 1, + showAttemptResults => $c->{submitAnswers}, + forceShowAttemptResults => $will{checkAnswers} || $will{showProblemGrader}, + showMessages => 1, + showCorrectAnswers => $c->{submitAnswers} ? ($c->{showCorrectOnRandomize} // $will{showCorrectAnswers}) + : $will{checkAnswers} || $will{showProblemGrader} ? $will{showCorrectAnswers} + : 0, + debuggingOptions => getTranslatorDebuggingOptions($authz, $userID) } ); @@ -630,24 +626,6 @@ async sub pre_header_initialize ($c) { id => 'num_attempts' ); - if ($prEnabled && $problem->{prCount} >= $rerandomizePeriod && !after($c->{set}->due_date, $c->submitTime)) { - $showMeAnother{active} = 0; - $must{requestNewSeed} = 1; - $can{requestNewSeed} = 1; - $want{requestNewSeed} = 1; - $will{requestNewSeed} = 1; - $c->{showCorrectOnRandomize} = $ce->{pg}{options}{showCorrectOnRandomize}; - # If this happens, it means that the page was refreshed. So prevent the answers from - # being recorded and the number of attempts from being increased. - if ($problem->{prCount} > $rerandomizePeriod) { - $c->{resubmitDetected} = 1; - $must{recordAnswers} = 0; - $can{recordAnswers} = 0; - $want{recordAnswers} = 0; - $will{recordAnswers} = 0; - } - } - # Update and fix hint/solution options after PG processing $can{showHints} &&= $pg->{flags}{hintExists}; $can{showSolutions} &&= $pg->{flags}{solutionExists}; @@ -1484,37 +1462,26 @@ sub output_summary ($c) { my $output = $c->c; # Attempt summary - if (defined $pg->{flags}{showPartialCorrectAnswers} - && $pg->{flags}{showPartialCorrectAnswers} >= 0 - && $c->{submitAnswers}) - { - push( - @$output, - $c->attemptResults( - $pg, - $c->{showCorrectOnRandomize} // $will{showCorrectAnswers}, - $pg->{flags}{showPartialCorrectAnswers}, 1 - ) - ); + if ($c->{submitAnswers}) { + push(@$output, $c->attemptResults($pg)); } elsif ($will{checkAnswers} || $c->{will}{showProblemGrader}) { push( @$output, $c->tag( 'div', - class => 'ResultsWithError d-inline-block mb-3', + class => 'alert alert-danger d-inline-block mb-2 p-1', $c->maketext('ANSWERS ONLY CHECKED -- ANSWERS NOT RECORDED') ), - $c->attemptResults($pg, $will{showCorrectAnswers}, 1, 1) + $c->attemptResults($pg) ); } elsif ($c->{previewAnswers}) { push( @$output, $c->tag( 'div', - class => 'ResultsWithError d-inline-block mb-3', + class => 'alert alert-danger d-inline-block mb-2 p-1', $c->maketext('PREVIEW ONLY -- ANSWERS NOT RECORDED') ), - $c->attemptResults($pg, 0, 0, 0) ); } @@ -1522,7 +1489,7 @@ sub output_summary ($c) { @$output, $c->tag( 'div', - class => 'ResultsWithError d-inline-block mb-3', + class => 'alert alert-danger d-inline-block mb-2 p-1', $c->maketext( 'ATTEMPT NOT ACCEPTED -- Please submit answers again (or request new version if neccessary).') ) diff --git a/lib/WeBWorK/ContentGenerator/ShowMeAnother.pm b/lib/WeBWorK/ContentGenerator/ShowMeAnother.pm index b48e5b028d..d0bd45f011 100644 --- a/lib/WeBWorK/ContentGenerator/ShowMeAnother.pm +++ b/lib/WeBWorK/ContentGenerator/ShowMeAnother.pm @@ -137,7 +137,7 @@ async sub pre_header_initialize ($c) { showHints => 0, showSolutions => 0, refreshMath2img => 0, - processAnswers => 0, + processAnswers => 1, permissionLevel => $db->getPermissionLevel($userName)->permission, effectivePermissionLevel => $db->getPermissionLevel($effectiveUserName)->permission, useMathQuill => $c->{will}{useMathQuill}, @@ -173,7 +173,7 @@ async sub pre_header_initialize ($c) { showHints => 0, showSolutions => 0, refreshMath2img => 0, - processAnswers => 0, + processAnswers => 1, permissionLevel => $db->getPermissionLevel($userName)->permission, effectivePermissionLevel => $db->getPermissionLevel($effectiveUserName)->permission, useMathQuill => $c->{will}{useMathQuill}, @@ -188,7 +188,9 @@ async sub pre_header_initialize ($c) { } # check to see if we've found a new version - if ($new_body_text ne $orig_body_text) { + if ($new_body_text ne $orig_body_text + || have_different_answers($showMeAnotherNewPG, $showMeAnotherOriginalPG)) + { # if we've found a new version, then # increment the counter detailing the number of times showMeAnother has been used # unless we're trying to check answers from the showMeAnother screen @@ -232,7 +234,7 @@ async sub pre_header_initialize ($c) { showHints => 0, showSolutions => 0, refreshMath2img => 0, - processAnswers => 0, + processAnswers => 1, permissionLevel => $db->getPermissionLevel($userName)->permission, effectivePermissionLevel => $db->getPermissionLevel($effectiveUserName)->permission, useMathQuill => $c->{will}{useMathQuill}, @@ -297,12 +299,18 @@ async sub pre_header_initialize ($c) { processAnswers => 1, permissionLevel => $db->getPermissionLevel($userName)->permission, effectivePermissionLevel => $db->getPermissionLevel($effectiveUserName)->permission, - useMathQuill => $c->{will}{useMathQuill}, - useMathView => $c->{will}{useMathView}, + useMathQuill => $will->{useMathQuill}, + useMathView => $will->{useMathView}, forceScaffoldsOpen => 0, isInstructor => $authz->hasPermissions($userName, 'view_answers'), + showFeedback => $c->{checkAnswers} || $c->{previewAnswers}, + showAttemptAnswers => $ce->{pg}{options}{showEvaluatedAnswers}, + showAttemptPreviews => 1, + showAttemptResults => $c->{checkAnswers}, + showMessages => 1, + showCorrectAnswers => $will->{checkAnswers} ? $will->{showCorrectAnswers} : 0, debuggingOptions => getTranslatorDebuggingOptions($authz, $userName) - }, + } ); # Warnings in the renderPG subprocess will not be caught by the global warning handler of this process. @@ -510,7 +518,7 @@ sub output_summary ($c) { ), $c->tag( 'div', - class => 'ResultsAlert', + class => 'alert alert-warning mb-2 p-1', $c->maketext(q{Remember to return to your original problem when you're finished here!}) ) ); @@ -524,7 +532,7 @@ sub output_summary ($c) { @$output, $c->tag( 'div', - class => 'ResultsAlert', + class => 'alert alert-warning mb-2 p-1', $c->maketext( 'You are only allowed to click on Show Me Another [quant,_1,time,times] per problem. ' . '[_2] Close this tab, and return to the original problem.', @@ -538,7 +546,7 @@ sub output_summary ($c) { @$output, $c->tag( 'div', - class => 'ResultsAlert', + class => 'alert alert-warning mb-2 p-1', $c->maketext( 'You must attempt this problem [quant,_1,time,times] before Show Me Another is available.', $showMeAnother{TriesNeeded} @@ -553,7 +561,7 @@ sub output_summary ($c) { @$output, $c->tag( 'div', - class => 'ResultsAlert', + class => 'alert alert-warning mb-2 p-1', $c->maketext( 'WeBWorK was unable to generate a different version of this problem. ' . 'Close this tab, and return to the original problem.' @@ -594,4 +602,12 @@ sub output_hidden_info ($c) { return ''; } +# Checks the PG object for two different seeds of the same pg file +sub have_different_answers ($pg1, $pg2) { + for (keys %{ $pg1->{answers} }) { + return 1 if ($pg1->{answers}{$_}{correct_ans} ne $pg2->{answers}{$_}{correct_ans}); + } + return 0; +} + 1; diff --git a/lib/WeBWorK/File/SetDef.pm b/lib/WeBWorK/File/SetDef.pm new file mode 100644 index 0000000000..b461167ae0 --- /dev/null +++ b/lib/WeBWorK/File/SetDef.pm @@ -0,0 +1,789 @@ +################################################################################ +# WeBWorK Online Homework Delivery System +# Copyright © 2000-2022 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::File::SetDef; +use Mojo::Base 'Exporter', -signatures; + +=head1 NAME + +WeBWorK::File::SetDef - utilities for dealing with set definition files. + +=cut + +use Carp; + +use WeBWorK::Debug; +use WeBWorK::Utils qw(timeToSec x parseDateTime formatDateTime format_set_name_display seq_to_jitar_id jitar_id_to_seq); +use WeBWorK::Utils::Instructor qw(assignSetToUser assignSetToAllUsers addProblemToSet); + +our @EXPORT_OK = qw(importSetsFromDef readSetDef exportSetsToDef); + +=head2 importSetsFromDef + +Usage: C + +Import requested set definition files into the course. + +$ce must be a course environment object and $db a database object for the +course. + +$setDefFiles must be a reference to an array of set definition file names with +path relative to the course templates directory. + +$existingSets must be a reference to an array containing set ids of existing +sets in the course if provided. If it is not provided, then the list of +existing sets will be obtained from the database. + +$assign is either 'all', a user id for a particular user to assign the imported +sets to, or something that evaluates to false. If it evaluates to false the +imported sets will not be assigned to any users. + +$startDate is a date to shift the set dates relative to. + +$newSetName is an optional name for the imported set. This can only be passed +when one set is begin imported. + +This returns a reference to an array of set ids of added sets, a reference to an +array of set ids of skipped sets, and a reference to an array of errors that +occurred in the process. Note that each entry in the array of errors is a +reference to an array whose contents are suitable to be passed directly to +maketext. + +=cut + +sub importSetsFromDef ($ce, $db, $setDefFiles, $existingSets = undef, $assign = '', $startDate = 0, $newSetName = '') { + my $minDate = 0; + + # Restrict to filenames that contain at least one non-whitespace character. + my @setDefFiles = grep {/\S/} @$setDefFiles; + + croak '$newSetName should not be passed when importing multiple set definitions files.' + if $newSetName && @setDefFiles > 1; + + # Get the list of existing sets for the course if that was not provided. + $existingSets = [ $db->listGlobalSets ] unless (ref($existingSets) eq 'ARRAY'); + + # Get a list of set ids of existing sets in the course. This is used to + # ensure that an imported set does not already exist. + my %allSets = map { $_ => 1 } @$existingSets; + + my (@added, @skipped, @errors); + + for my $set_definition_file (@setDefFiles) { + debug("$set_definition_file: reading set definition file"); + + # Read the data from the set definition file. + my ($setData, $readErrors) = readSetDef($ce, $set_definition_file); + push(@errors, @$readErrors) if @$readErrors; + + # Use the original name if a new name was not specified. + $setData->{setID} = $newSetName if $newSetName; + + my $prettySetID = format_set_name_display($setData->{setID}); + + if ($allSets{ $setData->{setID} }) { + # This set already exists! + push @skipped, $setData->{setID}; + push @errors, [ x('The set [_1] already exists.', $prettySetID) ]; + next; + } + + # Keep track of which as the earliest open date. + if ($minDate > $setData->{openDate} || $minDate == 0) { + $minDate = $setData->{openDate}; + } + + debug("$set_definition_file: adding set"); + # Add the data to the set record + my $newSetRecord = $db->newGlobalSet; + $newSetRecord->set_id($setData->{setID}); + $newSetRecord->set_header($setData->{screenHeaderFile}); + $newSetRecord->hardcopy_header($setData->{paperHeaderFile}); + $newSetRecord->open_date($setData->{openDate}); + $newSetRecord->due_date($setData->{dueDate}); + $newSetRecord->answer_date($setData->{answerDate}); + $newSetRecord->visible(1); + $newSetRecord->reduced_scoring_date($setData->{reducedScoringDate}); + $newSetRecord->enable_reduced_scoring($setData->{enableReducedScoring}); + $newSetRecord->description($setData->{description}); + $newSetRecord->email_instructor($setData->{emailInstructor}); + $newSetRecord->restrict_prob_progression($setData->{restrictProbProgression}); + + # Gateway/version data. These are all initialized to '' by readSetDef. + # So for non-gateway/versioned sets they'll just be stored as NULL. + $newSetRecord->assignment_type($setData->{assignmentType}); + $newSetRecord->attempts_per_version($setData->{attemptsPerVersion}); + $newSetRecord->time_interval($setData->{timeInterval}); + $newSetRecord->versions_per_interval($setData->{versionsPerInterval}); + $newSetRecord->version_time_limit($setData->{versionTimeLimit}); + $newSetRecord->problem_randorder($setData->{problemRandOrder}); + $newSetRecord->problems_per_page($setData->{problemsPerPage}); + $newSetRecord->hide_score($setData->{hideScore}); + $newSetRecord->hide_score_by_problem($setData->{hideScoreByProblem}); + $newSetRecord->hide_work($setData->{hideWork}); + $newSetRecord->time_limit_cap($setData->{capTimeLimit}); + $newSetRecord->restrict_ip($setData->{restrictIP}); + $newSetRecord->relax_restrict_ip($setData->{relaxRestrictIP}); + + # Create the set + eval { $db->addGlobalSet($newSetRecord) }; + if ($@) { + push @skipped, $setData->{setID}; + push @errors, [ x('Error creating set [_1]: [_2]'), $prettySetID, $@ ]; + next; + } + + push @added, $setData->{setID}; + + # Add locations to the set_locations table + if ($setData->{restrictIP} ne 'No' && $setData->{restrictLocation}) { + if ($db->existsLocation($setData->{restrictLocation})) { + if (!$db->existsGlobalSetLocation($setData->{setID}, $setData->{restrictLocation})) { + my $newSetLocation = $db->newGlobalSetLocation; + $newSetLocation->set_id($setData->{setID}); + $newSetLocation->location_id($setData->{restrictLocation}); + eval { $db->addGlobalSetLocation($newSetLocation) }; + if ($@) { + push + @errors, + [ + x('Error adding IP restriction location "[_1]" for set [_2]: [_3]'), + $setData->{restrictLocation}, + $prettySetID, $@ + ]; + } + } else { + # This should never happen. + push + @errors, + [ + x('IP restriction location "[_1]" for set [_2] already exists.'), + $setData->{restrictLocation}, $prettySetID + ]; + } + } else { + push + @errors, + [ + x( + 'IP restriction location "[_1]" for set [_2] does not exist. ' + . 'IP restrictions have been ignored.' + ), + $setData->{restrictLocation}, + $prettySetID + ]; + $newSetRecord->restrict_ip('No'); + $newSetRecord->relax_restrict_ip('No'); + eval { $db->putGlobalSet($newSetRecord) }; + # Ignore error messages here. If the set was added without error before, + # we assume (ha) that it will be added again without trouble. + } + } + + debug("$set_definition_file: adding problems to database"); + # Add problems + my $freeProblemID = WeBWorK::Utils::max($db->listGlobalProblems($setData->{setID})) + 1; + for my $rh_problem (@{ $setData->{problemData} }) { + addProblemToSet( + $db, $ce->{problemDefaults}, + setName => $setData->{setID}, + sourceFile => $rh_problem->{source_file}, + problemID => $rh_problem->{problemID} ? $rh_problem->{problemID} : $freeProblemID++, + value => $rh_problem->{value}, + maxAttempts => $rh_problem->{max_attempts}, + showMeAnother => $rh_problem->{showMeAnother}, + showHintsAfter => $rh_problem->{showHintsAfter}, + prPeriod => $rh_problem->{prPeriod}, + attToOpenChildren => $rh_problem->{attToOpenChildren}, + countsParentGrade => $rh_problem->{countsParentGrade} + ); + } + + if ($assign eq 'all') { + assignSetToAllUsers($db, $ce, $setData->{setID}); + } elsif ($assign) { + assignSetToUser($db, $assign, $newSetRecord); + } + } + + # If there is a start date we have to reopen all of the sets that were added and shift the dates. + if ($startDate) { + # The shift for all of the dates is from the min date to the start date + my $dateShift = $startDate - $minDate; + + for my $setID (@added) { + my $setRecord = $db->getGlobalSet($setID); + $setRecord->open_date($setRecord->open_date + $dateShift); + $setRecord->reduced_scoring_date($setRecord->reduced_scoring_date + $dateShift); + $setRecord->due_date($setRecord->due_date + $dateShift); + $setRecord->answer_date($setRecord->answer_date + $dateShift); + $db->putGlobalSet($setRecord); + } + } + + return \@added, \@skipped, \@errors; +} + +=head2 readSetDef + +Usage: C + +Read and parse a set definition file. + +$ce must be a course environment object for the course. + +$filename should be the set definition file with path relative to the course +templates directory. + +Returns a reference to a hash containing the information from the set definition +file and a reference to an array of errors in the file. See C<%data> and +C<%data{problemData}> for details on the contents of the return set definition +file data. Also note that each entry in the array of errors is a reference to +an array whose contents are suitable to be passed directly to maketext. + +=cut + +sub readSetDef ($ce, $fileName) { + my $filePath = "$ce->{courseDirs}{templates}/$fileName"; + + my %data = ( + setID => 'Invalid Set Definition Filename', + problemData => [], + paperHeaderFile => '', + screenHeaderFile => '', + openDate => '', + dueDate => '', + answerDate => '', + reducedScoringDate => '', + assignmentType => 'default', + enableReducedScoring => '', + attemptsPerVersion => '', + timeInterval => '', + versionsPerInterval => '', + versionTimeLimit => '', + problemRandOrder => '', + problemsPerPage => '', + hideScore => 'N', + hideScoreByProblem => 'N', + hideWork => 'N', + capTimeLimit => 0, + restrictIP => 'No', + restrictLocation => '', + relaxRestrictIP => 'No', + description => '', + emailInstructor => '', + restrictProbProgression => '' + ); + + my @errors; + + $data{setID} = $2 if ($fileName =~ m|^(.*/)?set([.\w-]+)\.def$|); + + if (my $setFH = Mojo::File->new($filePath)->open('<')) { + my $listType = ''; + + # Read and check set data + while (my $line = <$setFH>) { + chomp $line; + $line =~ s|(#.*)||; # Don't read past comments + unless ($line =~ /\S/) { next; } # Skip blank lines + $line =~ s/^\s*|\s*$//; # Trim spaces + $line =~ m|^(\w+)\s*=?\s*(.*)|; + + my $item = $1 // ''; + my $value = $2; + + if ($item eq 'setNumber') { + next; + } elsif (defined $data{$item}) { + $data{$item} = $value if defined $value; + } elsif ($item eq 'problemList' || $item eq 'problemListV2') { + $listType = $item; + last; + } else { + push(@errors, [ x('Invalid line in file "[_1]": ||[_2]||'), $fileName, $line ]); + } + } + + # Change 's to new lines in the set description. + $data{description} =~ s//\n/g; + + # Check and format dates + ($data{openDate}, $data{dueDate}, $data{answerDate}) = + map { parseDateTime($_, $ce->{siteDefaults}{timezone}) } + ($data{openDate}, $data{dueDate}, $data{answerDate}); + + unless (defined $data{openDate} + && defined $data{dueDate} + && defined $data{answerDate} + && $data{openDate} <= $data{dueDate} + && $data{dueDate} <= $data{answerDate}) + { + $data{dueDate} = time + 2 * 60 * 60 * 24 * 7 unless defined $data{dueDate}; + $data{openDate} = $data{dueDate} - 60 * $ce->{pg}{assignOpenPriorToDue} + if !defined $data{openDate} || $data{openDate} > $data{dueDate}; + $data{answerDate} = $data{dueDate} + 60 * $ce->{pg}{answersOpenAfterDueDate} + if !defined $data{answerDate} || $data{dueDate} > $data{answerDate}; + + push( + @errors, + [ + x( + 'The open date, due date, and answer date in "[_1]" are not in chronological order.' + . 'Default values will be used for dates that are out of order.' + ), + $fileName + ] + ); + } + + if ($data{enableReducedScoring} eq 'Y') { + $data{enableReducedScoring} = 1; + } elsif ($data{enableReducedScoring} eq 'N') { + $data{enableReducedScoring} = 0; + } elsif ($data{enableReducedScoring} ne '') { + push( + @errors, + [ + x('The value for enableReducedScoring in "[_1]" is not valid. It will be replaced with "N".'), + $fileName + ] + ); + $data{enableReducedScoring} = 0; + } else { + $data{enableReducedScoring} = 0; + } + + # Validate reduced scoring date + if ($data{reducedScoringDate}) { + if ($data{reducedScoringDate} =~ m+12/31/1969+ || $data{reducedScoringDate} =~ m+01/01/1970+) { + # Set the reduced scoring date to 0 for values which seem to roughly correspond to epoch 0. + $data{reducedScoringDate} = 0; + } else { + $data{reducedScoringDate} = parseDateTime($data{reducedScoringDate}, $ce->{siteDefaults}{timezone}); + } + } + + if ($data{reducedScoringDate}) { + if ($data{reducedScoringDate} < $data{openDate} || $data{reducedScoringDate} > $data{dueDate}) { + $data{reducedScoringDate} = $data{dueDate} - 60 * $ce->{pg}{ansEvalDefaults}{reducedScoringPeriod}; + + # If reduced scoring is enabled for the set, then add an error regarding this issue. + # Otherwise let it go. + if ($data{enableReducedScoring}) { + push( + @errors, + [ + x( + 'The reduced credit date in "[_1]" is not between the open date and close date. ' + . 'The default value will be used.' + ), + $fileName + ] + ); + } + } + } else { + $data{reducedScoringDate} = $data{dueDate} - 60 * $ce->{pg}{ansEvalDefaults}{reducedScoringPeriod}; + } + + # Convert Gateway times into seconds. + $data{timeInterval} = timeToSec($data{timeInterval}) if ($data{timeInterval}); + $data{versionTimeLimit} = timeToSec($data{versionTimeLimit}) if ($data{versionTimeLimit}); + + # Check that the values for hideScore and hideWork are valid. + for (qw(hideScore hideWork)) { + if ($data{$_} ne 'N' && $data{$_} ne 'Y' && $data{$_} ne 'BeforeAnswerDate') { + push( + @errors, + [ + x('The value for the [_1] option in "[_2]" is not valid. It will be replaced with "N".'), + $_, $fileName + ] + ); + $data{$_} = 'N'; + } + } + + if ($data{hideScoreByProblem} ne 'N' && $data{hideScoreByProblem} ne 'Y') { + push( + @errors, + [ + x( + 'The value for the hideScoreByProblem option in "[_1]" is not valid. ' + . 'It will be replaced with "N".', + $fileName + ) + ] + ); + $data{hideScoreByProblem} = 'N'; + } + + if ($data{capTimeLimit} ne '0' && $data{capTimeLimit} ne '1') { + push( + @errors, + [ + x( + 'The value for the capTimeLimit option in "[_1]" is not valid. It will be replaced with "0".'), + $fileName + ] + ); + $data{capTimeLimit} = '0'; + } + + if ($data{restrictIP} ne 'No' && $data{restrictIP} ne 'DenyFrom' && $data{restrictIP} ne 'RestrictTo') { + push( + @errors, + [ + x('The value for the restrictIP option in "[_1]" is not valid. It will be replaced with "No".'), + $fileName + ] + ); + $data{restrictIP} = 'No'; + $data{restrictLocation} = ''; + $data{relaxRestrictIP} = 'No'; + } + + if ($data{relaxRestrictIP} ne 'No' + && $data{relaxRestrictIP} ne 'AfterAnswerDate' + && $data{relaxRestrictIP} ne 'AfterVersionAnswerDate') + { + push( + @errors, + [ + x( + 'The value for the relaxRestrictIP option in "[_1]" is not valid. ' + . 'It will be replaced with "No".' + ), + $fileName + ] + ); + $data{relaxRestrictIP} = 'No'; + } + + # Validation of restrictLocation requires a database call. That is deferred until the set is added. + + # Read and check list of problems for the set + + # NOTE: There are two versions of problemList, the first is an unlabeled list which may or may not contain some + # newer variables. This is supported but the unlabeled list is hard to work with. The new version prints a + # labeled list of values similar to how its done for the set variables. + + if ($listType eq 'problemList') { + # The original set definition file type. + while (my $line = <$setFH>) { + chomp $line; + $line =~ s/(#.*)//; # Don't read past comments + unless ($line =~ /\S/) { next; } # Skip blank lines + + # Commas are valid in filenames, so we have to handle commas + # using backslash escaping. So \X will be replaced with X. + my @line = (); + my $curr = ''; + for (my $i = 0; $i < length $line; ++$i) { + my $c = substr($line, $i, 1); + if ($c eq '\\') { + $curr .= substr($line, ++$i, 1); + } elsif ($c eq ',') { + push @line, $curr; + $curr = ''; + } else { + $curr .= $c; + } + } + # Anything left? + push(@line, $curr) if ($curr); + + # Exract the problem data from the line. + my ($name, $weight, $attemptLimit, $showMeAnother) = @line; + + # Clean up problem values + $name =~ s/\s*//g; + + $weight //= ''; + $weight =~ s/[^\d\.]*//g; + unless ($weight =~ /\d+/) { $weight = $ce->{problemDefaults}{value}; } + + $attemptLimit //= ''; + $attemptLimit =~ s/[^\d-]*//g; + unless ($attemptLimit =~ /\d+/) { $attemptLimit = $ce->{problemDefaults}{max_attempts}; } + + push( + @{ $data{problemData} }, + { + source_file => $name, + value => $weight, + max_attempts => $attemptLimit, + showMeAnother => $showMeAnother // $ce->{problemDefaults}{showMeAnother}, + # Use defaults for these since they are not going to be in the file. + prPeriod => $ce->{problemDefaults}{prPeriod}, + showHintsAfter => $ce->{problemDefaults}{showHintsAfter}, + } + ); + } + } else { + # Set definition version 2. + my $problemData = {}; + while (my $line = <$setFH>) { + chomp $line; + $line =~ s|#.*||; # Don't read past comments + unless ($line =~ /\S/) { next; } # Skip blank lines + $line =~ s/^\s*|\s*$//g; # Trim spaces + $line =~ m|^(\w+)\s*=?\s*(.*)|; + + my $item = $1 // ''; + my $value = $2; + + if ($item eq 'problem_start') { + # Initialize the problem data with the defaults. + $problemData = { source_file => '', problem_id => '', %{ $ce->{problemDefaults} } }; + } elsif (defined $problemData->{$item}) { + $problemData->{$item} = $value if defined $value; + } elsif ($item eq 'problem_end') { + # Clean up and validate values + $problemData->{source_file} =~ s/\s*//g; + push(@errors, [ 'No source_file for problem in "[_1]"', $fileName ]) + unless $problemData->{source_file}; + + $problemData->{value} =~ s/[^\d\.]*//g; + $problemData->{value} = $ce->{problemDefaults}{value} + unless $problemData->{value} =~ /\d+/; + + $problemData->{max_attempts} =~ s/[^\d-]*//g; + $problemData->{max_attempts} = $ce->{problemDefaults}{max_attempts} + unless $problemData->{max_attempts} =~ /\d+/; + + $problemData->{counts_parent_grade} = $ce->{problemDefaults}{counts_parent_grade} + unless $problemData->{counts_parent_grade} =~ /(0|1)/; + $problemData->{counts_parent_grade} =~ s/[^\d]*//g; + + $problemData->{showMeAnother} = $ce->{problemDefaults}{showMeAnother} + unless $problemData->{showMeAnother} =~ /-?\d+/; + $problemData->{showMeAnother} =~ s/[^\d-]*//g; + + $problemData->{showHintsAfter} = $ce->{problemDefaults}{showHintsAfter} + unless $problemData->{showHintsAfter} =~ /-?\d+/; + $problemData->{showHintsAfter} =~ s/[^\d-]*//g; + + $problemData->{prPeriod} = $ce->{problemDefaults}{prPeriod} + unless $problemData->{prPeriod} =~ /-?\d+/; + $problemData->{prPeriod} =~ s/[^\d-]*//g; + + $problemData->{att_to_open_children} = $ce->{problemDefaults}{att_to_open_children} + unless ($problemData->{att_to_open_children} =~ /\d+/); + $problemData->{att_to_open_children} =~ s/[^\d-]*//g; + + if ($data{assignmentType} eq 'jitar') { + unless ($problemData->{problem_id} =~ /[\d\.]+/) { $problemData->{problem_id} = ''; } + $problemData->{problem_id} =~ s/[^\d\.-]*//g; + $problemData->{problem_id} = seq_to_jitar_id(split(/\./, $problemData->{problem_id})); + } else { + unless ($problemData->{problem_id} =~ /\d+/) { $problemData->{problem_id} = ''; } + $problemData->{problem_id} =~ s/[^\d-]*//g; + } + + push(@{ $data{problemData} }, $problemData); + } else { + push(@errors, [ x('Invalid line in file "[_1]": ||[_2]||'), $fileName, $line ]); + } + } + } + + $setFH->close; + } else { + push @errors, [ x(q{Can't open file [_1]}, $filePath) ]; + } + + return (\%data, \@errors); +} + +=head2 exportSetsToDef + +Usage: C + +Export sets to set definition files. + +$ce must be a course environment object and $db a database object for the +course. + +@filenames is a list of set ids for the sets to be exported. + +=cut + +sub exportSetsToDef ($ce, $db, @sets) { + my (@exported, @skipped, %reason); + +SET: for my $set (@sets) { + my $fileName = "set$set.def"; + + # Files can be exported to sub directories but not parent directories. + if ($fileName =~ /\.\./) { + push @skipped, $set; + $reason{$set} = [ x(q{Illegal filename contains '..'}) ]; + next SET; + } + + my $setRecord = $db->getGlobalSet($set); + unless (defined $setRecord) { + push @skipped, $set; + $reason{$set} = [ x('No record found.') ]; + next SET; + } + my $filePath = "$ce->{courseDirs}{templates}/$fileName"; + + # Back up existing file + if (-e $filePath) { + rename($filePath, "$filePath.bak") + or do { + push @skipped, $set; + $reason{$set} = [ x('Existing file [_1] could not be backed up.'), $filePath ]; + next SET; + }; + } + + my $openDate = + formatDateTime($setRecord->open_date, $ce->{siteDefaults}{timezone}, undef, $ce->{siteDefaults}{locale}); + my $dueDate = + formatDateTime($setRecord->due_date, $ce->{siteDefaults}{timezone}, undef, $ce->{siteDefaults}{locale}); + my $answerDate = + formatDateTime($setRecord->answer_date, $ce->{siteDefaults}{timezone}, undef, $ce->{siteDefaults}{locale}); + my $reducedScoringDate = formatDateTime( + $setRecord->reduced_scoring_date, + $ce->{siteDefaults}{timezone}, + undef, $ce->{siteDefaults}{locale} + ); + + my $description = ($setRecord->description // '') =~ s/\r?\n//gr; + + my $assignmentType = $setRecord->assignment_type; + my $enableReducedScoring = $setRecord->enable_reduced_scoring ? 'Y' : 'N'; + my $setHeader = $setRecord->set_header; + my $paperHeader = $setRecord->hardcopy_header; + my $emailInstructor = $setRecord->email_instructor; + my $restrictProbProgression = $setRecord->restrict_prob_progression; + + my @problemList = $db->getGlobalProblemsWhere({ set_id => $set }, 'problem_id'); + + my $problemList = ''; + for my $problemRecord (@problemList) { + my $problem_id = $problemRecord->problem_id(); + + $problem_id = join('.', jitar_id_to_seq($problem_id)) if ($setRecord->assignment_type eq 'jitar'); + + my $source_file = $problemRecord->source_file(); + my $value = $problemRecord->value(); + my $max_attempts = $problemRecord->max_attempts(); + my $showMeAnother = $problemRecord->showMeAnother(); + my $showHintsAfter = $problemRecord->showHintsAfter(); + my $prPeriod = $problemRecord->prPeriod(); + my $countsParentGrade = $problemRecord->counts_parent_grade(); + my $attToOpenChildren = $problemRecord->att_to_open_children(); + + # backslash-escape commas in fields + $source_file =~ s/([,\\])/\\$1/g; + $value =~ s/([,\\])/\\$1/g; + $max_attempts =~ s/([,\\])/\\$1/g; + $showMeAnother =~ s/([,\\])/\\$1/g; + $showHintsAfter =~ s/([,\\])/\\$1/g; + $prPeriod =~ s/([,\\])/\\$1/g; + + # This is the new way of saving problem information. + # The labelled list makes it easier to add variables and + # easier to tell when they are missing. + $problemList .= "problem_start\n"; + $problemList .= "problem_id = $problem_id\n"; + $problemList .= "source_file = $source_file\n"; + $problemList .= "value = $value\n"; + $problemList .= "max_attempts = $max_attempts\n"; + $problemList .= "showMeAnother = $showMeAnother\n"; + $problemList .= "showHintsAfter = $showHintsAfter\n"; + $problemList .= "prPeriod = $prPeriod\n"; + $problemList .= "counts_parent_grade = $countsParentGrade\n"; + $problemList .= "att_to_open_children = $attToOpenChildren \n"; + $problemList .= "problem_end\n"; + } + + # Gateway fields + my $gwFields = ''; + if ($assignmentType =~ /gateway/) { + my $attemptsPerV = $setRecord->attempts_per_version; + my $timeInterval = $setRecord->time_interval; + my $vPerInterval = $setRecord->versions_per_interval; + my $vTimeLimit = $setRecord->version_time_limit; + my $probRandom = $setRecord->problem_randorder; + my $probPerPage = $setRecord->problems_per_page; + my $hideScore = $setRecord->hide_score; + my $hideScoreByProblem = $setRecord->hide_score_by_problem; + my $hideWork = $setRecord->hide_work; + my $timeCap = $setRecord->time_limit_cap; + $gwFields = + "attemptsPerVersion = $attemptsPerV\n" + . "timeInterval = $timeInterval\n" + . "versionsPerInterval = $vPerInterval\n" + . "versionTimeLimit = $vTimeLimit\n" + . "problemRandOrder = $probRandom\n" + . "problemsPerPage = $probPerPage\n" + . "hideScore = $hideScore\n" + . "hideScoreByProblem = $hideScoreByProblem\n" + . "hideWork = $hideWork\n" + . "capTimeLimit = $timeCap\n"; + } + + # IP restriction fields + my $restrictIP = $setRecord->restrict_ip; + my $restrictFields = ''; + if ($restrictIP && $restrictIP ne 'No') { + # Only store the first location + my $restrictLoc = ($db->listGlobalSetLocations($setRecord->set_id))[0]; + my $relaxRestrict = $setRecord->relax_restrict_ip; + $restrictLoc || ($restrictLoc = ''); + $restrictFields = + "restrictIP = $restrictIP\n" + . "restrictLocation = $restrictLoc\n" + . "relaxRestrictIP = $relaxRestrict\n"; + } + + my $fileContents = + "assignmentType = $assignmentType\n" + . "openDate = $openDate\n" + . "reducedScoringDate = $reducedScoringDate\n" + . "dueDate = $dueDate\n" + . "answerDate = $answerDate\n" + . "enableReducedScoring = $enableReducedScoring\n" + . "paperHeaderFile = $paperHeader\n" + . "screenHeaderFile = $setHeader\n" + . $gwFields + . "description = $description\n" + . "restrictProbProgression = $restrictProbProgression\n" + . "emailInstructor = $emailInstructor\n" + . $restrictFields + . "\nproblemListV2\n" + . $problemList; + + $filePath = WeBWorK::Utils::surePathToFile($ce->{courseDirs}->{templates}, $filePath); + if (open(my $setDefFH, '>', $filePath)) { + print $setDefFH $fileContents; + close $setDefFH; + push @exported, $set; + } else { + push @skipped, $set; + $reason{$set} = [ x('Failed to open [_1]'), $filePath ]; + } + } + + return \@exported, \@skipped, \%reason; +} + +1; diff --git a/lib/WeBWorK/HTML/AttemptsTable.pm b/lib/WeBWorK/HTML/AttemptsTable.pm deleted file mode 100644 index 15103e0638..0000000000 --- a/lib/WeBWorK/HTML/AttemptsTable.pm +++ /dev/null @@ -1,466 +0,0 @@ -################################################################################ -# 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. -################################################################################ - -=head1 NAME - - AttemptsTable - -=head1 SYNPOSIS - - my $tbl = WeBWorK::HTML::AttemptsTable->new( - $answers, - answersSubmitted => 1, - answerOrder => $pg->{flags}{ANSWER_ENTRY_ORDER}, - displayMode => 'MathJax', - showAnswerNumbers => 0, - showAttemptAnswers => $showAttemptAnswers && $showEvaluatedAnswers, - showAttemptPreviews => $showAttemptPreview, - showAttemptResults => $showAttemptResults, - showCorrectAnswers => $showCorrectAnswers, - showMessages => $showAttemptAnswers, # internally checks for messages - showSummary => $showSummary, - imgGen => $imgGen, # not needed if ce is present , - ce => '', # not needed if $imgGen is present - maketext => WeBWorK::Localize::getLoc("en"), - ); - $tbl->{imgGen}->render(refresh => 1) if $tbl->displayMode eq 'images'; - my $answerTemplate = $tbl->answerTemplate; - - -=head1 DESCRIPTION - -This module handles the formatting of the table which presents the results of analyzing a student's -answer to a WeBWorK problem. It is used in Problem.pm, OpaqueServer.pm, standAlonePGproblemRender - -=head2 new - - my $tbl = WeBWorK::HTML::AttemptsTable->new( - $answers, - answersSubmitted => 1, - answerOrder => $pg->{flags}{ANSWER_ENTRY_ORDER}, - displayMode => 'MathJax', - showHeadline => 1, - showAnswerNumbers => 0, - showAttemptAnswers => $showAttemptAnswers && $showEvaluatedAnswers, - showAttemptPreviews => $showAttemptPreview, - showAttemptResults => $showAttemptResults, - showCorrectAnswers => $showCorrectAnswers, - showMessages => $showAttemptAnswers, # internally checks for messages - showSummary => $showSummary, - imgGen => $imgGen, # not needed if ce is present , - ce => '', # not needed if $imgGen is present - maketext => WeBWorK::Localize::getLoc("en"), - summary =>'', - ); - - $answers -- a hash of student answers e.g. $pg->{answers} - answersSubmitted if 0 then then the attemptsTable is not displayed (???) - answerOrder -- an array indicating the order the answers appear on the page. - displayMode 'MathJax' and 'images' are the most common - - showHeadline Show the header line 'Results for this submission' - - showAnswerNumbers, showAttemptAnswers, showAttemptPreviews,showAttemptResults, - showCorrectAnswers and showMessages control the display of each column in the table. - - attemptAnswers the student's typed in answer (possibly simplified numerically) - attemptPreview the student's answer after typesetting - attemptResults "correct", "_% correct", "incorrect" or "ungraded"- links to the answer blank - correctAnswers typeset version (untypeset versions are available via popups) - messages warns of formatting typos in the answer, or - more detailed messages about a wrong answer - summary is obtained from $pg->{result}{summary}. - If this is empty then a (localized) - version of "all answers are correct" - or "at least one answer is not coorrect" - imgGen points to a prebuilt image generator objectfor "images" mode - ce points to the CourseEnvironment -- it is needed if AttemptsTable - is required to build its own imgGen object - maketext points to a localization subroutine - -=head2 Methods - -=over 4 - -=item answerTemplate - -Returns HTML which formats the analysis of the student's answers to the problem. - -=back - -=head2 Read/Write Properties - -=over 4 - -=item showMessages, - -This can be switched on or off before exporting the answerTemplate, perhaps -under instructions from the PG problem. - -=item summary - -The contents of the summary can be defined when the attemptsTable object is created. - -The summary can be defined by the PG problem grader usually returned as -$pg->{result}{summary}. - -If the summary is not explicitly defined then (localized) versions -of the default summaries are created: - - "The answer above is correct.", - "Some answers will be graded later.", - "All of the [gradeable] answers above are correct.", - "[N] of the questions remain unanswered.", - "At least one of the answers above is NOT [fully] correct.', - -Note that if this is set after initialization, you must ensure that it is a -Mojo::ByteStream object if it contains html or characters that need escaping. - -=back - -=cut - -package WeBWorK::HTML::AttemptsTable; -use Mojo::Base 'Class::Accessor', -signatures; - -use Scalar::Util 'blessed'; -use WeBWorK::Utils 'wwRound'; - -# %options may contain: displayMode, submitted, imgGen, ce -# At least one of imgGen or ce must be provided if displayMode is 'images'. -sub new ($class, $rh_answers, $c, %options) { - $class = ref $class || $class; - ref($rh_answers) =~ /HASH/ or die 'The first entry to AttemptsTable must be a hash of answers'; - $c->isa('WeBWorK::Controller') or die 'The second entry to AttemptsTable must be a WeBWorK::Controller'; - my $self = bless { - answers => $rh_answers, - c => $c, - answerOrder => $options{answerOrder} // [], - answersSubmitted => $options{answersSubmitted} // 0, - summary => undef, # summary provided by problem grader (set in _init) - displayMode => $options{displayMode} || 'MathJax', - showHeadline => $options{showHeadline} // 1, - showAnswerNumbers => $options{showAnswerNumbers} // 1, - showAttemptAnswers => $options{showAttemptAnswers} // 1, # show student answer as entered and parsed - showAttemptPreviews => $options{showAttemptPreviews} // 1, # show preview of student answer - showAttemptResults => $options{showAttemptResults} // 1, # show results of grading student answer - showMessages => $options{showMessages} // 1, # show messages generated by evaluation - showCorrectAnswers => $options{showCorrectAnswers} // 0, # show the correct answers - showSummary => $options{showSummary} // 1, # show result summary - imgGen => undef, # set or created in _init method - }, $class; - - # Create accessors/mutators - $self->mk_ro_accessors(qw(answers c answerOrder answersSubmitted displayMode imgGen showAnswerNumbers - showAttemptAnswers showHeadline showAttemptPreviews showAttemptResults showCorrectAnswers showSummary)); - $self->mk_accessors(qw(showMessages summary)); - - # Sanity check and initialize imgGenerator. - $self->_init(%options); - - return $self; -} - -# Verify the display mode, and build imgGen if it is not supplied. -sub _init ($self, %options) { - $self->{submitted} = $options{submitted} // 0; - $self->{displayMode} = $options{displayMode} || 'MathJax'; - - # Only show message column if there is at least one message. - my @reallyShowMessages = grep { $self->answers->{$_}{ans_message} } @{ $self->answerOrder }; - $self->showMessages($self->showMessages && !!@reallyShowMessages); - - # Only used internally. Accessors are not needed. - $self->{numCorrect} = 0; - $self->{numBlanks} = 0; - $self->{numEssay} = 0; - - if ($self->displayMode eq 'images') { - if (blessed($options{imgGen}) && $options{imgGen}->isa('WeBWorK::PG::ImageGenerator')) { - $self->{imgGen} = $options{imgGen}; - } elsif (blessed($options{ce}) && $options{ce}->isa('WeBWorK::CourseEnvironment')) { - my $ce = $options{ce}; - - $self->{imgGen} = WeBWorK::PG::ImageGenerator->new( - tempDir => $ce->{webworkDirs}{tmp}, - latex => $ce->{externalPrograms}{latex}, - dvipng => $ce->{externalPrograms}{dvipng}, - useCache => 1, - cacheDir => $ce->{webworkDirs}{equationCache}, - cacheURL => $ce->{server_root_url} . $ce->{webworkURLs}{equationCache}, - cacheDB => $ce->{webworkFiles}{equationCacheDB}, - dvipng_align => $ce->{pg}{displayModeOptions}{images}{dvipng_align}, - dvipng_depth_db => $ce->{pg}{displayModeOptions}{images}{dvipng_depth_db}, - ); - } else { - warn 'Must provide image Generator (imgGen) or a course environment (ce) to build attempts table.'; - } - } - - # Make sure that the provided summary is a Mojo::ByteStream object. - $self->summary(blessed($options{summary}) - && $options{summary}->isa('Mojo::ByteStream') ? $options{summary} : $self->c->b($options{summary} // '')); - - return; -} - -sub formatAnswerRow ($self, $rh_answer, $ans_id, $answerNumber) { - my $c = $self->c; - - my $answerString = $rh_answer->{student_ans} // ''; - my $answerPreview = $self->previewAnswer($rh_answer) // ' '; - my $correctAnswer = $rh_answer->{correct_ans} // ''; - my $correctAnswerPreview = $self->previewCorrectAnswer($rh_answer) // ' '; - - my $answerMessage = $rh_answer->{ans_message} // ''; - $answerMessage =~ s/\n/
/g; - my $answerScore = $rh_answer->{score} // 0; - $self->{numCorrect} += $answerScore >= 1; - $self->{numEssay} += ($rh_answer->{type} // '') eq 'essay'; - $self->{numBlanks}++ unless $answerString =~ /\S/ || $answerScore >= 1; - - my $feedbackMessageClass = ($answerMessage eq '') ? '' : $c->maketext('FeedbackMessage'); - - my $resultString; - my $resultStringClass; - if ($answerScore >= 1) { - $resultString = $c->maketext('correct'); - $resultStringClass = 'ResultsWithoutError'; - } elsif (($rh_answer->{type} // '') eq 'essay') { - $resultString = $c->maketext('Ungraded'); - $self->{essayFlag} = 1; - } elsif ($answerScore == 0) { - $resultStringClass = 'ResultsWithError'; - $resultString = $c->maketext('incorrect'); - } else { - $resultString = $c->maketext('[_1]% correct', wwRound(0, $answerScore * 100)); - } - my $attemptResults = $c->tag( - 'td', - class => $resultStringClass, - $c->tag('a', href => '#', data => { answer_id => $ans_id }, $self->nbsp($resultString)) - ); - - return $c->c( - $self->showAnswerNumbers ? $c->tag('td', $answerNumber) : '', - $self->showAttemptAnswers ? $c->tag('td', dir => 'auto', $self->nbsp($answerString)) : '', - $self->showAttemptPreviews - ? (((defined $answerPreview && $answerPreview ne '') || $self->showAttemptAnswers) - ? $self->formatToolTip($answerString, $answerPreview) - : $c->tag('td', dir => 'auto', $self->nbsp($answerString))) - : '', - $self->showAttemptResults ? $attemptResults : '', - $self->showCorrectAnswers ? $self->formatToolTip($correctAnswer, $correctAnswerPreview) : '', - $self->showMessages ? $c->tag('td', class => $feedbackMessageClass, $self->nbsp($answerMessage)) : '' - )->join(''); -} - -# Determine whether any answers were submitted and create answer template if they have been. -sub answerTemplate ($self) { - my $c = $self->c; - - return '' unless $self->answersSubmitted; # Only print if there is at least one non-blank answer - - my $tableRows = $c->c; - - push( - @$tableRows, - $c->tag( - 'tr', - $c->c( - $self->showAnswerNumbers ? $c->tag('th', '#') : '', - $self->showAttemptAnswers ? $c->tag('th', $c->maketext('Entered')) : '', - $self->showAttemptPreviews ? $c->tag('th', $c->maketext('Answer Preview')) : '', - $self->showAttemptResults ? $c->tag('th', $c->maketext('Result')) : '', - $self->showCorrectAnswers ? $c->tag('th', $c->maketext('Correct Answer')) : '', - $self->showMessages ? $c->tag('th', $c->maketext('Message')) : '' - )->join('') - ) - ); - - my $answerNumber = 0; - for (@{ $self->answerOrder() }) { - push @$tableRows, $c->tag('tr', $self->formatAnswerRow($self->{answers}{$_}, $_, ++$answerNumber)); - } - - return $c->c( - $self->showHeadline - ? $c->tag('h2', class => 'attemptResultsHeader', $c->maketext('Results for this submission')) - : '', - $c->tag( - 'div', - class => 'table-responsive', - $c->tag('table', class => 'attemptResults table table-sm table-bordered', $tableRows->join('')) - ), - $self->showSummary ? $self->createSummary : '' - )->join(''); -} - -sub previewAnswer ($self, $answerResult) { - my $displayMode = $self->displayMode; - my $imgGen = $self->imgGen; - - my $tex = $answerResult->{preview_latex_string}; - - return '' unless defined $tex and $tex ne ''; - - return $tex if $answerResult->{non_tex_preview}; - - if ($displayMode eq 'plainText') { - return $tex; - } elsif (($answerResult->{type} // '') eq 'essay') { - return $tex; - } elsif ($displayMode eq 'images') { - return $imgGen->add($tex); - } elsif ($displayMode eq 'MathJax') { - return $self->c->tag('script', type => 'math/tex; mode=display', $self->c->b($tex)); - } -} - -sub previewCorrectAnswer ($self, $answerResult) { - my $displayMode = $self->displayMode; - my $imgGen = $self->imgGen; - - my $tex = $answerResult->{correct_ans_latex_string}; - - # Some answers don't have latex strings defined return the raw correct answer - # unless defined $tex and $tex contains non whitespace characters; - return $answerResult->{correct_ans} - unless defined $tex and $tex =~ /\S/; - - return $tex if $answerResult->{non_tex_preview}; - - if ($displayMode eq 'plainText') { - return $tex; - } elsif ($displayMode eq 'images') { - return $imgGen->add($tex); - } elsif ($displayMode eq 'MathJax') { - return $self->c->tag('script', type => 'math/tex; mode=display', $self->c->b($tex)); - } -} - -# Create summary -sub createSummary ($self) { - my $c = $self->c; - - my $numCorrect = $self->{numCorrect}; - my $numBlanks = $self->{numBlanks}; - my $numEssay = $self->{numEssay}; - - my $summary; - - unless (defined($self->summary) and $self->summary =~ /\S/) { - # Default messages - $summary = $c->c; - my @answerNames = @{ $self->answerOrder() }; - if (scalar @answerNames == 1) { - if ($numCorrect == scalar @answerNames) { - push( - @$summary, - $c->tag( - 'div', - class => 'ResultsWithoutError mb-2', - $c->maketext('The answer above is correct.') - ) - ); - } elsif ($self->{essayFlag}) { - push(@$summary, $c->tag('div', $c->maketext('Some answers will be graded later.'))); - } else { - push( - @$summary, - $c->tag( - 'div', - class => 'ResultsWithError mb-2', - $c->maketext('The answer above is NOT correct.') - ) - ); - } - } else { - if ($numCorrect + $numEssay == scalar @answerNames) { - if ($numEssay) { - push( - @$summary, - $c->tag( - 'div', - class => 'ResultsWithoutError mb-2', - $c->maketext('All of the gradeable answers above are correct.') - ) - ); - } else { - push( - @$summary, - $c->tag( - 'div', - class => 'ResultsWithoutError mb-2', - $c->maketext('All of the answers above are correct.') - ) - ); - } - } elsif ($numBlanks + $numEssay != scalar(@answerNames)) { - push( - @$summary, - $c->tag( - 'div', - class => 'ResultsWithError mb-2', - $c->maketext('At least one of the answers above is NOT correct.') - ) - ); - } - if ($numBlanks > $numEssay) { - my $s = ($numBlanks > 1) ? '' : 's'; - push( - @$summary, - $c->tag( - 'div', - class => 'ResultsAlert mb-2', - $c->maketext( - '[quant,_1,of the questions remains,of the questions remain] unanswered.', $numBlanks - ) - ) - ); - } - } - $summary = $summary->join(''); - } else { - $summary = $self->summary; # Summary defined by grader - } - $summary = $c->tag('div', role => 'alert', class => 'attemptResultsSummary', $summary); - $self->summary($summary); - return $summary; -} - -# Utility subroutine that prevents unwanted line breaks, and ensures that the return value is a Mojo::ByteStream object. -sub nbsp ($self, $str) { - return $self->c->b(defined $str && $str =~ /\S/ ? $str : ' '); -} - -# Note that formatToolTip output includes the wrapper. -sub formatToolTip ($self, $answer, $formattedAnswer) { - return $self->c->tag( - 'td', - $self->c->tag( - 'div', - class => 'answer-preview', - data => { - bs_toggle => 'popover', - bs_content => $answer, - bs_placement => 'bottom', - }, - $self->nbsp($formattedAnswer) - ) - ); -} - -1; diff --git a/lib/WeBWorK/Localize.pm b/lib/WeBWorK/Localize.pm index fad943e9de..dd0a53f8e1 100644 --- a/lib/WeBWorK/Localize.pm +++ b/lib/WeBWorK/Localize.pm @@ -1,70 +1,49 @@ package WeBWorK::Localize; +use parent 'Locale::Maketext'; + +use strict; +use warnings; use File::Spec; -use Locale::Maketext; use Locale::Maketext::Lexicon; use WeBWorK::Utils qw(x); -my $path = "$ENV{WEBWORK_ROOT}/lib/WeBWorK/Localize"; -my $pattern = File::Spec->catfile($path, '*.[pm]o'); -my $decode = 1; -my $encoding = undef; - -# For some reason this next stanza needs to be evaluated -# separately. I'm not sure why it can't be -# directly entered into the code. -# This code was cribbed from Locale::Maketext::Simple if I remember correctly -# - -eval " - package WeBWorK::Localize::I18N; - use base 'Locale::Maketext'; - %WeBWorK::Localize::I18N::Lexicon = ( '_AUTO' => 1 ); - Locale::Maketext::Lexicon->import({ - 'i-default' => [ 'Auto' ], - '*' => [ Gettext => \$pattern ], - _decode => \$decode, - _encoding => \$encoding, - }); - *tense = sub { \$_[1] . ((\$_[2] eq 'present') ? 'ing' : 'ed') }; - -" or die "Can't process eval in WeBWorK/Localize.pm: line 35: " . $@; - -package WeBWorK::Localize; +Locale::Maketext::Lexicon->import({ + 'i-default' => ['Auto'], + '*' => [ Gettext => File::Spec->catfile("$ENV{WEBWORK_ROOT}/lib/WeBWorK/Localize", '*.[pm]o') ], + _decode => 1, + _encoding => undef, +}); +*tense = sub { \$_[1] . ((\$_[2] eq 'present') ? 'ing' : 'ed') }; -# This subroutine is shared with the safe compartment in PG to -# allow maketext() to be constructed in PG problems and macros -# It seems to be a little fragile -- possibly it breaks -# on perl 5.8.8 +# This subroutine is used to pass a language handle to job queue tasks +# so that tasks can use maketext. sub getLoc { my $lang = shift; - my $lh = WeBWorK::Localize::I18N->get_handle($lang); + my $lh = WeBWorK::Localize->get_handle($lang); return sub { $lh->maketext(@_) }; } sub getLangHandle { my $lang = shift; - my $lh = WeBWorK::Localize::I18N->get_handle($lang); - return $lh; + return WeBWorK::Localize->get_handle($lang); } -# this is like [quant] but it doesn't write the number +# This is like [quant] but it doesn't write the number. # usage: [quant,_1,,,] - sub plural { my ($handle, $num, @forms) = @_; return '' if @forms == 0; - return $forms[2] if @forms > 2 and $num == 0; + return $forms[2] if @forms > 2 && $num == 0; # Normal case: - return ($handle->numerate($num, @forms)); + return $handle->numerate($num, @forms); } -# this is like [quant] but it also has -1 case -# usage: [negquant,_1,,,,] - +# This is like [quant] but it also has a negative case. (The one usage in the code interprets this as unlimited.) +# usage: [negquant,_1,,,,] sub negquant { my ($handle, $num, @forms) = @_; @@ -73,12 +52,11 @@ sub negquant { my $negcase = shift @forms; return $negcase if $num < 0; - return $forms[2] if @forms > 2 and $num == 0; - return ($handle->numf($num) . ' ' . $handle->numerate($num, @forms)); + return $forms[2] if @forms > 2 && $num == 0; + return $handle->numf($num) . ' ' . $handle->numerate($num, @forms); } -# we use x to mark the strings for translation -%Lexicon = ( +our %Lexicon = ( '_AUTO' => 1, '_ONE_COLUMN' => x('One Column'), @@ -93,7 +71,4 @@ sub negquant { '_STATUS' => [ x('Enrolled'), x('Audit'), x('Drop'), x('Proctor') ], ); -package WeBWorK::Localize::I18N; -use base(WeBWorK::Localize); - 1; diff --git a/lib/WeBWorK/Utils.pm b/lib/WeBWorK/Utils.pm index 7b333a7cda..e8616e2222 100644 --- a/lib/WeBWorK/Utils.pm +++ b/lib/WeBWorK/Utils.pm @@ -82,6 +82,7 @@ our @EXPORT_OK = qw( list2hash listFilesRecursive makeTempDirectory + min max nfreeze_base64 not_blank @@ -253,7 +254,7 @@ Creates a directory with the given name, permission bits, and group ID. sub createDirectory { my ($dirName, $permission, $numgid) = @_; - $permission = (defined($permission)) ? $permission : '0770'; + $permission //= 0770; my $errors = ''; mkdir($dirName, $permission) or $errors .= "Can't do mkdir($dirName, $permission): $!\n" . caller(3); @@ -1055,15 +1056,22 @@ sub thaw_base64 { } -sub max(@) { - my $soFar; - foreach my $item (@_) { - $soFar = $item unless defined $soFar; - if ($item > $soFar) { - $soFar = $item; - } +sub min { + my @items = @_; + my $min = (shift @items) // 0; + for my $item (@items) { + $min = $item if ($item < $min); + } + return $min; +} + +sub max { + my @items = @_; + my $max = (shift @items) // 0; + for my $item (@items) { + $max = $item if ($item > $max); } - return defined $soFar ? $soFar : 0; + return $max; } sub wwRound(@) { diff --git a/lib/WeBWorK/Utils/CourseIntegrityCheck.pm b/lib/WeBWorK/Utils/CourseIntegrityCheck.pm index 70a9b6b2eb..375b5ca137 100644 --- a/lib/WeBWorK/Utils/CourseIntegrityCheck.pm +++ b/lib/WeBWorK/Utils/CourseIntegrityCheck.pm @@ -25,8 +25,9 @@ with database schema and that course directory structure is correct. use strict; use warnings; +use Mojo::File qw(path); + use WeBWorK::Debug; -use WeBWorK::Utils qw/createDirectory/; use WeBWorK::Utils::CourseManagement qw/listCourses/; # Developer note: This file should not format messages in html. Instead return an array of tuples. Each tuple should @@ -79,8 +80,7 @@ sub confirm { my ($self, @args) = @_; my $sub = $self->{confirm_sub}; return &$s sub DESTROY { my ($self) = @_; - $self->unlock_database; - $self->SUPER::DESTROY if $self->can("SUPER::DESTROY"); + $self->unlock_database if $self->{db_locked}; return; } @@ -324,85 +324,100 @@ sub checkCourseDirectories { =item $CIchecker->updateCourseDirectories($courseName); -Creates some course directories automatically. +Check to see if all course directories exist and have the correct permissions. -=cut +If a directory does not exist, then it is copied from the model course if the +corresponding directory exists in the model course, and is created otherwise. -# FIXME: This method needs work. It should give better messages, and should at least attempt to fix permissions if -# possible. It also should deal with some of the other course directories that it skips. +If the permissions are not correct, then an attempt is made to correct the +permissions. The permissions are expected to match the course root directory. +If the permissions of the course root directory are not correct, then that will +need to be manually fixed. This method does not check that. + +=cut sub updateCourseDirectories { my $self = shift; my $ce = $self->{ce}; - my @courseDirectories = keys %{ $ce->{courseDirs} }; + my @messages; + + # Sort courseDirs by path. The important thing for the order is that a directory that is a subdirectory of + # another is listed after the directory containing it. + my @course_dirs = + grep { $_ ne 'root' } sort { $ce->{courseDirs}{$a} =~ /^$ce->{courseDirs}{$b}/ } keys %{ $ce->{courseDirs} }; + + # These are the directories in the model course that can be copied if not found in this course. + my %model_course_dirs = ( + templates => 'templates', + html => 'html', + achievements => 'templates/achievements', + email => 'templates/email', + achievements_html => 'html/achievements' + ); - #FIXME this is hardwired for the time being. - my %updateable_directories = (html_temp => 1, mailmerge => 1, tmpEditFileDir => 1, hardcopyThemes => 1); + my $permissions = path($ce->{courseDirs}{root})->stat->mode & 0777; - my @messages; + for my $dir (@course_dirs) { + my $path = path($ce->{courseDirs}{$dir}); + next if -r $path && -w $path && -x $path; - for my $dir (sort @courseDirectories) { - # Hack for upgrading the achievements directory. - if ($dir eq 'achievements') { - my $modelCourseAchievementsDir = "$ce->{webworkDirs}{courses}/modelCourse/templates/achievements"; - my $modelCourseAchievementsHtmlDir = "$ce->{webworkDirs}{courses}/modelCourse/html/achievements"; - my $courseAchievementsDir = $ce->{courseDirs}{achievements}; - my $courseAchievementsHtmlDir = $ce->{courseDirs}{achievements_html}; - my $courseTemplatesDir = $ce->{courseDirs}{templates}; - my $courseHtmlDir = $ce->{courseDirs}{html}; - unless (-e $modelCourseAchievementsDir && -e $modelCourseAchievementsHtmlDir) { + my $path_exists_initially = -e $path; + + # Create the directory if it doesn't exist. + if (!$path_exists_initially) { + eval { + $path->make_path({ mode => $permissions }); + push(@messages, [ "Created directory $path.", 1 ]); + }; + if ($@) { + push(@messages, [ "Failed to create directory $path.", 0 ]); + next; + } + } + + # Fix permissions if those are not correct. + if (($path->stat->mode & 0777) != $permissions) { + eval { + $path->chmod($permissions); + push(@messages, [ "Changed permissions for directory $path.", 1 ]); + }; + push(@messages, [ "Failed to change permissions for directory $path.", 0 ]) if $@; + } + + # If the path did not exist to begin with and there is a corresponding model course directory, + # then copy the contents of the model course directory. + if (!$path_exists_initially && $model_course_dirs{$dir}) { + my $modelCoursePath = "$ce->{webworkDirs}{courses}/modelCourse/$model_course_dirs{$dir}"; + if (!-r $modelCoursePath) { push( @messages, [ 'Your modelCourse in the "courses" directory is out of date or missing. Please update it from ' - . 'webwork/webwork2/courses.dist directory before upgrading the other courses. Cannot find ' - . "MathAchievements directory $modelCourseAchievementsDir nor MathAchievements picture " - . "directory $modelCourseAchievementsHtmlDir", + . "the webwork2/courses.dist directory. Cannot find directory $modelCoursePath. The " + . "directory $path has been created, but may be missing the files it should contain.", 0 ] ); - } else { - unless (-e $courseAchievementsDir && -e $courseAchievementsHtmlDir) { - push(@messages, - [ "Attempting to update the achievements directory for $ce->{courseDirs}{root}", 1 ]); - if (-e $courseAchievementsDir) { - push(@messages, [ 'Achievements directory is already present', 1 ]); - } else { - system "cp -RPpi $modelCourseAchievementsDir $courseTemplatesDir"; - push(@messages, [ 'Achievements directory created', 1 ]); - } - if (-e $courseAchievementsHtmlDir) { - push(@messages, [ 'Achievements html directory is already present', 1 ]); + next; + } + + eval { + for (path($modelCoursePath)->list_tree({ dir => 1 })->each) { + my $destPath = $_ =~ s!$modelCoursePath!$path!r; + if (-l $_) { + symlink(readlink $_, $destPath); + } elsif (-d $_) { + path($destPath)->make_path({ mode => $permissions }); } else { - system "cp -RPpi $modelCourseAchievementsHtmlDir $courseHtmlDir "; - push(@messages, [ 'Achievements html directory created', 1 ]); + $_->copy_to($destPath); } } - } + push(@messages, [ "Copied model course directory $modelCoursePath to $path.", 1 ]); + }; + push(@messages, [ "Failed to copy model course directory $modelCoursePath to $path: $@.", 0 ]) if $@; } - next unless exists $updateable_directories{$dir}; - my $path = $ce->{courseDirs}->{$dir}; - unless (-e $path) { # If the directory does not exist, create it. - my $parentDirectory = $path; - $parentDirectory =~ s|/$||; # Remove a trailing forward slash - $parentDirectory =~ s|/[^/]*$||; # Remove last node - my ($perms, $groupID) = (stat $parentDirectory)[ 2, 5 ]; - if (-w $parentDirectory) { - createDirectory($path, $perms, $groupID) - or push(@messages, [ "Failed to create directory at $path.", 0 ]); - } else { - push( - @messages, - [ - "Permissions error. Can't create directory at $path. " - . "Lack write permission on $parentDirectory.", - 0 - ] - ); - } - } } return \@messages; @@ -412,29 +427,28 @@ sub updateCourseDirectories { # Database utilities -- borrowed from DBUpgrade.pm ??use or modify??? --MEG ############################################################################## -sub lock_database { # lock named 'webwork.dbugrade' times out after 10 seconds - my $self = shift; - my $dbh = $self->dbh; - my ($lock_status) = $dbh->selectrow_array("SELECT GET_LOCK('webwork.dbupgrade', 10)"); - if (not defined $lock_status) { - die "Couldn't obtain lock because an error occurred.\n"; - } - if (!$lock_status) { +# Create a lock named 'webwork.dbugrade' that times out after 10 seconds. +sub lock_database { + my $self = shift; + my ($lock_status) = $self->dbh->selectrow_array("SELECT GET_LOCK('webwork.dbupgrade', 10)"); + if (!defined $lock_status) { + die "Couldn't obtain lock because a database error occurred.\n"; + } elsif (!$lock_status) { die "Timed out while waiting for lock.\n"; } + $self->{db_locked} = 1; return; } sub unlock_database { - my $self = shift; - my $dbh = $self->dbh; - my ($lock_status) = $dbh->selectrow_array("SELECT RELEASE_LOCK('webwork.dbupgrade')"); - if (not defined $lock_status) { - # die "Couldn't release lock because the lock does not exist.\n"; - } elsif ($lock_status) { - return; + my $self = shift; + my ($lock_status) = $self->dbh->selectrow_array("SELECT RELEASE_LOCK('webwork.dbupgrade')"); + if ($lock_status) { + delete $self->{db_locked}; + } elsif (defined $lock_status) { + warn "Couldn't release lock because the lock is not held by this thread.\n"; } else { - die "Couldn't release lock because the lock is not held by this thread.\n"; + warn "Unable to release lock because a database error occurred.\n"; } return; } diff --git a/lib/WeBWorK/Utils/LibraryStats.pm b/lib/WeBWorK/Utils/LibraryStats.pm index 186935de2a..a4a39027dd 100644 --- a/lib/WeBWorK/Utils/LibraryStats.pm +++ b/lib/WeBWorK/Utils/LibraryStats.pm @@ -13,61 +13,43 @@ # Artistic License for more details. ################################################################################ -########################### -# Utils::LibraryLocalStats -# -# This is an interface for getting local statistics about library problems -# for display -########################### - +# This is an interface for getting global and local statistics about library problems for display. package WeBWorK::Utils::LibraryStats; -use base qw(Exporter); use strict; use warnings; -use DBI; -our @EXPORT = (); -our @EXPORT_OK = qw(); +use DBI; sub new { - my $class = shift; - my $ce = shift; + my ($class, $ce) = @_; - my $dbh = DBI->connect( - $ce->{problemLibrary_db}->{dbsource}, - $ce->{problemLibrary_db}->{user}, - $ce->{problemLibrary_db}->{passwd}, + my $dbh = DBI->connect_cached( + $ce->{problemLibrary_db}{dbsource}, + $ce->{problemLibrary_db}{user}, + $ce->{problemLibrary_db}{passwd}, { PrintError => 0, RaiseError => 0, }, ); - my $localselectstm = $dbh->prepare("SELECT * FROM OPL_local_statistics WHERE source_file = ?"); - - my $globalselectstm = $dbh->prepare("SELECT * FROM OPL_global_statistics WHERE source_file = ?"); - - my $self = { + return bless { dbh => $dbh, - localselectstm => $localselectstm, - globalselectstm => $globalselectstm, - }; - - bless($self, $class); - return $self; + localselectstm => $dbh->prepare("SELECT * FROM OPL_local_statistics WHERE source_file = ?"), + globalselectstm => $dbh->prepare("SELECT * FROM OPL_global_statistics WHERE source_file = ?"), + }, $class; } sub getLocalStats { - my $self = shift; - my $source_file = shift; + my ($self, $source_file) = @_; my $selectstm = $self->{localselectstm}; unless ($selectstm->execute($source_file)) { if ($selectstm->errstr =~ /Table .* doesn't exist/) { - warn - "Couldn't find the OPL local statistics table. Did you download the latest OPL and run update-OPL-statistics.pl?"; + warn "Couldn't find the OPL local statistics table. " + . "Did you download the latest OPL and run update-OPL-statistics.pl?"; } die $selectstm->errstr; } @@ -87,15 +69,14 @@ sub getLocalStats { } sub getGlobalStats { - my $self = shift; - my $source_file = shift; + my ($self, $source_file) = @_; my $selectstm = $self->{globalselectstm}; unless ($selectstm->execute($source_file)) { if ($selectstm->errstr =~ /Table .* doesn't exist/) { - warn - "Couldn't find the OPL global statistics table. Did you download the latest OPL and run load-OPL-global-statistics.pl?"; + warn "Couldn't find the OPL global statistics table. " + . "Did you download the latest OPL and run load-OPL-global-statistics.pl?"; } die $selectstm->errstr; } diff --git a/lib/WeBWorK/Utils/ListingDB.pm b/lib/WeBWorK/Utils/ListingDB.pm index 7bcce145fd..60483182ce 100644 --- a/lib/WeBWorK/Utils/ListingDB.pm +++ b/lib/WeBWorK/Utils/ListingDB.pm @@ -105,10 +105,10 @@ sub getTables { sub getDB { my $ce = shift; - my $dbh = DBI->connect( - $ce->{problemLibrary_db}->{dbsource}, - $ce->{problemLibrary_db}->{user}, - $ce->{problemLibrary_db}->{passwd}, + my $dbh = DBI->connect_cached( + $ce->{problemLibrary_db}{dbsource}, + $ce->{problemLibrary_db}{user}, + $ce->{problemLibrary_db}{passwd}, { PrintError => 0, RaiseError => 1, diff --git a/lib/WeBWorK/Utils/Rendering.pm b/lib/WeBWorK/Utils/Rendering.pm index b80c6c96c4..f10e27641c 100644 --- a/lib/WeBWorK/Utils/Rendering.pm +++ b/lib/WeBWorK/Utils/Rendering.pm @@ -122,8 +122,7 @@ sub constructPGOptions ($ce, $user, $set, $problem, $psvn, $formFields, $transla $options{PERSISTENCE_HASH} = decode_json($problem->problem_data || '{}'); # Language - $options{language} = $ce->{language}; - $options{language_subroutine} = WeBWorK::Localize::getLoc($options{language}); + $options{language} = $ce->{language}; # Student and course Information $options{courseName} = $ce->{courseName}; @@ -152,6 +151,15 @@ sub constructPGOptions ($ce, $user, $set, $problem, $psvn, $formFields, $transla $options{inputs_ref} = $formFields; $options{processAnswers} = $translationOptions->{processAnswers}; + # Attempt Results + $options{showFeedback} = $translationOptions->{showFeedback}; + $options{showAttemptAnswers} = $translationOptions->{showAttemptAnswers}; + $options{showAttemptPreviews} = $translationOptions->{showAttemptPreviews}; + $options{forceShowAttemptResults} = $translationOptions->{forceShowAttemptResults}; + $options{showAttemptResults} = $translationOptions->{showAttemptResults}; + $options{showMessages} = $translationOptions->{showMessages}; + $options{showCorrectAnswers} = $translationOptions->{showCorrectAnswers}; + # External Data $options{external_data} = decode_json($set->{external_data} || '{}'); @@ -245,7 +253,7 @@ sub renderPG ($c, $effectiveUser, $set, $problem, $psvn, $formFields, $translati flags => $pg->{flags}, }; - if (ref $pg->{pgcore}) { + if (ref($pg->{pgcore}) eq 'PGcore') { $ret->{internal_debug_messages} = $pg->{pgcore}->get_internal_debug_messages; $ret->{warning_messages} = $pg->{pgcore}->get_warning_messages(); $ret->{debug_messages} = $pg->{pgcore}->get_debug_messages(); diff --git a/lib/WebworkWebservice/RenderProblem.pm b/lib/WebworkWebservice/RenderProblem.pm index ac2f5ef6ab..f039322613 100644 --- a/lib/WebworkWebservice/RenderProblem.pm +++ b/lib/WebworkWebservice/RenderProblem.pm @@ -228,7 +228,15 @@ async sub renderProblem { isInstructor => $rh->{isInstructor} // 0, forceScaffoldsOpen => $rh->{forceScaffoldsOpen} // 0, QUIZ_PREFIX => $rh->{answerPrefix}, - debuggingOptions => { + showFeedback => $rh->{preview} || $rh->{WWsubmit} || $rh->{WWcorrectAns}, + showAttemptAnswers => $rh->{showAttemptAnswers} // 1, + showAttemptPreviews => $rh->{showAttemptPreviews} + // ($rh->{preview} || $rh->{WWsubmit} || $rh->{WWcorrectAns}), + showAttemptResults => $rh->{showAttemptResults} // ($rh->{WWsubmit} || $rh->{WWcorrectAns}), + forceShowAttemptResults => $rh->{forceShowAttemptResults}, + showMessages => $rh->{showMessages} // ($rh->{preview} || $rh->{WWsubmit} || $rh->{WWcorrectAns}), + showCorrectAnswers => $rh->{showCorrectAnswers} // $rh->{WWcorrectAns}, + debuggingOptions => { show_resource_info => $rh->{show_resource_info} // 0, view_problem_debugging_info => $rh->{view_problem_debugging_info} // 0, show_pg_info => $rh->{show_pg_info} // 0, diff --git a/templates/ContentGenerator/Base/links.html.ep b/templates/ContentGenerator/Base/links.html.ep index add6effaa7..ee3d9857b6 100644 --- a/templates/ContentGenerator/Base/links.html.ep +++ b/templates/ContentGenerator/Base/links.html.ep @@ -138,8 +138,13 @@ % } % # Problem Editor % if (defined $prettySetID && defined $problemID) {
  • <%= maketext('TA:') %> 5
  • <%= maketext('Instructor:') %> 10
  • - <%= maketext('Note that if you set the permission level of a user to something other than "Student", you may ' . 'also want to set the status of the user to "Observer". Users with the "Observer" status are not ' . 'included in statistics, scoring, or instructor emails.') =%> -
    -
    <%= maketext('Drop student from the course') %>
    diff --git a/templates/RPCRenderFormats/default.html.ep b/templates/RPCRenderFormats/default.html.ep index fbccee5544..71e4f68543 100644 --- a/templates/RPCRenderFormats/default.html.ep +++ b/templates/RPCRenderFormats/default.html.ep @@ -37,7 +37,7 @@
    - %== $answerTemplate + %== $resultSummary <%= form_for $FORM_ACTION_URL, id => 'problemMainForm', class => 'problem-main-form', name => 'problemMainForm', method => 'POST', begin %>
    > @@ -66,7 +66,6 @@ % } %== $LTIGradeMessage % - %= hidden_field answersSubmitted => 1 %= hidden_field sourceFilePath => $sourceFilePath %= hidden_field problemSource => $problemSource %= hidden_field uriEncodedProblemSource => $uriEncodedProblemSource @@ -87,7 +86,6 @@ %= hidden_field showSummary => $showSummary %= hidden_field showHints => $showHints %= hidden_field showSolutions => $showSolutions - %= hidden_field showAnswerNumbers => $showAnswerNumbers %= hidden_field showPreviewButton => $showPreviewButton %= hidden_field showCheckAnswersButton => $showCheckAnswersButton %= hidden_field showCorrectAnswersButton => $showCorrectAnswersButton diff --git a/templates/RPCRenderFormats/default.json.ep b/templates/RPCRenderFormats/default.json.ep index 38a8c504d9..c25764aadd 100644 --- a/templates/RPCRenderFormats/default.json.ep +++ b/templates/RPCRenderFormats/default.json.ep @@ -19,7 +19,7 @@ % % body_part001 => '', % body_part100 => '
    ', - % body_part300 => $answerTemplate, + % body_part300 => $resultSummary, % body_part500 => '
    ', % body_part530 => qq{
    }, @@ -46,7 +46,6 @@ % . '}', % % hidden_input_field => { - % answersSubmitted => '1', % sourceFilePath => $sourceFilePath, % problemSource => $problemSource, % problemSeed => $problemSeed, @@ -64,7 +63,6 @@ % showSummary => $showSummary, % showHints => $showHints, % showSolutions => $showSolutions, - % showAnswerNumbers => $showAnswerNumbers, % showPreviewButton => $showPreviewButton, % showCheckAnswersButton => $showCheckAnswersButton, % showCorrectAnswersButton => $showCorrectAnswersButton,