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 00000000000..2e068914025
--- /dev/null
+++ b/lib/amd/build/stored_progress.min.js
@@ -0,0 +1,11 @@
+define("core/stored_progress",["exports","core/ajax","core/notification"],(function(_exports,Ajax,_notification){var obj;function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,Ajax=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}
+/**
+ * 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
+ */(Ajax),_notification=(obj=_notification)&&obj.__esModule?obj:{default:obj};var STORED_PROGRESS_LOADED=!1;function poll(ids,timeout){let promise=Ajax.call([{methodname:"core_output_poll_stored_progress",args:{ids:ids}}]),repollids=[];promise[0].then((function(results){return results.forEach((function(data){updateProgressBar(data.uniqueid,data.progress,data.message,data.estimated,data.error),data.progress<100&&!data.error&&repollids.push(data.id),data.timeout&&data.timeout>0&&(timeout=data.timeout)})),repollids.length>0&&setTimeout((()=>poll(repollids,timeout)),1e3*timeout)})).catch(_notification.default.exception)}_exports.init=timeout=>{if(!1===STORED_PROGRESS_LOADED){let ids=[];document.querySelectorAll(".stored-progress-bar").forEach((el=>{let id=el.dataset.recordid;ids.push(id)})),poll(ids,timeout),STORED_PROGRESS_LOADED=!0}}}));
+
+//# sourceMappingURL=stored_progress.min.js.map
\ No newline at end of file
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 00000000000..46068db0a43
--- /dev/null
+++ b/lib/amd/build/stored_progress.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"stored_progress.min.js","sources":["../src/stored_progress.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Script to update stored_progress progress bars on the screen.\n *\n * @module core/stored_progress\n * @copyright 2023 onwards Catalyst IT {@link http://www.catalyst-eu.net/}\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @author Conn Warwicker \n */\n\n/* global updateProgressBar */\n\nimport * as Ajax from 'core/ajax';\nimport Notification from 'core/notification';\n\n/**\n * @var bool This AMD script is loaded multiple times, for each progress bar on a page.\n * So this stops it running multiple times.\n * */\nvar STORED_PROGRESS_LOADED = false;\n\n/**\n * Poll a given stored progress record.\n *\n * @param {array} ids\n * @param {integer} timeout\n */\nfunction poll(ids, timeout) {\n\n // Call AJAX request.\n let promise = Ajax.call([{\n methodname: 'core_output_poll_stored_progress', args: {'ids': ids}\n }]);\n\n let repollids = [];\n\n // When AJAX request returns, handle the results.\n promise[0].then(function(results) {\n\n results.forEach(function(data) {\n\n // Update the progress bar percentage and message using the core method from the javascript-static.js.\n updateProgressBar(data.uniqueid, data.progress, data.message, data.estimated, data.error);\n\n // Add the bar for re-polling if it's not completed.\n if (data.progress < 100 && !data.error) {\n repollids.push(data.id);\n }\n\n // If a different timeout came back from the script, use that instead.\n if (data.timeout && data.timeout > 0) {\n timeout = data.timeout;\n }\n\n });\n\n // If we still want to poll any of them, do it again.\n if (repollids.length > 0) {\n return setTimeout(() => poll(repollids, timeout), timeout * 1000);\n }\n\n return false;\n\n }).catch(Notification.exception);\n\n}\n\n/**\n * Initialise the polling process.\n *\n * @param {integer} timeout Timeout to use (seconds).\n */\nexport const init = (timeout) => {\n\n if (STORED_PROGRESS_LOADED === false) {\n\n let ids = [];\n\n // Find any stored progress bars we want to poll.\n document.querySelectorAll('.stored-progress-bar').forEach(el => {\n\n // Get its id and add to array.\n let id = el.dataset.recordid;\n ids.push(id);\n\n });\n\n // Poll for updates from these IDs.\n poll(ids, timeout);\n\n // Script has run, we don't want it to run again.\n STORED_PROGRESS_LOADED = true;\n\n }\n\n};"],"names":["STORED_PROGRESS_LOADED","poll","ids","timeout","promise","Ajax","call","methodname","args","repollids","then","results","forEach","data","updateProgressBar","uniqueid","progress","message","estimated","error","push","id","length","setTimeout","catch","Notification","exception","document","querySelectorAll","el","dataset","recordid"],"mappings":";;;;;;;;oFAiCIA,wBAAyB,WAQpBC,KAAKC,IAAKC,aAGXC,QAAUC,KAAKC,KAAK,CAAC,CACrBC,WAAY,mCAAoCC,KAAM,KAAQN,QAG9DO,UAAY,GAGhBL,QAAQ,GAAGM,MAAK,SAASC,gBAErBA,QAAQC,SAAQ,SAASC,MAGrBC,kBAAkBD,KAAKE,SAAUF,KAAKG,SAAUH,KAAKI,QAASJ,KAAKK,UAAWL,KAAKM,OAG/EN,KAAKG,SAAW,MAAQH,KAAKM,OAC7BV,UAAUW,KAAKP,KAAKQ,IAIpBR,KAAKV,SAAWU,KAAKV,QAAU,IAC/BA,QAAUU,KAAKV,YAMnBM,UAAUa,OAAS,GACZC,YAAW,IAAMtB,KAAKQ,UAAWN,UAAoB,IAAVA,YAKvDqB,MAAMC,sBAAaC,yBASLvB,cAEc,IAA3BH,uBAAkC,KAE9BE,IAAM,GAGVyB,SAASC,iBAAiB,wBAAwBhB,SAAQiB,SAGlDR,GAAKQ,GAAGC,QAAQC,SACpB7B,IAAIkB,KAAKC,OAKbpB,KAAKC,IAAKC,SAGVH,wBAAyB"}
\ No newline at end of file
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 @@
+