From b92886ad59b3836bfb04e8b8d5d82ad6b65b59a7 Mon Sep 17 00:00:00 2001 From: Conn Warwicker Date: Mon, 13 May 2024 11:22:29 +0100 Subject: [PATCH] MDL-70854 core: Add stored progress bars. - Alters existing progress_bar class to support extension - Adds stored_progress_bar class as child of progress_bar - Adds webservice to poll stored progress - Updates database tables - Bumps version - Adds unit/behat tests --- .upgradenotes/MDL-70854-2024071306574741.yml | 5 + .../tool/task/classes/running_tasks_table.php | 24 ++ .../task/tests/behat/running_tasks.feature | 21 + config-dist.php | 9 + lang/en/admin.php | 1 + lib/amd/build/stored_progress.min.js | Bin 0 -> 2360 bytes lib/amd/build/stored_progress.min.js.map | Bin 0 -> 4378 bytes lib/amd/src/stored_progress.js | 110 ++++++ lib/behat/classes/behat_core_generator.php | 5 + .../external/output/poll_stored_progress.php | 118 ++++++ lib/classes/output/core_renderer.php | 11 +- lib/classes/output/core_renderer_cli.php | 4 +- lib/classes/output/progress_bar.php | 162 ++++++-- lib/classes/output/stored_progress_bar.php | 365 ++++++++++++++++++ .../task/delete_unconfirmed_users_task.php | 20 +- .../task/stored_progress_bar_cleanup_task.php | 53 +++ .../task/stored_progress_task_trait.php | 59 +++ lib/db/install.xml | 17 + lib/db/services.php | 8 + lib/db/tasks.php | 10 + lib/db/upgrade.php | 28 ++ lib/javascript-static.js | 4 +- lib/templates/progress_bar.mustache | 36 +- lib/testing/generator/data_generator.php | 54 +++ lib/tests/behat/behat_general.php | 17 + .../output/poll_stored_progress_test.php | 65 ++++ lib/tests/stored_progress_bar_test.php | 195 ++++++++++ .../stored_progress_bar_cleanup_task_test.php | 51 +++ version.php | 2 +- 29 files changed, 1396 insertions(+), 58 deletions(-) create mode 100644 .upgradenotes/MDL-70854-2024071306574741.yml create mode 100644 lib/amd/build/stored_progress.min.js create mode 100644 lib/amd/build/stored_progress.min.js.map create mode 100644 lib/amd/src/stored_progress.js create mode 100644 lib/classes/external/output/poll_stored_progress.php create mode 100644 lib/classes/output/stored_progress_bar.php create mode 100644 lib/classes/task/stored_progress_bar_cleanup_task.php create mode 100644 lib/classes/task/stored_progress_task_trait.php create mode 100644 lib/tests/external/output/poll_stored_progress_test.php create mode 100644 lib/tests/stored_progress_bar_test.php create mode 100644 lib/tests/task/stored_progress_bar_cleanup_task_test.php diff --git a/.upgradenotes/MDL-70854-2024071306574741.yml b/.upgradenotes/MDL-70854-2024071306574741.yml new file mode 100644 index 00000000000..42b111e3715 --- /dev/null +++ b/.upgradenotes/MDL-70854-2024071306574741.yml @@ -0,0 +1,5 @@ +issueNumber: MDL-70854 +notes: + core: + - message: Added stored progress bars + type: improved diff --git a/admin/tool/task/classes/running_tasks_table.php b/admin/tool/task/classes/running_tasks_table.php index f26265d99ab..1193e93ead7 100644 --- a/admin/tool/task/classes/running_tasks_table.php +++ b/admin/tool/task/classes/running_tasks_table.php @@ -49,6 +49,7 @@ class running_tasks_table extends \table_sql { 'classname' => get_string('classname', 'tool_task'), 'type' => get_string('tasktype', 'admin'), 'time' => get_string('taskage', 'tool_task'), + 'progress' => get_string('progress', 'core'), 'timestarted' => get_string('started', 'tool_task'), 'hostname' => get_string('hostname', 'tool_task'), 'pid' => get_string('pid', 'tool_task'), @@ -153,4 +154,27 @@ class running_tasks_table extends \table_sql { public function col_timestarted($row): string { return userdate($row->timestarted); } + + /** + * Format the progress column. + * + * @param \stdClass $row + * @return string + */ + public function col_progress($row): string { + // Check to see if there is a stored progress record for this task. + if ($row->type === 'adhoc') { + $idnumber = \core\output\stored_progress_bar::convert_to_idnumber($row->classname, $row->id); + } else { + $idnumber = \core\output\stored_progress_bar::convert_to_idnumber($row->classname); + } + + $bar = \core\output\stored_progress_bar::get_by_idnumber($idnumber); + if ($bar) { + return $bar->get_content(); + } else { + return '-'; + } + } + } diff --git a/admin/tool/task/tests/behat/running_tasks.feature b/admin/tool/task/tests/behat/running_tasks.feature index 5725d5c51c4..b26d6bd9932 100644 --- a/admin/tool/task/tests/behat/running_tasks.feature +++ b/admin/tool/task/tests/behat/running_tasks.feature @@ -38,3 +38,24 @@ Feature: See running scheduled tasks And I should see "2 days" in the "core\task\asynchronous_restore_task" "table_row" And I should see "c69335460f7f" in the "core\task\asynchronous_restore_task" "table_row" And I should see "1916" in the "core\task\asynchronous_restore_task" "table_row" + + @javascript + Scenario: If a task with a stored progress bar is running, I should be able to observe the progress. + Given the following config values are set as admin: + | progresspollinterval | 1 | + And the following "tool_task > scheduled tasks" exist: + | classname | seconds | hostname | pid | + | \core\task\delete_unconfirmed_users_task | 120 | c69335460f7f | 1917 | + And the following "stored progress bars" exist: + | idnumber | percent | + | core_task_delete_unconfirmed_users_task | 50.00 | + And I navigate to "Server > Tasks > Tasks running now" in site administration + And I should see "2 mins" in the "Delete unconfirmed users" "table_row" + And I should see "c69335460f7f" in the "Delete unconfirmed users" "table_row" + And I should see "1917" in the "Delete unconfirmed users" "table_row" + And I should see "50.0%" in the "Delete unconfirmed users" "table_row" + When I set the stored progress bar "core_task_delete_unconfirmed_users_task" to "75.00" + # Wait for the progress polling. + And I wait "1" seconds + Then I should not see "50.0%" in the "Delete unconfirmed users" "table_row" + And I should see "75.0%" in the "Delete unconfirmed users" "table_row" diff --git a/config-dist.php b/config-dist.php index ec2708cde80..8e7de4445d9 100644 --- a/config-dist.php +++ b/config-dist.php @@ -773,6 +773,15 @@ $CFG->admin = 'admin'; // Defaults to 60 minutes. // // $CFG->enrolments_sync_interval = 3600 +// +// Stored progress polling interval +// +// Stored progress bars which can be polled for updates via AJAX can be controlled by the +// `progresspollinterval` config setting, to determine the interval (in seconds) at which the +// polling should be done and latest update retrieved. +// If no value is set, then it will default to 5 seconds. +// +// $CFG->progresspollinterval = 5; //========================================================================= // 7. SETTINGS FOR DEVELOPMENT SERVERS - not intended for production use!!! diff --git a/lang/en/admin.php b/lang/en/admin.php index be392c74838..db918e07cbd 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -1342,6 +1342,7 @@ $string['stickyblockscourseview'] = 'Course page'; $string['stickyblocksduplicatenotice'] = 'If any block you add here is already present in a particular page, it will result in a duplicate.
Only the pinned block will be non-editable, the duplicate will still be editable.'; $string['stickyblocksmymoodle'] = 'My Moodle'; $string['stickyblockspagetype'] = 'Page type to configure'; +$string['storedprogressbarcleanuptask'] = 'Stored progress bar cleanup task'; $string['strictformsrequired'] = 'Strict validation of required fields'; $string['stripalltitletags'] = 'Remove HTML tags from all activity names'; $string['supportandservices'] = 'Support and services'; diff --git a/lib/amd/build/stored_progress.min.js b/lib/amd/build/stored_progress.min.js new file mode 100644 index 0000000000000000000000000000000000000000..2e068914025ff349878e30751cf07cfb3ddc0c61 GIT binary patch literal 2360 zcmai0T~FgW5Pj!YxVci4px~C>w|33P0zxaXTU1!_fKX+UX=_OBtm9CslK;MAJE0%2 zx=6J4czkAj=8WA8CPG3Kq)J1#H29f~3$3Oa$}&jq19&ZzHaPSi{E5G|W2uaoh?EhOZV5WfEDKN*atHkRCof_RrqYtgD$fTtKxzTL^GC=V048D4dM9r6*BX6u zap0k%e`UqmB(~FQ6Ig>D??&l=NUDyvKU4 zG1qk^-1bi@g!Tqzh3Dc`Zu)4d*HvQwVtR`k8{hF4hVF~bco|vjHb-woDLJcy$C3{p zpV+ES(7C1a9LiLS0)@_S@gv<~*q9M&xR4*W4JJxQR%6ewBtlv($5x!p>5vhIEAKGa zOGa6>+Ke{l7dNd${IuTd?(gprLiWjxs{k>I zR7J)Okez#nAZhLbB9B~`L`h<1=#**zNgag8Ihng=KukhDyT2N*R254trZYpxm)@7J zh_}EyrX*+F@O)Vs^5d4QPI4ii$;_Cd-|a3I3#^Y{J%EZzFx|RA%S8&Z1RHb1dnzlc z^t5YZJndjv4*KcxpeMMd{uqHVkS2sw!GBY&UzcVEzx#M~v(r3`*TZ(gv zS^D|OPA*3dpc~oi6}x})er2`*Y|9kyw)HW(^gnjv&Fye>aX!8t4KGI*H#g&};pzFs zIqMv*uj)dfW<^#eM$AD~CSK)WY_;a11ZLCmvc6x<0ne66K8L=0IL7E*RAy`u#yjN> z60WCZe}%w({IBbWIMHA|amMZ+22Ftb-UCG;$Y}l2;46hnEaO#k|2A`qJ$ivpz=kL4 zjK?dlx9dh;-?@&gomNu(sX%0jo3-lmvpGJ5d2`8+>{?`Mr0277zm?ti3Il}-VFP!vK=e~+%%j#BAdNcvRLIWPVKL8w?r literal 0 HcmV?d00001 diff --git a/lib/amd/build/stored_progress.min.js.map b/lib/amd/build/stored_progress.min.js.map new file mode 100644 index 0000000000000000000000000000000000000000..46068db0a4394891344721c46b7a8c5e9d04239c GIT binary patch literal 4378 zcmai1+fv&|6MmImUo4OkHvi^gYe`5k62^GJ9DIP~Pg2w%jqM4tq#cbQ6veym*E15f zA=y=xr8)HU@$2sKQ|n%tQs-G~_kGZs>QuFMTcyp7N^T01&x|U|_CjavZ)GcJm3d`i zRkn6Nx7zJaY2wanQjmRpp^Ng&s?0KZr$ghpE{P=&zJ)ZF@+l4SJi*E~&8;nVJDr7_ zw{tV=e91cQ*U<23qZF0-)ILa~cIh#%D3%#PSzTJAZz~JtmSmQ6azl$e(bGp}!;4jx zC_{Fx$SSiaSvXI6rxWU_Oc|Ndd3BrWm`-%8vQm+Rn!HlZRYJF(5g$2ZEk?B#beMy) zv~XcSN@L$p?NWN*2&p*+lpCkcrnIcQp}gROThRECQVGSHv+W;Pzcex-ojL92c>#Oo zU5s2r10D9qr`^$r4$p=p=v)lP{b+I`hIBp|o}Z1nZK4r; z1ZOx7|Kj7Ri<425C@XbZF73WXa7$>ICN!7#3PFyQzK4F2;=Ew({v&=)v`q7C=4`ED z1l9Ue%5ob}2^BxqxO;ebXwS0B0S_49_?Mke&<0%Iz59~UI~v7C7nW>JRe^v5i0f%W zO}@~rG{BelY8eA985oi0vJX^NKs-M&((H8xIZ^v;q0zIsCHigWH{d8kRFaY+X=VCY zTI!GKX+PE3U6VL$KwR%`tE!zT+j;iPYI|7X53FTTshZl(tVr8)yGVUXoS)D=&-Y(x@`=wQnc0~%OY>VKn|I=QU1LINY6`$G zM{Rh==_kI)?@0Q5K^x9j67!7@U$UC!G`D)HAqzRQN`~mZtTXvChWq!BbDQU>uSQ~U zKnM_r71brpWdeLHs?_QNA+ma*%77T6O2+fG_;Pq9s@P1q(4rgwjo<-H6eTC4sj^H1 zdh6UgF}s2l8I8||-GiI+;aP8pbhd5+wcG(!etHSm`DAT@H6 zINm-`sIGEBPn^h)&!m%bZCRr#&y;y~2BHMi!I@TB>^xaO)+P=G4Z#*Yu@gD&sHKsF zD&kM^7gF-?N|lxc<4Kiel@=PMbI*kifCQ({IL!O}EEEuwWO9M(?LvA3`9)=KSmw>E zgxUy5npwF^PaDu?W0wtKNaK0_y5-!%W0q?aGr&G1fByQi1#Pe9aM-H0ptY6BVBH*j zg2RlGjy8v=bM;}4wH2RtzP7QMZ8o0StjSjV!Q{lA*-ZoCdWQJ>q%so4l7E#@nSuHpJ-GwJ9u*pEudI$;!2> zOe2Ik3CK+wUFvBqzMdh{dV#vv5G!MHvkZfm=lblGFnwhIqz&{HJGM#%*j zwxHuEFnpyeXuNifI`onLx3fdP{z|_%HN9j8Wh1otzTlu;ROQ@r(FrTrQ1BNZT3%~H$p2v4fZ+lM%+-6}Vx8AI@O8(ReE+gD%p}y0;nSY`k2aZ+E zc--vot&#tm3D4_M@YXzVgW?;(1DZK^Ee?UZX!y>#Kv;9GRB|S9zgs$l-M{o>s68;1J~_Q|^h=Ub?sGpxBs`x3{0 z5^!HYSwzJ82dj0E0kGcJ9A;QGeqhVU-v#EldZ2#??8zk0HgL7(9723y88+Ue%+Eqg z)>OWavj7Lq=MVe`i94YC8};Lp_^ZXr;sZzEU1N%ueS^h_??(7=i^XTzSTW(QiO)+C zV+?;}ZprmIjOxH*#G%0FO^4vMx;9DcxDppd z7zM{d97jRly`|{=6$KYUOqzAx4I&{TydnegD8ZqyYRs2)#a_pp&R$y_{fd> ze0ub!8y~aGs}naK1ZSex>7FhRT(Rg2e~Ndb-@{8jFu8Ix=k9$0^ZP|ObdyV#b2X{Q z>*XAW#wvu5p1nJ^D|<5Ga;`2OJUkTn}b zK?!?h6g-AvbaEdCvFOH3_dYxdDs}~Xx%)@hbzIrCmx!HrrN+JvT|6Jcxm&%!d?b45 p!H_+F?EUXTdcn?)itu-*M*q(17eV>s+oG9x7=Xd$Q4N#U^FNxMc~$@b literal 0 HcmV?d00001 diff --git a/lib/amd/src/stored_progress.js b/lib/amd/src/stored_progress.js new file mode 100644 index 00000000000..762dc38443d --- /dev/null +++ b/lib/amd/src/stored_progress.js @@ -0,0 +1,110 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle 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 the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Script to update stored_progress progress bars on the screen. + * + * @module core/stored_progress + * @copyright 2023 onwards Catalyst IT {@link http://www.catalyst-eu.net/} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @author Conn Warwicker + */ + +/* global updateProgressBar */ + +import * as Ajax from 'core/ajax'; +import Notification from 'core/notification'; + +/** + * @var bool This AMD script is loaded multiple times, for each progress bar on a page. + * So this stops it running multiple times. + * */ +var STORED_PROGRESS_LOADED = false; + +/** + * Poll a given stored progress record. + * + * @param {array} ids + * @param {integer} timeout + */ +function poll(ids, timeout) { + + // Call AJAX request. + let promise = Ajax.call([{ + methodname: 'core_output_poll_stored_progress', args: {'ids': ids} + }]); + + let repollids = []; + + // When AJAX request returns, handle the results. + promise[0].then(function(results) { + + results.forEach(function(data) { + + // Update the progress bar percentage and message using the core method from the javascript-static.js. + updateProgressBar(data.uniqueid, data.progress, data.message, data.estimated, data.error); + + // Add the bar for re-polling if it's not completed. + if (data.progress < 100 && !data.error) { + repollids.push(data.id); + } + + // If a different timeout came back from the script, use that instead. + if (data.timeout && data.timeout > 0) { + timeout = data.timeout; + } + + }); + + // If we still want to poll any of them, do it again. + if (repollids.length > 0) { + return setTimeout(() => poll(repollids, timeout), timeout * 1000); + } + + return false; + + }).catch(Notification.exception); + +} + +/** + * Initialise the polling process. + * + * @param {integer} timeout Timeout to use (seconds). + */ +export const init = (timeout) => { + + if (STORED_PROGRESS_LOADED === false) { + + let ids = []; + + // Find any stored progress bars we want to poll. + document.querySelectorAll('.stored-progress-bar').forEach(el => { + + // Get its id and add to array. + let id = el.dataset.recordid; + ids.push(id); + + }); + + // Poll for updates from these IDs. + poll(ids, timeout); + + // Script has run, we don't want it to run again. + STORED_PROGRESS_LOADED = true; + + } + +}; \ No newline at end of file diff --git a/lib/behat/classes/behat_core_generator.php b/lib/behat/classes/behat_core_generator.php index bc1e0807912..7bedba6d03a 100644 --- a/lib/behat/classes/behat_core_generator.php +++ b/lib/behat/classes/behat_core_generator.php @@ -316,6 +316,11 @@ class behat_core_generator extends behat_generator_base { 'required' => ['subject', 'userfrom', 'userto'], 'switchids' => ['userfrom' => 'userfromid', 'userto' => 'usertoid'], ], + 'stored progress bars' => [ + 'singular' => 'stored progress bar', + 'datagenerator' => 'stored_progress_bar', + 'required' => ['idnumber'], + ], ]; return $entities; diff --git a/lib/classes/external/output/poll_stored_progress.php b/lib/classes/external/output/poll_stored_progress.php new file mode 100644 index 00000000000..d17a6f3b6d7 --- /dev/null +++ b/lib/classes/external/output/poll_stored_progress.php @@ -0,0 +1,118 @@ +. + +namespace core\external\output; + +use core_external\external_function_parameters; +use core_external\external_multiple_structure; +use core_external\external_single_structure; +use core_external\external_value; + +/** + * Poll Stored Progress webservice. + * + * @package core + * @copyright 2023 onwards Catalyst IT {@link http://www.catalyst-eu.net/} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @author Conn Warwicker + */ +class poll_stored_progress extends \core_external\external_api { + + /** + * Returns description of method parameters + * + * @return external_function_parameters + */ + public static function execute_parameters() { + return new external_function_parameters([ + 'ids' => new external_multiple_structure( + new external_value(PARAM_INT, 'The stored_progress ID', VALUE_REQUIRED) + ), + ]); + } + + /** + * Returns description of method return data + * + * @return external_multiple_structure + */ + public static function execute_returns() { + return new external_multiple_structure( + new external_single_structure([ + 'id' => new external_value(PARAM_INT, 'stored_progress record id'), + 'uniqueid' => new external_value(PARAM_TEXT, 'unique element id'), + 'progress' => new external_value(PARAM_FLOAT, 'percentage progress'), + 'estimated' => new external_value(PARAM_RAW, 'estimated time left string'), + 'message' => new external_value(PARAM_TEXT, 'message to be displayed with the bar'), + 'error' => new external_value(PARAM_TEXT, 'error', VALUE_OPTIONAL), + 'timeout' => new external_value(PARAM_TEXT, 'timeout to use in the polling', VALUE_OPTIONAL), + ]) + ); + } + + /** + * Poll the database for the progress of stored progress objects + * + * @param array $ids + * @return array + */ + public static function execute(array $ids) { + global $CFG, $DB; + + $params = self::validate_parameters(self::execute_parameters(), [ + 'ids' => $ids, + ]); + + $return = []; + + foreach ($ids as $id) { + + // Load the stored progress bar object. + $bar = \core\output\stored_progress_bar::get_by_id($id); + if ($bar) { + + // Return the updated bar data. + $return[$id] = [ + 'id' => $id, + 'uniqueid' => $bar->get_id(), + 'progress' => $bar->get_percent(), + 'estimated' => $bar->get_estimate_message($bar->get_percent()), + 'message' => $bar->get_message(), + 'timeout' => \core\output\stored_progress_bar::get_timeout(), + 'error' => $bar->get_haserrored(), + ]; + + } else { + + // If we could not find the record, we still need to return the right arguments in the array for the webservice. + $return[$id] = [ + 'id' => $id, + 'uniqueid' => '', + 'progress' => 0, + 'estimated' => '', + 'message' => get_string('invalidrecordunknown', 'error'), + 'timeout' => \core\output\stored_progress_bar::get_timeout(), + 'error' => true, + ]; + + } + + } + + return $return; + } + +} diff --git a/lib/classes/output/core_renderer.php b/lib/classes/output/core_renderer.php index 27d63344b7b..d52a9884110 100644 --- a/lib/classes/output/core_renderer.php +++ b/lib/classes/output/core_renderer.php @@ -4697,15 +4697,12 @@ EOD; * @param float $percent * @param string $msg Message * @param string $estimate time remaining message + * @param bool $error Was there an error? * @return string ascii fragment */ - public function render_progress_bar_update(string $id, float $percent, string $msg, string $estimate): string { - return html_writer::script(js_writer::function_call('updateProgressBar', [ - $id, - round($percent, 1), - $msg, - $estimate, - ])); + public function render_progress_bar_update(string $id, float $percent, string $msg, string $estimate, + bool $error = false): string { + return html_writer::script(js_writer::function_call('updateProgressBar', [$id, $percent, $msg, $estimate, $error])); } /** diff --git a/lib/classes/output/core_renderer_cli.php b/lib/classes/output/core_renderer_cli.php index d277966bd2a..0e8f2072274 100644 --- a/lib/classes/output/core_renderer_cli.php +++ b/lib/classes/output/core_renderer_cli.php @@ -116,9 +116,11 @@ class core_renderer_cli extends core_renderer { * @param float $percent * @param string $msg Message * @param string $estimate time remaining message + * @param bool $error (Unused in cli) * @return string ascii fragment */ - public function render_progress_bar_update(string $id, float $percent, string $msg, string $estimate): string { + public function render_progress_bar_update(string $id, float $percent, string $msg, string $estimate, + bool $error = false): string { $size = 55; // The width of the progress bar in chars. $ascii = ''; diff --git a/lib/classes/output/progress_bar.php b/lib/classes/output/progress_bar.php index 93cb60545e0..6c26a14676e 100644 --- a/lib/classes/output/progress_bar.php +++ b/lib/classes/output/progress_bar.php @@ -34,35 +34,49 @@ use core\exception\coding_exception; * @category output */ class progress_bar implements renderable, templatable { - /** @var string html id */ - private $htmlid; + + /** @var bool Can use output buffering. */ + protected static $supportsoutputbuffering = false; + + /** @var string unique id */ + protected $idnumber; + /** @var int total width */ - private $width; + protected $width; + /** @var int last percentage printed */ - private $percent = 0; + protected $percent = 0; + /** @var int time when last printed */ - private $lastupdate = 0; + protected $lastupdate = 0; + /** @var int when did we start printing this */ - private $timestart = 0; + protected $timestart = 0; + + /** @var bool Whether or not to auto render updates to the screen */ + protected $autoupdate = true; + + /** @var bool Whether or not an error has occured */ + protected $haserrored = false; /** * Constructor * * Prints JS code if $autostart true. * - * @param string $htmlid The container ID. + * @param string $htmlid The unique ID for the progress bar or HTML container id. * @param int $width The suggested width. * @param bool $autostart Whether to start the progress bar right away. */ public function __construct($htmlid = '', $width = 500, $autostart = false) { - if (!CLI_SCRIPT && !NO_OUTPUT_BUFFERING) { + if (!static::$supportsoutputbuffering && !CLI_SCRIPT && !NO_OUTPUT_BUFFERING) { debugging('progress_bar used in a non-CLI script without setting NO_OUTPUT_BUFFERING.', DEBUG_DEVELOPER); } if (!empty($htmlid)) { - $this->htmlid = $htmlid; + $this->idnumber = $htmlid; } else { - $this->htmlid = 'pbar_' . uniqid(); + $this->idnumber = 'pbar_'.uniqid(); } $this->width = $width; @@ -77,7 +91,15 @@ class progress_bar implements renderable, templatable { * @return string id */ public function get_id(): string { - return $this->htmlid; + return $this->idnumber; + } + + /** + * Get the percent + * @return float + */ + public function get_percent(): float { + return $this->percent; } /** @@ -86,15 +108,43 @@ class progress_bar implements renderable, templatable { * @return void Echo's output */ public function create() { - global $OUTPUT; $this->timestart = microtime(true); + $this->render(); + } + + /** + * Render the progress bar. + * + * @return void + */ + public function render(): void { flush(); - echo $OUTPUT->render($this); + echo $this->get_content(); flush(); } + /** + * Get the content to be rendered + * + * @return string + */ + public function get_content(): string { + global $OUTPUT; + return $OUTPUT->render($this); + } + + /** + * Set whether or not to auto render updates to the screen + * + * @param bool $value + * @return void + */ + public function auto_update(bool $value): void { + $this->autoupdate = $value; + } + /** * Update the progress bar. * @@ -103,7 +153,7 @@ class progress_bar implements renderable, templatable { * @return void Echo's output * @throws coding_exception */ - private function update_raw($percent, $msg) { + protected function update_raw($percent, $msg) { global $OUTPUT; if (empty($this->timestart)) { @@ -113,28 +163,20 @@ class progress_bar implements renderable, templatable { $estimate = $this->estimate($percent); - if ($estimate === null) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedIf - // Always do the first and last updates. - } else if ($estimate == 0) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedIf - // Always do the last updates. - } else if ($this->lastupdate + 20 < time()) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedIf - // We must update otherwise browser would time out. - } else if (round($this->percent, 2) === round($percent, 2)) { - // No significant change, no need to update anything. + // No significant change, no need to update anything. + if (round($this->percent, 2) === round($percent, 2)) { return; } - $estimatemsg = ''; - if ($estimate != 0 && is_numeric($estimate)) { - // Err on the conservative side and also avoid showing 'now' as the estimate. - $estimatemsg = format_time(ceil($estimate)); - } + $estimatemsg = $this->get_estimate_message($percent); $this->percent = $percent; $this->lastupdate = microtime(true); - echo $OUTPUT->render_progress_bar_update($this->htmlid, $this->percent, $msg, $estimatemsg); - flush(); + if ($this->autoupdate) { + echo $OUTPUT->render_progress_bar_update($this->idnumber, sprintf("%.1f", $this->percent), $msg, $estimatemsg); + flush(); + } } /** @@ -143,7 +185,7 @@ class progress_bar implements renderable, templatable { * @param int $pt From 1-100. * @return mixed Null (unknown), or int. */ - private function estimate($pt) { + protected function estimate($pt) { if ($this->lastupdate == 0) { return null; } @@ -201,10 +243,68 @@ class progress_bar implements renderable, templatable { */ public function export_for_template(renderer_base $output) { return [ - 'id' => $this->htmlid, + 'id' => '', + 'idnumber' => $this->idnumber, 'width' => $this->width, + 'class' => '', + 'value' => 0, + 'error' => 0, ]; } + + /** + * This gets the estimate message to be displayed with the progress bar. + * + * @param float $percent + * @return string + */ + public function get_estimate_message(float $percent): string { + $estimate = $this->estimate($percent); + $estimatemsg = ''; + if ($estimate != 0 && is_numeric($estimate)) { + $estimatemsg = format_time(ceil($estimate)); + } + + return $estimatemsg; + } + + /** + * Set the error flag on the object + * + * @param bool $value + * @return void + */ + protected function set_haserrored(bool $value): void { + $this->haserrored = $value; + } + + /** + * Check if the process has errored + * + * @return bool + */ + public function get_haserrored(): bool { + return $this->haserrored; + } + + /** + * Set that the process running has errored + * + * @param string $errormsg + * @return void + */ + public function error(string $errormsg): void { + global $OUTPUT; + + $this->haserrored = true; + $this->message = $errormsg; + + if ($this->autoupdate) { + echo $OUTPUT->render_progress_bar_update($this->idnumber, sprintf("%.1f", $this->percent), $errormsg, '', true); + flush(); + } + } + } // Alias this class to the old name. diff --git a/lib/classes/output/stored_progress_bar.php b/lib/classes/output/stored_progress_bar.php new file mode 100644 index 00000000000..37d5be6a007 --- /dev/null +++ b/lib/classes/output/stored_progress_bar.php @@ -0,0 +1,365 @@ +. + +namespace core\output; + +/** + * Stored progress bar class. + * + * @package core + * @copyright 2023 onwards Catalyst IT {@link http://www.catalyst-eu.net/} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @author Conn Warwicker + */ +class stored_progress_bar extends progress_bar { + + /** @var bool Can use output buffering. */ + protected static $supportsoutputbuffering = true; + + /** @var int DB record ID */ + protected $recordid; + + /** @var string|null Message to associate with bar */ + protected $message = null; + + /** @var \core\clock Clock object */ + protected $clock; + + /** + * This overwrites the progress_bar::__construct method. + * + * @param string $idnumber + */ + public function __construct($idnumber) { + + $this->clock = \core\di::get(\core\clock::class); + + // Construct from the parent. + parent::__construct($idnumber, 0, true); + + } + + /** + * Just set the timestart, do not render the bar immediately. + * + * @return void + */ + public function create(): void { + $this->timestart = $this->clock->time(); + } + + /** + * Load the stored progress bar from the database based on its uniqued idnumber + * + * @param string $idnumber Unique ID of the bar + * @return stored_progress_bar|null + */ + public static function get_by_idnumber(string $idnumber): ?stored_progress_bar { + global $DB; + + $record = $DB->get_record('stored_progress', ['idnumber' => $idnumber]); + if ($record) { + return self::load($record); + } else { + return null; + } + } + + /** + * Load the stored progress bar from the database, based on it's record ID + * + * @param int $id Database record ID + * @return stored_progress_bar|null + */ + public static function get_by_id(int $id): ?stored_progress_bar { + global $DB; + + $record = $DB->get_record('stored_progress', ['id' => $id]); + if ($record) { + return self::load($record); + } else { + return null; + } + } + + /** + * Load the stored progress bar object from its record in the database. + * + * @param stdClass $record + * @return stored_progress_bar + */ + public static function load(\stdClass $record): stored_progress_bar { + $progress = new stored_progress_bar($record->idnumber); + $progress->set_record_id($record->id); + $progress->set_time_started($record->timestart); + $progress->set_last_updated($record->lastupdate); + $progress->set_percent($record->percentcompleted); + $progress->set_message($record->message); + $progress->set_haserrored($record->haserrored); + return $progress; + } + + /** + * Set the DB record ID + * + * @param int $id + * @return void + */ + protected function set_record_id(int $id): void { + $this->recordid = $id; + } + + /** + * Set the time we started the process. + * + * @param int $value + * @return void + */ + protected function set_time_started(int $value): void { + $this->timestart = $value; + } + + /** + * Set the time we started last updated the progress. + * + * @param int|null $value + * @return void + */ + protected function set_last_updated(?int $value = null): void { + $this->lastupdate = $value; + } + + /** + * Set the percent completed. + * + * @param float|null $value + * @return void + */ + protected function set_percent($value = null): void { + $this->percent = $value; + } + + /** + * Set the message. + * + * @param string|null $value + * @return void + */ + protected function set_message(?string $value = null): void { + $this->message = $value; + } + + /** + * Set that the process running has errored and store that against the bar + * + * @param string $errormsg + * @return void + */ + public function error(string $errormsg): void { + // Update the error variables. + parent::error($errormsg); + + // Update the record. + $this->update_record(); + } + + /** + * Get the progress bar message. + * + * @return string|null + */ + public function get_message(): ?string { + return $this->message; + } + + /** + * Get the content to display the progress bar and start polling via AJAX + * + * @return string + */ + public function get_content(): string { + global $CFG, $PAGE, $OUTPUT; + + $PAGE->requires->js_call_amd('core/stored_progress', 'init', [ + self::get_timeout(), + ]); + + $context = $this->export_for_template($OUTPUT); + return $OUTPUT->render_from_template('core/progress_bar', $context); + } + + /** + * Export for template. + * + * @param renderer_base $output The renderer. + * @return array + */ + public function export_for_template(\renderer_base $output): array { + return [ + 'id' => $this->recordid, + 'idnumber' => $this->idnumber, + 'width' => $this->width, + 'class' => 'stored-progress-bar', + 'value' => $this->percent, + 'message' => $this->message, + 'error' => $this->haserrored, + ]; + } + + /** + * Start the recording of the progress and store in the database + * + * @return int ID of the DB record + */ + public function start(): int { + global $OUTPUT, $DB; + + // If we are running in an non-interactive CLI environment, call the progress bar renderer to avoid warnings + // when we do an update. + if (defined('STDOUT') && !stream_isatty(STDOUT)) { + $OUTPUT->render_progress_bar($this); + } + + // Delete any existing records for this. + $this->clear_records(); + + // Create new progress record. + $this->recordid = $DB->insert_record('stored_progress', [ + 'idnumber' => $this->idnumber, + 'timestart' => (int)$this->timestart, + ]); + + return $this->recordid; + } + + /** + * End the polling progress and delete the DB record. + * + * @return void + */ + protected function clear_records(): void { + global $DB; + + $DB->delete_records('stored_progress', [ + 'idnumber' => $this->idnumber, + ]); + } + + /** + * Update the database record with the percentage and message + * + * @param float $percent + * @param string $msg + * @return void + */ + protected function update_raw($percent, $msg): void { + $this->percent = $percent; + $this->message = $msg; + + // Update the database record with the new data. + $this->update_record(); + + // Update any CLI script's progress with an ASCII progress bar. + $this->render_update(); + } + + /** + * Render an update to the CLI + * + * This will only work in CLI scripts, and not in scheduled/adhoc tasks even though they run via CLI, + * as they seem to use a different renderer (core_renderer instead of core_renderer_cli). + * + * We also can't check this based on "CLI_SCRIPT" const as that is true for tasks. + * + * So this will just check a flag to see if we want auto rendering of updates. + * + * @return void + */ + protected function render_update(): void { + global $OUTPUT; + + // If no output buffering, don't render it at all. + if (defined('NO_OUTPUT_BUFFERING') && NO_OUTPUT_BUFFERING) { + $this->auto_update(false); + } + + // If we want the screen to auto update, render it. + if ($this->autoupdate) { + echo $OUTPUT->render_progress_bar_update( + $this->idnumber, sprintf("%.1f", $this->percent), $this->message, $this->get_estimate_message($this->percent) + ); + } + } + + /** + * Update the database record + * + * @throws \moodle_exception + * @return void + */ + protected function update_record(): void { + global $DB; + + if (is_null($this->recordid)) { + throw new \moodle_exception('Polling has not been started. Cannot set iteration.'); + } + + // Update time. + $this->lastupdate = $this->clock->time(); + + // Update the database record. + $record = new \stdClass(); + $record->id = $this->recordid; + $record->lastupdate = (int)$this->lastupdate; + $record->percentcompleted = $this->percent; + $record->message = $this->message; + $record->haserrored = $this->haserrored; + $DB->update_record('stored_progress', $record); + } + + /** + * We need a way to specify a unique idnumber for processes being monitored, so that + * firstly we don't accidentally overwrite a running process, and secondly so we can + * automatically load them in some cases, without having to manually code in its name. + * + * So this uses the classname of the object being monitored, along with its id. + * + * This method should be used when creating the stored_progress record to set it's idnumber. + * + * @param string $class Class name of the object being monitored, e.g. \local_something\task\my_task + * @param int|null $id ID of an object from database, e.g. 123 + * @return string Converted string, e.g. local_something_task_my_task_123 + */ + public static function convert_to_idnumber(string $class, ?int $id = null): string { + $idnumber = preg_replace("/[^a-z0-9_]/", "_", ltrim($class, '\\')); + if (!is_null($id)) { + $idnumber .= '_' . $id; + } + + return $idnumber; + } + + /** + * Get the polling timeout in seconds. Default: 5. + * + * @return int + */ + public static function get_timeout(): int { + global $CFG; + return $CFG->progresspollinterval ?? 5; + } + +} diff --git a/lib/classes/task/delete_unconfirmed_users_task.php b/lib/classes/task/delete_unconfirmed_users_task.php index 40dfe8ef1ef..9cc4c416b0f 100644 --- a/lib/classes/task/delete_unconfirmed_users_task.php +++ b/lib/classes/task/delete_unconfirmed_users_task.php @@ -27,6 +27,7 @@ namespace core\task; * Simple task to delete user accounts for users who have not confirmed in time. */ class delete_unconfirmed_users_task extends scheduled_task { + use stored_progress_task_trait; /** * Get a descriptive name for this task (shown to admins). @@ -48,14 +49,23 @@ class delete_unconfirmed_users_task extends scheduled_task { // Delete users who haven't confirmed within required period. if (!empty($CFG->deleteunconfirmed)) { + $this->start_stored_progress(); $cuttime = $timenow - ($CFG->deleteunconfirmed * 3600); - $rs = $DB->get_recordset_sql ("SELECT * - FROM {user} - WHERE confirmed = 0 AND timecreated > 0 - AND timecreated < ? AND deleted = 0", array($cuttime)); + $selectcount = "SELECT COUNT(*)"; + $select = "SELECT *"; + $sql = " + FROM {user} + WHERE confirmed = 0 AND timecreated > 0 + AND timecreated < ? AND deleted = 0"; + $params = [$cuttime]; + $count = $DB->count_records_sql($selectcount . $sql, $params); + $rs = $DB->get_recordset_sql($select . $sql, $params); + $processed = 0; foreach ($rs as $user) { delete_user($user); - mtrace(" Deleted unconfirmed user ".fullname($user, true)." ($user->id)"); + $message = " Deleted unconfirmed user ".fullname($user, true)." ($user->id)"; + $processed++; + $this->progress->update($processed, $count, $message); } $rs->close(); } diff --git a/lib/classes/task/stored_progress_bar_cleanup_task.php b/lib/classes/task/stored_progress_bar_cleanup_task.php new file mode 100644 index 00000000000..14f438cfc35 --- /dev/null +++ b/lib/classes/task/stored_progress_bar_cleanup_task.php @@ -0,0 +1,53 @@ +. + +namespace core\task; + +/** + * Scheduled task to clean up old stored_progress bar records. + * + * @package core + * @copyright 2023 onwards Catalyst IT {@link http://www.catalyst-eu.net/} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @author Conn Warwicker + */ +class stored_progress_bar_cleanup_task extends scheduled_task { + + /** + * Get a descriptive name for this task (shown to admins). + * + * @return string + */ + public function get_name() { + return get_string('storedprogressbarcleanuptask', 'admin'); + } + + /** + * Delete all the old stored progress bar records. + * By default this runs once per day at 1AM. + * + * @return void + */ + public function execute(): void { + global $DB; + + $twentyfourhoursago = time() - DAYSECS; + + $DB->delete_records_select('stored_progress', 'lastupdate < :ago', ['ago' => $twentyfourhoursago]); + + mtrace('Deleted old stored_progress records'); + } +} diff --git a/lib/classes/task/stored_progress_task_trait.php b/lib/classes/task/stored_progress_task_trait.php new file mode 100644 index 00000000000..ae79ccf913e --- /dev/null +++ b/lib/classes/task/stored_progress_task_trait.php @@ -0,0 +1,59 @@ +. + +namespace core\task; + +/** + * Trait to use in tasks to automatically add stored progress functionality. + * + * @package core + * @copyright 2024 onwards Catalyst IT {@link http://www.catalyst-eu.net/} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @author Conn Warwicker + */ +trait stored_progress_task_trait { + + /** @var \core\output\stored_progress_bar|null $progress */ + protected $progress = null; + + /** + * Start a stored progress bar implementation for the task this trait is used in. + * + * @return void + */ + protected function start_stored_progress(): void { + global $OUTPUT, $PAGE; + + // To get around the issue in MDL-80770, we are manually setting the renderer to cli. + $OUTPUT = $PAGE->get_renderer('core', null, 'cli'); + + // Construct a unique name for the progress bar. + // For adhoc tasks, this will need the ID in it. For scheduled tasks just the class name. + if (method_exists($this, 'get_id')) { + $name = get_class($this) . '_' . $this->get_id(); + } else { + $name = get_class($this); + } + + $this->progress = new \core\output\stored_progress_bar( + \core\output\stored_progress_bar::convert_to_idnumber($name) + ); + + // Start the progress. + $this->progress->start(); + } + +} diff --git a/lib/db/install.xml b/lib/db/install.xml index a2f52c5a996..a88720e8a97 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -4842,5 +4842,22 @@ + + + + + + + + + + + + + + + + +
diff --git a/lib/db/services.php b/lib/db/services.php index 39766f2707f..a0ec47bf505 100644 --- a/lib/db/services.php +++ b/lib/db/services.php @@ -3223,6 +3223,14 @@ $functions = array( 'type' => 'read', 'ajax' => true, ], + 'core_output_poll_stored_progress' => [ + 'classname' => 'core\external\output\poll_stored_progress', + 'methodname' => 'execute', + 'description' => 'Polls for the current percentage progress of a stored progress object', + 'type' => 'read', + 'ajax' => true, + 'readonlysession' => true, + ], ); $services = array( diff --git a/lib/db/tasks.php b/lib/db/tasks.php index 0a9548e4b45..2ef837ca2ad 100644 --- a/lib/db/tasks.php +++ b/lib/db/tasks.php @@ -476,4 +476,14 @@ $tasks = array( 'dayofweek' => 'R', 'disabled' => true, ], + [ + 'classname' => 'core\task\stored_progress_bar_cleanup_task', + 'blocking' => 0, + 'minute' => '00', + 'hour' => '01', + 'day' => '*', + 'dayofweek' => '*', + 'month' => '*', + 'disabled' => false, + ], ); diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index c5e13751ca3..56bfed471a5 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -1178,5 +1178,33 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2024070500.01); } + if ($oldversion < 2024071900.01) { + // Define table stored_progress to be created. + $table = new xmldb_table('stored_progress'); + + // Adding fields to table stored_progress. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('idnumber', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null); + $table->add_field('timestart', XMLDB_TYPE_INTEGER, '20', null, null, null, null); + $table->add_field('lastupdate', XMLDB_TYPE_INTEGER, '20', null, null, null, null); + $table->add_field('percentcompleted', XMLDB_TYPE_NUMBER, '5, 2', null, null, null, '0'); + $table->add_field('message', XMLDB_TYPE_CHAR, '255', null, null, null, null); + $table->add_field('haserrored', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '0'); + + // Adding keys to table stored_progress. + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + + // Adding indexes to table stored_progress. + $table->add_index('uid_index', XMLDB_INDEX_NOTUNIQUE, ['idnumber']); + + // Conditionally launch create table for stored_progress. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2024071900.01); + } + return true; } diff --git a/lib/javascript-static.js b/lib/javascript-static.js index 9782cfa4996..a5bf70c16d1 100644 --- a/lib/javascript-static.js +++ b/lib/javascript-static.js @@ -1164,7 +1164,8 @@ function stripHTML(str) { throw new Error('stripHTML can not be used any more. Please use jQuery instead.'); } -function updateProgressBar(id, percent, msg, estimate) { +// eslint-disable-next-line no-unused-vars +function updateProgressBar(id, percent, msg, estimate, error) { var event, el = document.getElementById(id), eventData = {}; @@ -1176,6 +1177,7 @@ function updateProgressBar(id, percent, msg, estimate) { eventData.message = msg; eventData.percent = percent; eventData.estimate = estimate; + eventData.error = error; try { event = new CustomEvent('update', { diff --git a/lib/templates/progress_bar.mustache b/lib/templates/progress_bar.mustache index 82f9cfcb38d..acdd7d13d7d 100644 --- a/lib/templates/progress_bar.mustache +++ b/lib/templates/progress_bar.mustache @@ -25,17 +25,17 @@ "width": "500" } }} -
+
-
+
-
 
+
 
-   - 0% +   + {{value}}%
@@ -43,21 +43,33 @@ {{! We must not use the JS helper otherwise this gets executed too late. }}