This commit is contained in:
Huong Nguyen
2025-09-05 08:41:04 +07:00
31 changed files with 963 additions and 414 deletions
@@ -0,0 +1,19 @@
issueNumber: MDL-86066
notes:
core_reportbuilder:
- message: >
The following methods from the `schedule` helper class have been
deprecated, in favour of usage of the new schedule type system:
* `create_schedule`
* `get_report_empty_options`
* `send_schedule_message`
type: deprecated
- message: >-
Report schedule types are now extendable by third-party plugins by
extending the `core_reportbuilder\local\schedules\base` class in your
component namespace: `<component>\reportbuilder\schedule\<type>`
type: improved
+3
View File
@@ -89,3 +89,6 @@ resources_help,core
unabletomessage,core_message
notificationimage,core_message
hiddensections_help,core
privacy:metadata:schedule:message,core_reportbuilder
privacy:metadata:schedule:reportempty,core_reportbuilder
privacy:metadata:schedule:subject,core_reportbuilder
+9 -3
View File
@@ -214,13 +214,12 @@ $string['privacy:metadata:report:usercreated'] = 'The ID of the user who created
$string['privacy:metadata:report:usermodified'] = 'The ID of the user who last modified the report';
$string['privacy:metadata:schedule'] = 'Report schedule definitions';
$string['privacy:metadata:schedule:audiences'] = 'The audiences this schedule is for';
$string['privacy:metadata:schedule:classname'] = 'The class used by the schedule';
$string['privacy:metadata:schedule:configdata'] = 'Configuration data used by the schedule';
$string['privacy:metadata:schedule:enabled'] = 'The status of the schedule';
$string['privacy:metadata:schedule:format'] = 'The format of the scheduled report';
$string['privacy:metadata:schedule:message'] = 'The message of the schedule';
$string['privacy:metadata:schedule:name'] = 'The name of the schedule';
$string['privacy:metadata:schedule:recurrence'] = 'The recurrence of the schedule';
$string['privacy:metadata:schedule:reportempty'] = 'Action to take if scheduled report is empty';
$string['privacy:metadata:schedule:subject'] = 'The subject of the schedule';
$string['privacy:metadata:schedule:timecreated'] = 'The time when the schedule was created';
$string['privacy:metadata:schedule:timemodified'] = 'The time when the schedule was last modified';
$string['privacy:metadata:schedule:timescheduled'] = 'The time the schedule will begin';
@@ -255,6 +254,8 @@ $string['resetconditions'] = 'Reset conditions';
$string['resetconditionsconfirm'] = 'Are you sure you want to reset all conditions for this report?';
$string['schedulecreated'] = 'Schedule created';
$string['scheduledeleted'] = 'Schedule deleted';
$string['scheduleemail'] = 'Schedule an email';
$string['scheduleemaildescription'] = 'Set up a recurring email to share this report with your chosen audience';
$string['scheduleempty'] = 'If the report is empty';
$string['scheduleemptydontsend'] = 'Don\'t send message';
$string['scheduleemptysendwithattachment'] = 'Send message with empty report';
@@ -302,3 +303,8 @@ $string['filterdateto'] = 'Date to';
// Deprecated since Moodle 5.0.
$string['privacy:metadata:preference:reportfilter'] = 'Stored report filter values';
$string['userpicture'] = 'User picture';
// Deprecated since Moodle 5.1.
$string['privacy:metadata:schedule:message'] = 'The message of the schedule';
$string['privacy:metadata:schedule:reportempty'] = 'Action to take if scheduled report is empty';
$string['privacy:metadata:schedule:subject'] = 'The subject of the schedule';
+3 -5
View File
@@ -4669,15 +4669,13 @@
<FIELD NAME="reportid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="name" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="enabled" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="1" SEQUENCE="false"/>
<FIELD NAME="audiences" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="audiences" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="classname" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="configdata" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="format" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="subject" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="message" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="messageformat" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="userviewas" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="timescheduled" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="recurrence" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="reportempty" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="timelastsent" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="timenextsend" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="usercreated" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+65
View File
@@ -2125,5 +2125,70 @@ function xmldb_main_upgrade($oldversion) {
upgrade_main_savepoint(true, 2025090200.01);
}
if ($oldversion < 2025090200.02) {
$table = new xmldb_table('reportbuilder_schedule');
// Conditionally launch add field classname.
$field = new xmldb_field('classname', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null, 'enabled');
if (!$dbman->field_exists($table, $field)) {
$dbman->add_field($table, $field);
}
// Conditionally launch add field configdata.
$field = new xmldb_field('configdata', XMLDB_TYPE_TEXT, null, null, null, null, null, 'classname');
if (!$dbman->field_exists($table, $field)) {
$dbman->add_field($table, $field);
}
// Migrate existing data to new structure.
$schedules = $DB->get_records('reportbuilder_schedule');
foreach ($schedules as $schedule) {
$DB->update_record('reportbuilder_schedule', [
'id' => $schedule->id,
'classname' => core_reportbuilder\reportbuilder\schedule\message::class,
'configdata' => json_encode([
'subject' => $schedule->subject,
'message' => ['text' => $schedule->message, 'format' => $schedule->messageformat],
'reportempty' => $schedule->reportempty,
]),
]);
}
// Launch change of nullability for field configdata (after migrating data).
$field = new xmldb_field('configdata', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null, 'classname');
$dbman->change_field_notnull($table, $field);
// Launch change of nullability for field audiences.
$field = new xmldb_field('audiences', XMLDB_TYPE_TEXT, null, null, null, null, null, 'enabled');
$dbman->change_field_notnull($table, $field);
// Conditionally launch drop field subject.
$field = new xmldb_field('subject');
if ($dbman->field_exists($table, $field)) {
$dbman->drop_field($table, $field);
}
// Conditionally launch drop field message.
$field = new xmldb_field('message');
if ($dbman->field_exists($table, $field)) {
$dbman->drop_field($table, $field);
}
// Conditionally launch drop field messageformat.
$field = new xmldb_field('messageformat');
if ($dbman->field_exists($table, $field)) {
$dbman->drop_field($table, $field);
}
// Conditionally launch drop field reportempty.
$field = new xmldb_field('reportempty');
if ($dbman->field_exists($table, $field)) {
$dbman->drop_field($table, $field);
}
// Main savepoint reached.
upgrade_main_savepoint(true, 2025090200.02);
}
return true;
}
@@ -5,6 +5,6 @@ define("core_reportbuilder/local/repository/modals",["exports","core_form/modalf
* @module core_reportbuilder/local/repository/modals
* @copyright 2021 David Matamoros <davidmc@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.createScheduleModal=_exports.createReportModal=_exports.createDuplicateReportModal=void 0,_modalform=(obj=_modalform)&&obj.__esModule?obj:{default:obj};const createModalForm=(triggerElement,modalTitle,formClass,formArgs)=>new _modalform.default({modalConfig:{title:modalTitle},formClass:formClass,args:formArgs,saveButtonText:(0,_str.getString)("save","moodle"),returnFocus:triggerElement});_exports.createReportModal=function(triggerElement,modalTitle){let reportId=arguments.length>2&&void 0!==arguments[2]?arguments[2]:0;return createModalForm(triggerElement,modalTitle,"core_reportbuilder\\form\\report",{id:reportId})};_exports.createDuplicateReportModal=(triggerElement,modalTitle,reportId,reportName)=>createModalForm(triggerElement,modalTitle,"core_reportbuilder\\form\\duplicate_report",{id:reportId,name:reportName});_exports.createScheduleModal=function(triggerElement,modalTitle,reportId){let scheduleId=arguments.length>3&&void 0!==arguments[3]?arguments[3]:0;return createModalForm(triggerElement,modalTitle,"core_reportbuilder\\form\\schedule",{reportid:reportId,id:scheduleId})}}));
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.createScheduleModal=_exports.createReportModal=_exports.createDuplicateReportModal=void 0,_modalform=(obj=_modalform)&&obj.__esModule?obj:{default:obj};const createModalForm=(triggerElement,modalTitle,formClass,formArgs)=>new _modalform.default({modalConfig:{title:modalTitle},formClass:formClass,args:formArgs,saveButtonText:(0,_str.getString)("save","moodle"),returnFocus:triggerElement});_exports.createReportModal=function(triggerElement,modalTitle){let reportId=arguments.length>2&&void 0!==arguments[2]?arguments[2]:0;return createModalForm(triggerElement,modalTitle,"core_reportbuilder\\form\\report",{id:reportId})};_exports.createDuplicateReportModal=(triggerElement,modalTitle,reportId,reportName)=>createModalForm(triggerElement,modalTitle,"core_reportbuilder\\form\\duplicate_report",{id:reportId,name:reportName});_exports.createScheduleModal=function(triggerElement,modalTitle,reportId){let scheduleId=arguments.length>3&&void 0!==arguments[3]?arguments[3]:0,scheduleClass=arguments.length>4&&void 0!==arguments[4]?arguments[4]:"";return createModalForm(triggerElement,modalTitle,"core_reportbuilder\\form\\schedule",{reportid:reportId,id:scheduleId,classname:scheduleClass})}}));
//# sourceMappingURL=modals.min.js.map
@@ -1 +1 @@
{"version":3,"file":"modals.min.js","sources":["../../../src/local/repository/modals.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 <http://www.gnu.org/licenses/>.\n\n/**\n * Module to handle modal form requests\n *\n * @module core_reportbuilder/local/repository/modals\n * @copyright 2021 David Matamoros <[email protected]>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport ModalForm from 'core_form/modalform';\nimport {getString} from 'core/str';\n\n/**\n * Return modal instance\n *\n * @param {EventTarget} triggerElement\n * @param {Promise} modalTitle\n * @param {String} formClass\n * @param {Object} formArgs\n * @return {ModalForm}\n */\nconst createModalForm = (triggerElement, modalTitle, formClass, formArgs) => {\n return new ModalForm({\n modalConfig: {\n title: modalTitle,\n },\n formClass: formClass,\n args: formArgs,\n saveButtonText: getString('save', 'moodle'),\n returnFocus: triggerElement,\n });\n};\n\n/**\n * Return report modal instance\n *\n * @param {EventTarget} triggerElement\n * @param {Promise} modalTitle\n * @param {Number} reportId\n * @return {ModalForm}\n */\nexport const createReportModal = (triggerElement, modalTitle, reportId = 0) => {\n return createModalForm(triggerElement, modalTitle, 'core_reportbuilder\\\\form\\\\report', {\n id: reportId,\n });\n};\n\n/**\n * Return duplicate report modal instance\n *\n * @param {EventTarget} triggerElement\n * @param {Promise} modalTitle\n * @param {Number} reportId\n * @param {String} reportName\n * @return {ModalForm}\n */\nexport const createDuplicateReportModal = (triggerElement, modalTitle, reportId, reportName) => {\n return createModalForm(triggerElement, modalTitle, 'core_reportbuilder\\\\form\\\\duplicate_report', {\n id: reportId,\n name: reportName,\n });\n};\n\n/**\n * Return schedule modal instance\n *\n * @param {EventTarget} triggerElement\n * @param {Promise} modalTitle\n * @param {Number} reportId\n * @param {Number} scheduleId\n * @return {ModalForm}\n */\nexport const createScheduleModal = (triggerElement, modalTitle, reportId, scheduleId = 0) => {\n return createModalForm(triggerElement, modalTitle, 'core_reportbuilder\\\\form\\\\schedule', {\n reportid: reportId,\n id: scheduleId,\n });\n};\n"],"names":["createModalForm","triggerElement","modalTitle","formClass","formArgs","ModalForm","modalConfig","title","args","saveButtonText","returnFocus","reportId","id","reportName","name","scheduleId","reportid"],"mappings":";;;;;;;oOAmCMA,gBAAkB,CAACC,eAAgBC,WAAYC,UAAWC,WACrD,IAAIC,mBAAU,CACjBC,YAAa,CACTC,MAAOL,YAEXC,UAAWA,UACXK,KAAMJ,SACNK,gBAAgB,kBAAU,OAAQ,UAClCC,YAAaT,4CAYY,SAACA,eAAgBC,gBAAYS,gEAAW,SAC9DX,gBAAgBC,eAAgBC,WAAY,mCAAoC,CACnFU,GAAID,gDAa8B,CAACV,eAAgBC,WAAYS,SAAUE,aACtEb,gBAAgBC,eAAgBC,WAAY,6CAA8C,CAC7FU,GAAID,SACJG,KAAMD,0CAaqB,SAACZ,eAAgBC,WAAYS,cAAUI,kEAAa,SAC5Ef,gBAAgBC,eAAgBC,WAAY,qCAAsC,CACrFc,SAAUL,SACVC,GAAIG"}
{"version":3,"file":"modals.min.js","sources":["../../../src/local/repository/modals.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 <http://www.gnu.org/licenses/>.\n\n/**\n * Module to handle modal form requests\n *\n * @module core_reportbuilder/local/repository/modals\n * @copyright 2021 David Matamoros <[email protected]>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport ModalForm from 'core_form/modalform';\nimport {getString} from 'core/str';\n\n/**\n * Return modal instance\n *\n * @param {EventTarget} triggerElement\n * @param {Promise} modalTitle\n * @param {String} formClass\n * @param {Object} formArgs\n * @return {ModalForm}\n */\nconst createModalForm = (triggerElement, modalTitle, formClass, formArgs) => {\n return new ModalForm({\n modalConfig: {\n title: modalTitle,\n },\n formClass: formClass,\n args: formArgs,\n saveButtonText: getString('save', 'moodle'),\n returnFocus: triggerElement,\n });\n};\n\n/**\n * Return report modal instance\n *\n * @param {EventTarget} triggerElement\n * @param {Promise} modalTitle\n * @param {Number} reportId\n * @return {ModalForm}\n */\nexport const createReportModal = (triggerElement, modalTitle, reportId = 0) => {\n return createModalForm(triggerElement, modalTitle, 'core_reportbuilder\\\\form\\\\report', {\n id: reportId,\n });\n};\n\n/**\n * Return duplicate report modal instance\n *\n * @param {EventTarget} triggerElement\n * @param {Promise} modalTitle\n * @param {Number} reportId\n * @param {String} reportName\n * @return {ModalForm}\n */\nexport const createDuplicateReportModal = (triggerElement, modalTitle, reportId, reportName) => {\n return createModalForm(triggerElement, modalTitle, 'core_reportbuilder\\\\form\\\\duplicate_report', {\n id: reportId,\n name: reportName,\n });\n};\n\n/**\n * Return schedule modal instance\n *\n * @param {EventTarget} triggerElement\n * @param {Promise} modalTitle\n * @param {Number} reportId\n * @param {Number} scheduleId\n * @param {String} scheduleClass\n * @return {ModalForm}\n */\nexport const createScheduleModal = (triggerElement, modalTitle, reportId, scheduleId = 0, scheduleClass = '') => {\n return createModalForm(triggerElement, modalTitle, 'core_reportbuilder\\\\form\\\\schedule', {\n reportid: reportId,\n id: scheduleId,\n classname: scheduleClass,\n });\n};\n"],"names":["createModalForm","triggerElement","modalTitle","formClass","formArgs","ModalForm","modalConfig","title","args","saveButtonText","returnFocus","reportId","id","reportName","name","scheduleId","scheduleClass","reportid","classname"],"mappings":";;;;;;;oOAmCMA,gBAAkB,CAACC,eAAgBC,WAAYC,UAAWC,WACrD,IAAIC,mBAAU,CACjBC,YAAa,CACTC,MAAOL,YAEXC,UAAWA,UACXK,KAAMJ,SACNK,gBAAgB,kBAAU,OAAQ,UAClCC,YAAaT,4CAYY,SAACA,eAAgBC,gBAAYS,gEAAW,SAC9DX,gBAAgBC,eAAgBC,WAAY,mCAAoC,CACnFU,GAAID,gDAa8B,CAACV,eAAgBC,WAAYS,SAAUE,aACtEb,gBAAgBC,eAAgBC,WAAY,6CAA8C,CAC7FU,GAAID,SACJG,KAAMD,0CAcqB,SAACZ,eAAgBC,WAAYS,cAAUI,kEAAa,EAAGC,qEAAgB,UAC/FhB,gBAAgBC,eAAgBC,WAAY,qCAAsC,CACrFe,SAAUN,SACVC,GAAIG,WACJG,UAAWF"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -82,11 +82,13 @@ export const createDuplicateReportModal = (triggerElement, modalTitle, reportId,
* @param {Promise} modalTitle
* @param {Number} reportId
* @param {Number} scheduleId
* @param {String} scheduleClass
* @return {ModalForm}
*/
export const createScheduleModal = (triggerElement, modalTitle, reportId, scheduleId = 0) => {
export const createScheduleModal = (triggerElement, modalTitle, reportId, scheduleId = 0, scheduleClass = '') => {
return createModalForm(triggerElement, modalTitle, 'core_reportbuilder\\form\\schedule', {
reportid: reportId,
id: scheduleId,
classname: scheduleClass,
});
};
+4 -1
View File
@@ -75,7 +75,10 @@ export const init = reportId => {
if (scheduleCreate) {
event.preventDefault();
const scheduleModal = createScheduleModal(event.target, getString('newschedule', 'core_reportbuilder'), reportId);
const {scheduleClass} = scheduleCreate.dataset;
const scheduleModal = createScheduleModal(event.target, getString('newschedule', 'core_reportbuilder'), reportId, 0,
scheduleClass);
scheduleModal.addEventListener(scheduleModal.events.FORM_SUBMITTED, () => {
getString('schedulecreated', 'core_reportbuilder')
.then(addToast)
+80 -63
View File
@@ -26,10 +26,10 @@ use core\output\notification;
use core_form\dynamic_form;
use core_reportbuilder\manager;
use core_reportbuilder\permission;
use core_reportbuilder\local\helpers\audience;
use core_reportbuilder\local\helpers\schedule as helper;
use core_reportbuilder\local\helpers\{audience, schedule as helper};
use core_reportbuilder\local\models\schedule as model;
use core_reportbuilder\local\report\base;
use core_reportbuilder\local\schedules\base;
use core_reportbuilder\local\report\base as report_base;
/**
* Schedule form
@@ -41,11 +41,30 @@ use core_reportbuilder\local\report\base;
class schedule extends dynamic_form {
/**
* Return instance of the system report using the filter form
* Return schedule instance
*
* @return base
*/
private function get_report(): base {
private function get_schedule(): base {
$reportid = $this->optional_param('reportid', 0, PARAM_INT);
$scheduleid = $this->optional_param('id', 0, PARAM_INT);
if ($scheduleid > 0) {
$schedule = model::get_record(['id' => $scheduleid, 'reportid' => $reportid], MUST_EXIST);
return base::from_persistent($schedule);
} else {
/** @var base $scheduleclass */
$scheduleclass = $this->optional_param('classname', '', PARAM_RAW);
return $scheduleclass::instance();
}
}
/**
* Return instance of the system report using the filter form
*
* @return report_base
*/
private function get_report(): report_base {
$reportid = $this->optional_param('reportid', 0, PARAM_INT);
return manager::get_report_from_id($reportid);
}
@@ -83,6 +102,9 @@ class schedule extends dynamic_form {
$mform->addElement('hidden', 'id');
$mform->setType('id', PARAM_INT);
$mform->addElement('hidden', 'classname');
$mform->setType('id', PARAM_RAW);
// General fields.
$mform->addElement('header', 'headergeneral', get_string('general'));
@@ -121,63 +143,47 @@ class schedule extends dynamic_form {
}
// Audience fields.
$mform->addElement('header', 'headeraudience', get_string('audience', 'core_reportbuilder'));
$mform->setExpanded('headeraudience', true);
$schedule = $this->get_schedule();
if ($schedule->requires_audience()) {
$mform->addElement('header', 'headeraudience', get_string('audience', 'core_reportbuilder'));
$mform->setExpanded('headeraudience', true);
$audiences = audience::get_base_records($this->optional_param('reportid', 0, PARAM_INT));
if (empty($audiences)) {
$notification = new notification(get_string('noaudiences', 'core_reportbuilder'), notification::NOTIFY_INFO, false);
$mform->addElement('static', 'noaudiences', '', $OUTPUT->render($notification));
}
$audiencecheckboxes = [];
foreach ($audiences as $audience) {
$persistent = $audience->get_persistent();
// Check for a custom name, otherwise fall back to default.
if ('' === $audiencelabel = $persistent->get_formatted_heading($context)) {
$audiencelabel = get_string('audiencelabel', 'core_reportbuilder', (object) [
'name' => $audience->get_name(),
'description' => $audience->get_description(),
]);
$audiences = audience::get_base_records($this->optional_param('reportid', 0, PARAM_INT));
if (empty($audiences)) {
$notification = new notification(get_string('noaudiences', 'core_reportbuilder'), notification::NOTIFY_INFO, false);
$mform->addElement('static', 'noaudiences', '', $OUTPUT->render($notification));
}
$audiencecheckboxes[] = $mform->createElement('checkbox', $persistent->get('id'), $audiencelabel);
$audiencecheckboxes = [];
foreach ($audiences as $audience) {
$persistent = $audience->get_persistent();
// Check for a custom name, otherwise fall back to default.
if ('' === $audiencelabel = $persistent->get_formatted_heading($context)) {
$audiencelabel = get_string('audiencelabel', 'core_reportbuilder', (object) [
'name' => $audience->get_name(),
'description' => $audience->get_description(),
]);
}
$audiencecheckboxes[] = $mform->createElement('checkbox', $persistent->get('id'), $audiencelabel);
}
$mform->addElement('group', 'audiences', '', $audiencecheckboxes, html_writer::div('', 'w-100 mb-2'));
}
$mform->addElement('group', 'audiences', '', $audiencecheckboxes, html_writer::div('', 'w-100 mb-2'));
// Message fields.
$mform->addElement('header', 'headermessage', get_string('messagecontent', 'core_reportbuilder'));
$mform->addElement('text', 'subject', get_string('messagesubject', 'core_reportbuilder'));
$mform->setType('subject', PARAM_TEXT);
$mform->addRule('subject', null, 'required', null, 'client');
$mform->addRule('subject', get_string('maximumchars', '', 255), 'maxlength', 255);
$mform->addElement('editor', 'message', get_string('messagebody', 'core_reportbuilder'), null, ['autosave' => false]);
$mform->setType('message', PARAM_RAW);
$mform->addRule('message', null, 'required', null, 'client');
// Advanced.
$mform->addElement('header', 'headeradvanced', get_string('advanced'));
$mform->addElement('select', 'reportempty', get_string('scheduleempty', 'core_reportbuilder'),
helper::get_report_empty_options());
$mform->setType('reportempty', PARAM_INT);
// Load schedule type form definition.
$schedule->definition($mform);
}
/**
* Load form data if we are editing an existing schedule
*/
public function set_data_for_dynamic_submission(): void {
$reportid = $this->optional_param('reportid', 0, PARAM_INT);
$scheduleid = $this->optional_param('id', 0, PARAM_INT);
$schedule = $this->get_schedule();
if ($scheduleid > 0) {
$schedule = model::get_record(['id' => $scheduleid, 'reportid' => $reportid]);
$data = (array) $schedule->to_record();
if ($schedule->get_persistent()->get('id') > 0) {
$data = (array) $schedule->get_persistent()->to_record();
// Pre-process some of the form fields.
if (!in_array($data['userviewas'], [model::REPORT_VIEWAS_CREATOR, model::REPORT_VIEWAS_RECIPIENT])) {
@@ -185,17 +191,18 @@ class schedule extends dynamic_form {
$data['userviewas'] = model::REPORT_VIEWAS_USER;
}
$audiences = json_decode($data['audiences']);
$data['audiences'] = array_fill_keys($audiences, 1);
if ($schedule->requires_audience()) {
$audiences = (array) json_decode((string) $data['audiences']);
$data['audiences'] = array_fill_keys($audiences, 1);
}
$data['message'] = [
'text' => $data['message'],
'format' => $data['messageformat'],
];
// Load schedule type form definition data.
$data['configdata'] = $schedule->get_configdata();
$this->set_data($data);
} else {
$this->set_data(['reportid' => $reportid]);
$reportid = $this->optional_param('reportid', 0, PARAM_INT);
$this->set_data(['reportid' => $reportid, 'classname' => $schedule::class]);
}
}
@@ -220,11 +227,13 @@ class schedule extends dynamic_form {
$errors['user'] = get_string('required');
}
if (empty($data['audiences'])) {
// Load schedule type form validation.
$schedule = $this->get_schedule();
if ($schedule->requires_audience() && empty($data['audiences'])) {
$errors['audiences'] = get_string('required');
}
return $errors;
return array_merge($errors, $schedule->validate($data, $files));
}
/**
@@ -238,13 +247,21 @@ class schedule extends dynamic_form {
$data->userviewas = (int) $data->user;
}
$data->audiences = json_encode(array_keys($data->audiences));
['text' => $data->message, 'format' => $data->messageformat] = $data->message;
$schedule = $this->get_schedule();
if ($data->id) {
if ($schedule->requires_audience()) {
$data->audiences = json_encode(array_keys($data->audiences));
} else {
$data->audiences = null;
}
$data->configdata = json_encode((array) $data->configdata);
if ($schedule->get_persistent()->get('id') > 0) {
helper::update_schedule($data);
} else {
helper::create_schedule($data);
unset($data->id);
$schedule::create($data);
}
}
@@ -264,7 +264,7 @@ class audience {
public static function get_audiences_for_report_schedules(int $reportid): array {
global $DB;
$audiences = $DB->get_fieldset_select(schedule::TABLE, 'audiences', 'reportid = ?', [$reportid]);
$audiences = $DB->get_fieldset_select(schedule::TABLE, 'audiences', 'reportid = ? AND audiences IS NOT NULL', [$reportid]);
// Reduce JSON encoded audience data of each schedule to an array of audience IDs.
$audienceids = array_reduce($audiences, static function(array $carry, string $audience): array {
@@ -171,7 +171,7 @@ class report {
// Map new audience ids with the old ones.
$audiences = array_map(
fn($audienceid) => $audiencemap[$audienceid] ?? 0,
(array) json_decode($schedulerecord->audiences),
(array) json_decode((string) $schedulerecord->audiences),
);
(new schedule(0, $schedulerecord))
@@ -19,7 +19,7 @@ declare(strict_types=1);
namespace core_reportbuilder\local\helpers;
use context_user;
use core\{clock, di};
use core\{component, clock, di};
use core\exception\{coding_exception, invalid_parameter_exception};
use core_user;
use stdClass;
@@ -29,6 +29,7 @@ use core\message\message;
use core\plugininfo\dataformat;
use core_reportbuilder\local\models\audience as audience_model;
use core_reportbuilder\local\models\schedule as model;
use core_reportbuilder\local\schedules\base;
use core_reportbuilder\table\custom_report_table_view;
/**
@@ -39,6 +40,26 @@ use core_reportbuilder\table\custom_report_table_view;
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class schedule {
/**
* Determine whether given class is a valid schedule type
*
* @param string $scheduleclass
* @return bool
*/
public static function valid(string $scheduleclass): bool {
return class_exists($scheduleclass) && is_subclass_of($scheduleclass, base::class);
}
/**
* Return list of all available/valid schedule types
*
* @return base[]
*/
public static function get_schedules(): array {
$classes = component::get_component_classes_in_namespace(null, 'reportbuilder\\schedule');
return array_filter(array_keys($classes), [static::class, 'valid']);
}
/**
* Create report schedule, calculate when it should be next sent
@@ -46,18 +67,18 @@ class schedule {
* @param stdClass $data
* @param int|null $timenow Deprecated since Moodle 4.5 - please use {@see clock} dependency injection
* @return model
*
* @deprecated since Moodle 5.1 - please do not use this function any more, {@see base::create}
*/
#[\core\attribute\deprecated(base::class . '::create', since: '5.1', mdl: 'MDL-86066')]
public static function create_schedule(stdClass $data, ?int $timenow = null): model {
\core\deprecation::emit_deprecation([self::class, __FUNCTION__]);
if ($timenow !== null) {
debugging('Passing $timenow is deprecated, please use \core\clock dependency injection', DEBUG_DEVELOPER);
}
$data->name = trim($data->name);
$schedule = (new model(0, $data));
$schedule->set('timenextsend', self::calculate_next_send_time($schedule));
return $schedule->create();
return base::create($data)->get_persistent();
}
/**
@@ -113,7 +134,7 @@ class schedule {
public static function get_schedule_report_users(model $schedule): array {
global $DB;
$audienceids = (array) json_decode($schedule->get('audiences'));
$audienceids = (array) json_decode((string) $schedule->get('audiences'));
// Retrieve all selected audience records for the schedule.
[$audienceselect, $audienceparams] = $DB->get_in_or_equal($audienceids, SQL_PARAMS_NAMED, 'aid', true, null);
@@ -317,8 +338,13 @@ class schedule {
* @param stdClass $user
* @param stored_file $attachment
* @return bool
*
* @deprecated since Moodle 5.1 - please do not use this function any more
*/
#[\core\attribute\deprecated(reason: 'It is no longer used', since: '5.1', mdl: 'MDL-86066')]
public static function send_schedule_message(model $schedule, stdClass $user, stored_file $attachment): bool {
\core\deprecation::emit_deprecation([self::class, __FUNCTION__]);
$message = new message();
$message->component = 'moodle';
$message->name = 'reportbuilderschedule';
@@ -402,12 +428,17 @@ class schedule {
* Return list of options for when report is empty
*
* @return string[]
*
* @deprecated since Moodle 5.1 - please do not use this function any more
*/
#[\core\attribute\deprecated(reason: 'It is no longer used', since: '5.1', mdl: 'MDL-86066')]
public static function get_report_empty_options(): array {
\core\deprecation::emit_deprecation([self::class, __FUNCTION__]);
return [
model::REPORT_EMPTY_SEND_EMPTY => get_string('scheduleemptysendwithattachment', 'core_reportbuilder'),
model::REPORT_EMPTY_SEND_WITHOUT => get_string('scheduleemptysendwithoutattachment', 'core_reportbuilder'),
model::REPORT_EMPTY_DONT_SEND => get_string('scheduleemptydontsend', 'core_reportbuilder'),
0 => get_string('scheduleemptysendwithattachment', 'core_reportbuilder'),
1 => get_string('scheduleemptysendwithoutattachment', 'core_reportbuilder'),
2 => get_string('scheduleemptydontsend', 'core_reportbuilder'),
];
}
}
@@ -67,15 +67,6 @@ class schedule extends persistent {
/** @var int Annual recurrence */
public const RECURRENCE_ANNUALLY = 5;
/** @var int Send schedule with empty report */
public const REPORT_EMPTY_SEND_EMPTY = 0;
/** @var int Send schedule without report */
public const REPORT_EMPTY_SEND_WITHOUT = 1;
/** @var int Don't send schedule if report is empty */
public const REPORT_EMPTY_DONT_SEND = 2;
/**
* Return the definition of the properties of this model.
*
@@ -95,27 +86,19 @@ class schedule extends persistent {
],
'audiences' => [
'type' => PARAM_RAW,
'default' => '[]',
'null' => NULL_ALLOWED,
'default' => null,
],
'classname' => [
'type' => PARAM_TEXT,
],
'configdata' => [
'type' => PARAM_RAW,
'default' => '{}',
],
'format' => [
'type' => PARAM_PLUGIN,
],
'subject' => [
'type' => PARAM_TEXT,
],
'message' => [
'type' => PARAM_CLEANHTML,
],
'messageformat' => [
'type' => PARAM_INT,
'default' => FORMAT_HTML,
'choices' => [
FORMAT_MOODLE,
FORMAT_HTML,
FORMAT_PLAIN,
FORMAT_MARKDOWN,
],
],
'userviewas' => [
'type' => PARAM_INT,
'default' => self::REPORT_VIEWAS_CREATOR,
@@ -136,15 +119,6 @@ class schedule extends persistent {
self::RECURRENCE_ANNUALLY,
],
],
'reportempty' => [
'type' => PARAM_INT,
'default' => self::REPORT_EMPTY_SEND_EMPTY,
'choices' => [
self::REPORT_EMPTY_SEND_EMPTY,
self::REPORT_EMPTY_SEND_WITHOUT,
self::REPORT_EMPTY_DONT_SEND,
],
],
'timelastsent' => [
'type' => PARAM_INT,
'default' => 0,
@@ -0,0 +1,176 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
declare(strict_types=1);
namespace core_reportbuilder\local\schedules;
use core_reportbuilder\local\helpers\schedule as helper;
use core_reportbuilder\local\models\schedule;
use MoodleQuickForm;
use progress_trace;
use stdClass;
/**
* Schedule base class
*
* @package core_reportbuilder
* @copyright 2025 Paul Holden <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class base {
/**
* Private constructor, please use {@see instance} or {@see from_persistent} methods instead
*
* @param schedule $schedule The persistent object associated with this schedule
*/
private function __construct(
/** @var schedule The persistent object associated with this schedule */
private schedule $schedule,
) {
// Nothing to see here.
}
/**
* Load instance of schedule type
*
* @param int $id
* @param stdClass|null $record
* @return self|null
*/
final public static function instance(int $id = 0, ?stdClass $record = null): ?self {
$schedule = new schedule($id, $record);
return static::from_persistent($schedule);
}
/**
* Load instance of schedule type from persistent
*
* @param schedule $schedule
* @return self|null
*/
final public static function from_persistent(schedule $schedule): ?self {
// Ensure schedule class is always populated.
if (!$classname = $schedule->get('classname')) {
$classname = get_called_class();
$schedule->set('classname', $classname);
}
if (!helper::valid($classname)) {
return null;
}
return new $classname($schedule);
}
/**
* Create instance of schedule type from record
*
* @param stdClass $record
* @return self
*/
final public static function create(stdClass $record): self {
$schedule = new schedule(0, $record);
$schedule->set_many([
'name' => trim($schedule->get('name')),
'timenextsend' => helper::calculate_next_send_time($schedule),
]);
$instance = self::from_persistent($schedule);
$instance->get_persistent()->create();
return $instance;
}
/**
* Name of the schedule
*
* @return string
*/
abstract public function get_name(): string;
/**
* Description of the schedule
*
* @return string
*/
abstract public function get_description(): string;
/**
* If the current user is able to add this schedule type
*
* @return bool
*/
public function user_can_add(): bool {
return true;
}
/**
* Whether the schedule requires audience configuration
*
* @return bool
*/
public function requires_audience(): bool {
return true;
}
/**
* Schedule specific form definition elements
*
* @param MoodleQuickForm $mform
*/
abstract public function definition(MoodleQuickForm $mform): void;
/**
* Validate schedule specific form data
*
* @param array $data
* @param array $files
* @return array
*/
public function validate(array $data, array $files): array {
return [];
}
/**
* Execute the schedule. If {@see requires_audience}, then a list of audience user records will be passed as parameter
*
* Will be called via cron as part of the {@see \core_reportbuilder\task\send_schedule} task
*
* @param stdClass[] $users
* @param progress_trace $trace
*/
abstract public function execute(array $users, progress_trace $trace): void;
/**
* Return schedule persistent
*
* @return schedule
*/
final public function get_persistent(): schedule {
return $this->schedule;
}
/**
* Return decoded schedule config
*
* @return array
*/
final public function get_configdata(): array {
return json_decode($this->schedule->get('configdata'), true);
}
}
@@ -30,6 +30,7 @@ use core_reportbuilder\local\filters\{boolean_select, date, text};
use core_reportbuilder\local\helpers\format;
use core_reportbuilder\local\models\{report, schedule};
use core_reportbuilder\local\report\{action, column, filter};
use core_reportbuilder\local\schedules\base;
use core_reportbuilder\output\schedule_name_editable;
/**
@@ -60,7 +61,7 @@ class report_schedules extends system_report {
$this->add_base_condition_simple('sc.reportid', $this->get_parameter('reportid', 0, PARAM_INT));
// Select fields required for actions, permission checks, and row class callbacks.
$this->add_base_fields('sc.id, sc.name, sc.enabled, rb.contextid');
$this->add_base_fields('sc.id, sc.name, sc.enabled, sc.classname, rb.contextid');
// Join user entity for "User modified" column.
$entityuser = new user();
@@ -310,31 +311,38 @@ class report_schedules extends system_report {
*/
protected function add_actions(): void {
// Edit action.
$this->add_action(new action(
$this->add_action((new action(
new moodle_url('#'),
new pix_icon('t/edit', ''),
['data-action' => 'schedule-edit', 'data-schedule-id' => ':id'],
false,
new lang_string('editscheduledetails', 'core_reportbuilder')
));
))
->add_callback(static function (stdClass $row): bool {
$instance = base::instance(0, $row);
return $instance !== null && $instance->user_can_add();
}));
// Send now action.
$this->add_action((new action(
new moodle_url('#'),
new pix_icon('t/email', ''),
new pix_icon('t/play', ''),
['data-action' => 'schedule-send', 'data-schedule-id' => ':id', 'data-schedule-name' => ':name'],
false,
new lang_string('sendschedule', 'core_reportbuilder')
))
->add_callback(function(stdClass $row): bool {
$instance = base::instance(0, $row);
if ($instance === null || !$instance->user_can_add()) {
return false;
}
// Ensure data name attribute is properly formatted.
$row->name = (new schedule(0, $row))->get_formatted_name(
$row->name = $instance->get_persistent()->get_formatted_name(
context::instance_by_id($row->contextid));
return true;
})
);
}));
// Delete action.
$this->add_action((new action(
@@ -350,13 +358,16 @@ class report_schedules extends system_report {
new lang_string('deleteschedule', 'core_reportbuilder')
))
->add_callback(function(stdClass $row): bool {
$instance = base::instance(0, $row);
if ($instance !== null && !$instance->user_can_add()) {
return false;
}
// Ensure data name attribute is properly formatted.
$row->name = (new schedule(0, $row))->get_formatted_name(
context::instance_by_id($row->contextid));
return true;
})
);
}));
}
}
@@ -19,13 +19,14 @@ declare(strict_types=1);
namespace core_reportbuilder\output\dynamictabs;
use context_system;
use renderer_base;
use core\output\{choicelist, renderer_base};
use core\output\local\dropdown\{dialog, status};
use core\output\dynamic_tabs\base;
use core_reportbuilder\permission;
use core_reportbuilder\system_report_factory;
use core_reportbuilder\local\helpers\schedule;
use core_reportbuilder\local\models\report;
use core_reportbuilder\local\systemreports\report_schedules;
use core_reportbuilder\output\report_action;
/**
* Schedules dynamic tab
@@ -45,12 +46,38 @@ class schedules extends base {
public function export_for_template(renderer_base $output): array {
$report = system_report_factory::create(report_schedules::class, context_system::instance(), '', '', 0,
['reportid' => $this->data['reportid']]);
$report->set_report_action(new report_action(
// Schedule type menu.
$choicelist = new choicelist();
$choicelist->set_allow_empty(true);
// Include those schedule types the user can add.
foreach (schedule::get_schedules() as $schedule) {
$instance = $schedule::instance();
if ($instance->user_can_add()) {
$choicelist->add_option(
$instance::class,
$instance->get_name(),
[
'description' => $instance->get_description(),
'extras' => ['data-action' => 'schedule-create', 'data-schedule-class' => $instance::class],
]
);
}
}
$dialog = new status(
get_string('newschedule', 'core_reportbuilder'),
['class' => 'btn btn-primary ms-auto', 'data-action' => 'schedule-create'],
));
$choicelist,
[
'classes' => 'd-flex justify-content-end mb-2',
'buttonclasses' => 'btn btn-primary dropdown-toggle',
],
);
$dialog->set_dialog_width(dialog::WIDTH['small']);
return [
'menu' => $dialog->export_for_template($output),
'reportid' => $this->data['reportid'],
'report' => $report->output(),
];
@@ -30,6 +30,7 @@ use core_privacy\local\request\userlist;
use core_privacy\local\request\writer;
use core_reportbuilder\manager;
use core_reportbuilder\local\helpers\schedule as schedule_helper;
use core_reportbuilder\local\schedules\base;
use core_reportbuilder\local\models\{audience, column, filter, report, schedule, user_filter};
/**
@@ -95,13 +96,12 @@ class provider implements
'name' => 'privacy:metadata:schedule:name',
'enabled' => 'privacy:metadata:schedule:enabled',
'audiences' => 'privacy:metadata:schedule:audiences',
'classname' => 'privacy:metadata:schedule:classname',
'configdata' => 'privacy:metadata:schedule:configdata',
'format' => 'privacy:metadata:schedule:format',
'subject' => 'privacy:metadata:schedule:subject',
'message' => 'privacy:metadata:schedule:message',
'userviewas' => 'privacy:metadata:schedule:userviewas',
'timescheduled' => 'privacy:metadata:schedule:timescheduled',
'recurrence' => 'privacy:metadata:schedule:recurrence',
'reportempty' => 'privacy:metadata:schedule:reportempty',
'usercreated' => 'privacy:metadata:schedule:usercreated',
'usermodified' => 'privacy:metadata:schedule:usermodified',
'timecreated' => 'privacy:metadata:schedule:timecreated',
@@ -367,31 +367,38 @@ class provider implements
$formatoptions = schedule_helper::get_format_options();
$recurrenceoptions = schedule_helper::get_recurrence_options();
$viewasoptions = schedule_helper::get_viewas_options();
$reportemptyoptions = schedule_helper::get_report_empty_options();
$scheduledata = array_map(static function(schedule $schedule) use (
$context, $formatoptions, $recurrenceoptions, $viewasoptions, $reportemptyoptions): stdClass {
$scheduledata = array_map(
static function (schedule $schedule) use (
$context,
$formatoptions,
$recurrenceoptions,
$viewasoptions,
): stdClass {
// Show the schedule name, if it exists.
$scheduleinstance = base::from_persistent($schedule);
// The "User view as" property will be either creator, recipient or a specific userid.
$userviewas = $schedule->get('userviewas');
// The "User view as" property will be either creator, recipient or a specific userid.
$userviewas = $schedule->get('userviewas');
return (object) [
'name' => $schedule->get_formatted_name($context),
'enabled' => transform::yesno($schedule->get('enabled')),
'format' => $formatoptions[$schedule->get('format')],
'timescheduled' => transform::datetime($schedule->get('timescheduled')),
'recurrence' => $recurrenceoptions[$schedule->get('recurrence')],
'userviewas' => $viewasoptions[$userviewas] ?? transform::user($userviewas),
'audiences' => $schedule->get('audiences'),
'subject' => $schedule->get('subject'),
'message' => format_text($schedule->get('message'), $schedule->get('messageformat'), ['context' => $context]),
'reportempty' => $reportemptyoptions[$schedule->get('reportempty')],
'usercreated' => transform::user($schedule->get('usercreated')),
'usermodified' => transform::user($schedule->get('usermodified')),
'timecreated' => transform::datetime($schedule->get('timecreated')),
'timemodified' => transform::datetime($schedule->get('timemodified')),
];
}, $schedules);
return (object) [
'name' => $schedule->get_formatted_name($context),
'enabled' => transform::yesno($schedule->get('enabled')),
'classname' => $scheduleinstance?->get_name() ?? $schedule->get('classname'),
'configdata' => $schedule->get('configdata'),
'format' => $formatoptions[$schedule->get('format')],
'timescheduled' => transform::datetime($schedule->get('timescheduled')),
'recurrence' => $recurrenceoptions[$schedule->get('recurrence')],
'userviewas' => $viewasoptions[$userviewas] ?? transform::user($userviewas),
'audiences' => $schedule->get('audiences'),
'usercreated' => transform::user($schedule->get('usercreated')),
'usermodified' => transform::user($schedule->get('usermodified')),
'timecreated' => transform::datetime($schedule->get('timecreated')),
'timemodified' => transform::datetime($schedule->get('timemodified')),
];
},
$schedules,
);
writer::with_context($context)->export_related_data($subcontext, 'schedules', (object) ['data' => $scheduledata]);
}
@@ -0,0 +1,178 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
declare(strict_types=1);
namespace core_reportbuilder\reportbuilder\schedule;
use core\exception\moodle_exception;
use core\user;
use core_reportbuilder\local\helpers\{report, schedule as helper};
use core_reportbuilder\local\models\schedule;
use core_reportbuilder\local\schedules\base;
use MoodleQuickForm;
use progress_trace;
use stdClass;
use stored_file;
/**
* Message schedule class
*
* @package core_reportbuilder
* @copyright 2025 Paul Holden <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class message extends base {
/** @var int Send schedule with empty report */
public const REPORT_EMPTY_SEND_EMPTY = 0;
/** @var int Send schedule without report */
public const REPORT_EMPTY_SEND_WITHOUT = 1;
/** @var int Don't send schedule if report is empty */
public const REPORT_EMPTY_DONT_SEND = 2;
#[\Override]
public function get_name(): string {
return get_string('scheduleemail', 'core_reportbuilder');
}
#[\Override]
public function get_description(): string {
return get_string('scheduleemaildescription', 'core_reportbuilder');
}
#[\Override]
public function definition(MoodleQuickForm $mform): void {
// Message fields.
$mform->addElement('header', 'headermessage', get_string('messagecontent', 'core_reportbuilder'));
$mform->addElement('text', 'configdata[subject]', get_string('messagesubject', 'core_reportbuilder'));
$mform->setType('configdata[subject]', PARAM_TEXT);
$mform->addRule('configdata[subject]', null, 'required', null, 'client');
$mform->addRule('configdata[subject]', get_string('maximumchars', '', 255), 'maxlength', 255);
$mform->addElement('editor', 'configdata[message]', get_string('messagebody', 'core_reportbuilder'), null, [
'autosave' => false,
]);
$mform->setType('configdata[message]', PARAM_RAW);
$mform->addRule('configdata[message]', null, 'required', null, 'client');
// Advanced.
$mform->addElement('header', 'headeradvanced', get_string('advanced'));
$mform->addElement('select', 'configdata[reportempty]', get_string('scheduleempty', 'core_reportbuilder'), [
static::REPORT_EMPTY_SEND_EMPTY => get_string('scheduleemptysendwithattachment', 'core_reportbuilder'),
static::REPORT_EMPTY_SEND_WITHOUT => get_string('scheduleemptysendwithoutattachment', 'core_reportbuilder'),
static::REPORT_EMPTY_DONT_SEND => get_string('scheduleemptydontsend', 'core_reportbuilder'),
]);
}
#[\Override]
public function execute(array $users, progress_trace $trace): void {
$scheduleattachment = null;
if (count($users) > 0) {
$schedule = $this->get_persistent();
$scheduleuserviewas = $schedule->get('userviewas');
$schedulereportempty = $this->get_configdata()['reportempty'] ?? static::REPORT_EMPTY_SEND_EMPTY;
// Handle schedule configuration as to who the report should be viewed as.
if ($scheduleuserviewas === schedule::REPORT_VIEWAS_CREATOR) {
$scheduleattachment = helper::get_schedule_report_file($schedule);
} else if ($scheduleuserviewas !== schedule::REPORT_VIEWAS_RECIPIENT) {
// Get the user to view the schedule report as, ensure it's an active account.
try {
$scheduleviewas = user::get_user($scheduleuserviewas, '*', MUST_EXIST);
user::require_active_user($scheduleviewas);
} catch (moodle_exception $exception) {
$trace->output('Invalid schedule view as user: ' . $exception->getMessage());
return;
}
\core\cron::setup_user($scheduleviewas);
$scheduleattachment = helper::get_schedule_report_file($schedule);
}
// Apply special handling if report is empty (default is to send it anyway).
if (
$schedulereportempty === static::REPORT_EMPTY_DONT_SEND &&
$scheduleattachment !== null &&
report::get_report_row_count($schedule->get('reportid')) === 0
) {
$trace->output('Empty report, skipping', 1);
} else {
// Now iterate over recipient users, send the report to each.
foreach ($users as $user) {
$trace->output('Sending to: ' . fullname($user, true), 1);
// If we already created the attachment, send that. Otherwise generate per recipient.
if ($scheduleattachment !== null) {
$this->send_message($user, $scheduleattachment);
} else {
\core\cron::setup_user($user);
if (
$schedulereportempty === static::REPORT_EMPTY_DONT_SEND &&
report::get_report_row_count($schedule->get('reportid')) === 0
) {
$trace->output('Empty report, skipping', 2);
continue;
}
$recipientattachment = helper::get_schedule_report_file($schedule);
$this->send_message($user, $recipientattachment);
$recipientattachment->delete();
}
}
}
}
if ($scheduleattachment !== null) {
$scheduleattachment->delete();
}
}
/**
* Send schedule message to user
*
* @param stdClass $user
* @param stored_file $attachment
* @return bool
*/
private function send_message(stdClass $user, stored_file $attachment): bool {
$config = $this->get_configdata();
$message = new \core\message\message();
$message->component = 'moodle';
$message->name = 'reportbuilderschedule';
$message->courseid = SITEID;
$message->userfrom = user::get_noreply_user();
$message->userto = $user;
$message->subject = $config['subject'];
$message->fullmessage = $config['message']['text'];
$message->fullmessageformat = $config['message']['format'];
$message->fullmessagehtml = $message->fullmessage;
$message->smallmessage = $message->fullmessage;
// Attach report to outgoing message.
$message->attachment = $attachment;
$message->attachname = $attachment->get_filename();
return (bool) message_send($message);
}
}
@@ -21,7 +21,8 @@ namespace core_reportbuilder\task;
use core\{clock, di};
use core\task\adhoc_task;
use core_user;
use core_reportbuilder\local\helpers\{report, schedule as helper};
use core_reportbuilder\local\schedules\base;
use core_reportbuilder\local\helpers\schedule as helper;
use core_reportbuilder\local\models\schedule;
use moodle_exception;
@@ -62,14 +63,13 @@ class send_schedule extends adhoc_task {
}
$schedule = schedule::get_record(['id' => $scheduleid, 'reportid' => $reportid]);
if ($schedule === false) {
if ($schedule === false || !$instance = base::from_persistent($schedule)) {
$this->log('Invalid schedule', 0);
return;
}
$this->log_start('Sending schedule: ' . $schedule->get_formatted_name());
$this->log_start('Sending schedule: ' . $schedule->get_formatted_name() . ' (' . $instance->get_name() . ')');
$scheduleattachment = null;
$originaluser = $USER;
// Get the schedule creator, ensure it's an active account.
@@ -84,71 +84,19 @@ class send_schedule extends adhoc_task {
// Switch to schedule creator, and retrieve list of recipient users.
\core\cron::setup_user($schedulecreator);
$users = helper::get_schedule_report_users($schedule);
if (count($users) > 0) {
$scheduleuserviewas = $schedule->get('userviewas');
$schedulereportempty = $schedule->get('reportempty');
// Handle schedule configuration as to who the report should be viewed as.
if ($scheduleuserviewas === schedule::REPORT_VIEWAS_CREATOR) {
$scheduleattachment = helper::get_schedule_report_file($schedule);
} else if ($scheduleuserviewas !== schedule::REPORT_VIEWAS_RECIPIENT) {
// Get the user to view the schedule report as, ensure it's an active account.
try {
$scheduleviewas = core_user::get_user($scheduleuserviewas, '*', MUST_EXIST);
core_user::require_active_user($scheduleviewas);
} catch (moodle_exception $exception) {
$this->log('Invalid schedule view as user: ' . $exception->getMessage(), 0);
return;
}
\core\cron::setup_user($scheduleviewas);
$scheduleattachment = helper::get_schedule_report_file($schedule);
}
// Apply special handling if report is empty (default is to send it anyway).
if ($schedulereportempty === schedule::REPORT_EMPTY_DONT_SEND && $scheduleattachment !== null &&
report::get_report_row_count($schedule->get('reportid')) === 0) {
$this->log('Empty report, skipping');
} else {
// Now iterate over recipient users, send the report to each.
foreach ($users as $user) {
$this->log('Sending to: ' . fullname($user, true));
// If we already created the attachment, send that. Otherwise generate per recipient.
if ($scheduleattachment !== null) {
helper::send_schedule_message($schedule, $user, $scheduleattachment);
} else {
\core\cron::setup_user($user);
if ($schedulereportempty === schedule::REPORT_EMPTY_DONT_SEND &&
report::get_report_row_count($schedule->get('reportid')) === 0) {
$this->log('Empty report, skipping', 2);
continue;
}
$recipientattachment = helper::get_schedule_report_file($schedule);
helper::send_schedule_message($schedule, $user, $recipientattachment);
$recipientattachment->delete();
}
}
}
$users = [];
if ($instance->requires_audience()) {
$users = helper::get_schedule_report_users($schedule);
}
// Execute schedule type.
$instance->execute($users, $this->get_trace());
// Finish, clean up (set persistent property manually to avoid updating it's user/time modified data).
$DB->set_field($schedule::TABLE, 'timelastsent', di::get(clock::class)->time(), [
'id' => $schedule->get('id'),
]);
if ($scheduleattachment !== null) {
$scheduleattachment->delete();
}
$this->log_finish('Sending schedule complete');
// Restore cron user to original state.
@@ -21,12 +21,16 @@
Example context (json):
{
"menu": {},
"report": "The report content",
"reportid": 10
}
}}
<h2 class="visually-hidden">{{#str}} schedules, core_reportbuilder {{/str}}</h2>
<div class="reportbuilder-schedules-container">
{{#menu}}
{{> core/local/dropdown/status}}
{{/menu}}
{{{ report }}}
</div>
@@ -204,6 +204,7 @@ Feature: Configure access to reports based on intended audience
And I am on the "My report" "reportbuilder > Editor" page logged in as "admin"
And I click on the "Schedules" dynamic tab
And I press "New schedule"
And I click on "Schedule an email" "link" in the ".dropdown" "css_element"
And I set the following fields in the "New schedule" "dialogue" to these values:
| Name | My schedule |
| Starting from | ##tomorrow 11:00## |
@@ -30,6 +30,7 @@ Feature: Manage custom report schedules
And I press "Save changes"
When I click on the "Schedules" dynamic tab
And I press "New schedule"
And I click on "Schedule an email" "link" in the ".dropdown" "css_element"
And I set the following fields in the "New schedule" "dialogue" to these values:
| Name | My schedule |
| Starting from | ##tomorrow 11:00## |
@@ -52,6 +53,7 @@ Feature: Manage custom report schedules
And I set the field "Rename audience 'All users'" to "<span class=\"multilang\" lang=\"en\">English</span><span class=\"multilang\" lang=\"es\">Spanish</span>"
When I click on the "Schedules" dynamic tab
And I press "New schedule"
And I click on "Schedule an email" "link" in the ".dropdown" "css_element"
Then I should see "English" in the "New schedule" "dialogue"
And I should not see "Spanish" in the "New schedule" "dialogue"
And I click on "Cancel" "button" in the "New schedule" "dialogue"
@@ -147,11 +149,22 @@ Feature: Manage custom report schedules
| Name | My updated schedule |
| Starting from | ##tomorrow 11:00## |
| All users: All site users | 1 |
| Subject | Tell me how to win your heart |
| Body | For I haven't got a clue |
| If the report is empty | Don't send message |
And I click on "Save" "button" in the "Edit schedule details" "dialogue"
Then I should see "Schedule updated"
And the following should exist in the "Report schedules" table:
| Name | Time last sent | Time next send | Modified by |
| My updated schedule | Never | ##tomorrow 11:00##%A, %d %B %Y, %H:%M## | Admin User |
And I press "Edit schedule details" action in the "My updated schedule" report row
And the following fields in the "Edit schedule details" "dialogue" match these values:
| Name | My updated schedule |
# | Starting from | ##tomorrow 11:00## | Nope, can't: MDL-86255.
| All users: All site users | 1 |
| Subject | Tell me how to win your heart |
| Body | For I haven't got a clue |
| If the report is empty | Don't send message |
Scenario: Send report schedule
Given the following "core_reportbuilder > Schedules" exist:
+10 -16
View File
@@ -18,13 +18,10 @@ declare(strict_types=1);
use core\{clock, di};
use core_reportbuilder\manager;
use core_reportbuilder\local\helpers\report as helper;
use core_reportbuilder\local\helpers\schedule as schedule_helper;
use core_reportbuilder\local\models\column;
use core_reportbuilder\local\models\filter;
use core_reportbuilder\local\models\report;
use core_reportbuilder\local\models\schedule;
use core_reportbuilder\local\audiences\base as audience_base;
use core_reportbuilder\local\helpers\report as helper;
use core_reportbuilder\local\models\{column, filter, report, schedule};
use core_reportbuilder\local\schedules\base as schedule_base;
/**
* Report builder test generator
@@ -177,10 +174,9 @@ class core_reportbuilder_generator extends component_generator_base {
// Default to all users if not specified, for convenience.
/** @var audience_base $classname */
$classname = $record['classname'] ??
\core_reportbuilder\reportbuilder\audience\allusers::class;
$classname = $record['classname'] ?? \core_reportbuilder\reportbuilder\audience\allusers::class;
return ($classname)::create($record['reportid'], $record['configdata']);
return $classname::create($record['reportid'], $record['configdata']);
}
/**
@@ -205,16 +201,14 @@ class core_reportbuilder_generator extends component_generator_base {
if (!array_key_exists('format', $record)) {
$record['format'] = 'csv';
}
if (!array_key_exists('subject', $record)) {
$record['subject'] = $record['name'] . ' subject';
}
if (!array_key_exists('message', $record)) {
$record['message'] = $record['name'] . ' message';
}
if (!array_key_exists('timescheduled', $record)) {
$record['timescheduled'] = usergetmidnight(di::get(clock::class)->time() + DAYSECS);
}
return schedule_helper::create_schedule((object) $record);
// Default to message schedule if not specified, for convenience.
/** @var schedule_base $classname */
$classname = $record['classname'] ?? \core_reportbuilder\reportbuilder\schedule\message::class;
return $classname::create((object) $record)->get_persistent();
}
}
@@ -24,7 +24,9 @@ use core\exception\{coding_exception, invalid_parameter_exception};
use core_cohort\reportbuilder\audience\cohortmember;
use core_reportbuilder_generator;
use core_reportbuilder\local\models\schedule as model;
use core_reportbuilder\local\schedules\base;
use core_reportbuilder\reportbuilder\audience\manual;
use core_reportbuilder\reportbuilder\schedule\message;
use core_user\reportbuilder\datasource\users;
/**
@@ -48,6 +50,39 @@ final class schedule_test extends advanced_testcase {
$this->clock = $this->mock_clock_with_frozen(1622847600);
}
/**
* Data provider for {@see test_valid}
*
* @return array[]
*/
public static function valid_provider(): array {
return [
[message::class, true],
[base::class, false],
[\core\url::class, false],
['doesntexist', false],
];
}
/**
* Test validity of given classname as a schedule type
*
* @param string $classname
* @param bool $expected
*
* @dataProvider valid_provider
*/
public function test_valid(string $classname, bool $expected): void {
$this->assertEquals($expected, schedule::valid($classname));
}
/**
* Test getting list of available schedules
*/
public function test_get_schedules(): void {
$this->assertContains(message::class, schedule::get_schedules());
}
/**
* Test create schedule
*/
@@ -64,17 +99,19 @@ final class schedule_test extends advanced_testcase {
$schedule = schedule::create_schedule((object) [
'name' => 'My schedule',
'reportid' => $report->get('id'),
'classname' => message::class,
'configdata' => json_encode(['subject' => 'Hello', 'message' => 'Hola']),
'format' => 'csv',
'subject' => 'Hello',
'message' => 'Hola',
'timescheduled' => $timescheduled,
]);
$this->assertDebuggingCalled(null, DEBUG_DEVELOPER);
$this->assertEquals('My schedule', $schedule->get('name'));
$this->assertEquals($report->get('id'), $schedule->get('reportid'));
$this->assertEquals(message::class, $schedule->get('classname'));
$this->assertEquals('{"subject":"Hello","message":"Hola"}', $schedule->get('configdata'));
$this->assertEquals('csv', $schedule->get('format'));
$this->assertEquals('Hello', $schedule->get('subject'));
$this->assertEquals('Hola', $schedule->get('message'));
$this->assertEquals($timescheduled, $schedule->get('timescheduled'));
$this->assertEquals($timescheduled, $schedule->get('timenextsend'));
}
@@ -263,7 +300,7 @@ final class schedule_test extends advanced_testcase {
// There is only one row in the report (the only user on the site).
$count = schedule::get_schedule_report_count($schedule);
$this->assertDebuggingCalled();
$this->assertDebuggingCalled(null, DEBUG_DEVELOPER);
$this->assertEquals(1, $count);
}
@@ -261,14 +261,13 @@ final class provider_test extends provider_testcase {
$this->assertEquals($schedule->get_formatted_name(), $scheduledata->name);
$this->assertEquals('Yes', $scheduledata->enabled);
$this->assertEquals('Schedule an email', $scheduledata->classname);
$this->assertEquals('{}', $scheduledata->configdata);
$this->assertEquals('Comma separated values (.csv)', $scheduledata->format);
$this->assertNotEmpty($scheduledata->timescheduled);
$this->assertEquals('None', $scheduledata->recurrence);
$this->assertEquals('Schedule creator', $scheduledata->userviewas);
$this->assertEquals(json_encode([$audiencepersistent->get('id')]), $scheduledata->audiences);
$this->assertEquals($schedule->get('subject'), $scheduledata->subject);
$this->assertEquals(format_text($schedule->get('message'), $schedule->get('messageformat')), $scheduledata->message);
$this->assertEquals('Send message with empty report', $scheduledata->reportempty);
$this->assertEquals($user->id, $scheduledata->usercreated);
$this->assertEquals($user->id, $scheduledata->usermodified);
$this->assertNotEmpty($scheduledata->timecreated);
@@ -0,0 +1,194 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
declare(strict_types=1);
namespace core_reportbuilder\reportbuilder\schedule;
use advanced_testcase;
use core_collator;
use core_user;
use core_reportbuilder\local\filters\user;
use core_reportbuilder\local\models\schedule;
use core_reportbuilder\manager;
use core_reportbuilder\reportbuilder\audience\manual;
use core_reportbuilder_generator;
use core_reportbuilder\task\send_schedule;
use core_notes\reportbuilder\datasource\notes;
use core_user\reportbuilder\datasource\users;
/**
* Unit tests for message schedule class
*
* @package core_reportbuilder
* @covers \core_reportbuilder\reportbuilder\schedule\message
* @copyright 2025 Paul Holden <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class message_test extends advanced_testcase {
/**
* Data provider for {@see test_execute_viewas_user}
*
* @return array[]
*/
public static function execute_report_viewas_user_provider(): array {
return [
'View report as schedule creator' => [schedule::REPORT_VIEWAS_CREATOR, null, 'admin', 'admin'],
'View report as schedule recipient' => [schedule::REPORT_VIEWAS_RECIPIENT, null, 'userone', 'usertwo'],
'View report as specific user' => [null, 'userone', 'userone', 'userone'],
];
}
/**
* Test executing task for a schedule with differing "View as user" configuration
*
* @param int|null $viewasuser
* @param string|null $viewasusername
* @param string $useronesees
* @param string $usertwosees
*
* @dataProvider execute_report_viewas_user_provider
*/
public function test_execute_report_viewas_user(
?int $viewasuser,
?string $viewasusername,
string $useronesees,
string $usertwosees
): void {
$this->preventResetByRollback();
$this->resetAfterTest();
$this->setAdminUser();
$userone = $this->getDataGenerator()->create_user([
'username' => 'userone',
'email' => '[email protected]',
'firstname' => 'Zoe',
'lastname' => 'Zebra',
]);
$usertwo = $this->getDataGenerator()->create_user([
'username' => 'usertwo',
'email' => '[email protected]',
'firstname' => 'Henrietta',
'lastname' => 'Hamster',
]);
/** @var core_reportbuilder_generator $generator */
$generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder');
// Create a report, with a single column and condition that the current user only sees themselves.
$report = $generator->create_report(['name' => 'Myself', 'source' => users::class, 'default' => false]);
$generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:username']);
$generator->create_condition(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:userselect']);
manager::get_report_from_persistent($report)
->set_condition_values(['user:userselect_operator' => user::USER_CURRENT]);
// Add audience/schedule for our two test users.
$audience = $generator->create_audience([
'reportid' => $report->get('id'),
'classname' => manual::class,
'configdata' => [
'users' => [$userone->id, $usertwo->id],
],
]);
// If "View as user" isn't specified, it should be the ID of the given "View as username".
if ($viewasuser === null) {
$viewasuser = core_user::get_user_by_username($viewasusername, '*', null, MUST_EXIST)->id;
}
$schedule = $generator->create_schedule([
'reportid' => $report->get('id'),
'name' => 'My schedule',
'userviewas' => $viewasuser,
'audiences' => json_encode([$audience->get_persistent()->get('id')]),
'configdata' => json_encode(['subject' => 'Hi', 'message' => ['text' => 'Hi', 'format' => 1]]),
]);
// Send the schedule, catch emails in sink (noting the users are sorted alphabetically).
$sink = $this->redirectEmails();
$this->expectOutputRegex("/^Sending schedule: My schedule \(Schedule an email\)\n" .
" Sending to: " . fullname($usertwo) . "\n" .
" Sending to: " . fullname($userone) . "\n" .
"Sending schedule complete\n/");
$sendschedule = new send_schedule();
$sendschedule->set_custom_data(['reportid' => $report->get('id'), 'scheduleid' => $schedule->get('id')]);
$sendschedule->execute();
$messages = $sink->get_messages();
$this->assertCount(2, $messages);
$sink->close();
// Ensure caught messages are consistently ordered by recipient email prior to assertions.
core_collator::asort_objects_by_property($messages, 'to');
$messages = array_values($messages);
$messageoneattachment = self::extract_message_attachment($messages[0]->body);
$this->assertEquals($userone->email, $messages[0]->to);
$this->assertStringEndsWith("Username\n{$useronesees}\n", $messageoneattachment);
$messagetwoattachment = self::extract_message_attachment($messages[1]->body);
$this->assertEquals($usertwo->email, $messages[1]->to);
$this->assertStringEndsWith("Username\n{$usertwosees}\n", $messagetwoattachment);
}
/**
* Test executing for a schedule that is configured to not send empty reports
*/
public function test_execute_report_empty(): void {
$this->resetAfterTest();
$this->setAdminUser();
/** @var core_reportbuilder_generator $generator */
$generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder');
// Create a report that won't return any data.
$report = $generator->create_report(['name' => 'Notes', 'source' => notes::class]);
$audience = $generator->create_audience(['reportid' => $report->get('id'), 'configdata' => []]);
$schedule = $generator->create_schedule([
'reportid' => $report->get('id'),
'name' => 'My schedule',
'audiences' => json_encode([$audience->get_persistent()->get('id')]),
'configdata' => json_encode(['reportempty' => message::REPORT_EMPTY_DONT_SEND]),
]);
$this->expectOutputString("Sending schedule: My schedule (Schedule an email)\n" .
" Empty report, skipping\n" .
"Sending schedule complete\n");
// Execute via task.
$sendschedule = new send_schedule();
$sendschedule->set_custom_data(['reportid' => $report->get('id'), 'scheduleid' => $schedule->get('id')]);
$sendschedule->execute();
}
/**
* Given a multi-part message in MIME format, return the base64 encoded attachment contained within
*
* @param string $messagebody
* @return string
*/
private static function extract_message_attachment(string $messagebody): string {
$mimepart = preg_split('/Content-Disposition: attachment; filename="My schedule.csv"\s+/m', $messagebody);
// Extract the base64 encoded content after the "Content-Disposition" header.
preg_match_all('/^([A-Z0-9\/\+=]+)\s/im', $mimepart[1], $matches);
return base64_decode(implode($matches[0]));
}
}
@@ -38,114 +38,6 @@ use core_user\reportbuilder\datasource\users;
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class send_schedule_test extends advanced_testcase {
/**
* Data provider for {@see test_execute_viewas_user}
*
* @return array[]
*/
public static function execute_report_viewas_user_provider(): array {
return [
'View report as schedule creator' => [schedule::REPORT_VIEWAS_CREATOR, null, 'admin', 'admin'],
'View report as schedule recipient' => [schedule::REPORT_VIEWAS_RECIPIENT, null, 'userone', 'usertwo'],
'View report as specific user' => [null, 'userone', 'userone', 'userone'],
];
}
/**
* Test executing task for a schedule with differing "View as user" configuration
*
* @param int|null $viewasuser
* @param string|null $viewasusername
* @param string $useronesees
* @param string $usertwosees
*
* @dataProvider execute_report_viewas_user_provider
*/
public function test_execute_report_viewas_user(
?int $viewasuser,
?string $viewasusername,
string $useronesees,
string $usertwosees
): void {
$this->preventResetByRollback();
$this->resetAfterTest();
$this->setAdminUser();
$userone = $this->getDataGenerator()->create_user([
'username' => 'userone',
'email' => '[email protected]',
'firstname' => 'Zoe',
'lastname' => 'Zebra',
]);
$usertwo = $this->getDataGenerator()->create_user([
'username' => 'usertwo',
'email' => '[email protected]',
'firstname' => 'Henrietta',
'lastname' => 'Hamster',
]);
/** @var core_reportbuilder_generator $generator */
$generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder');
// Create a report, with a single column and condition that the current user only sees themselves.
$report = $generator->create_report(['name' => 'Myself', 'source' => users::class, 'default' => false]);
$generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:username']);
$generator->create_condition(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:userselect']);
manager::get_report_from_persistent($report)
->set_condition_values(['user:userselect_operator' => user::USER_CURRENT]);
// Add audience/schedule for our two test users.
$audience = $generator->create_audience([
'reportid' => $report->get('id'),
'classname' => manual::class,
'configdata' => [
'users' => [$userone->id, $usertwo->id],
],
]);
// If "View as user" isn't specified, it should be the ID of the given "View as username".
if ($viewasuser === null) {
$viewasuser = core_user::get_user_by_username($viewasusername, '*', null, MUST_EXIST)->id;
}
$schedule = $generator->create_schedule([
'reportid' => $report->get('id'),
'name' => 'My schedule',
'userviewas' => $viewasuser,
'audiences' => json_encode([$audience->get_persistent()->get('id')]),
]);
// Send the schedule, catch emails in sink (noting the users are sorted alphabetically).
$sink = $this->redirectEmails();
$this->expectOutputRegex("/^Sending schedule: My schedule\n" .
" Sending to: " . fullname($usertwo) . "\n" .
" Sending to: " . fullname($userone) . "\n" .
"Sending schedule complete\n/"
);
$sendschedule = new send_schedule();
$sendschedule->set_custom_data(['reportid' => $report->get('id'), 'scheduleid' => $schedule->get('id')]);
$sendschedule->execute();
$messages = $sink->get_messages();
$this->assertCount(2, $messages);
$sink->close();
// Ensure caught messages are consistently ordered by recipient email prior to assertions.
core_collator::asort_objects_by_property($messages, 'to');
$messages = array_values($messages);
$messageoneattachment = self::extract_message_attachment($messages[0]->body);
$this->assertEquals($userone->email, $messages[0]->to);
$this->assertStringEndsWith("Username\n{$useronesees}\n", $messageoneattachment);
$messagetwoattachment = self::extract_message_attachment($messages[1]->body);
$this->assertEquals($usertwo->email, $messages[1]->to);
$this->assertStringEndsWith("Username\n{$usertwosees}\n", $messagetwoattachment);
}
/**
* Test executing task where the schedule "View as user" is an inactive account
*/
@@ -166,44 +58,8 @@ final class send_schedule_test extends advanced_testcase {
'audiences' => json_encode([$audience->get_persistent()->get('id')]),
]);
$this->expectOutputRegex("/^Sending schedule: My schedule\nInvalid schedule view as user: Invalid user/");
$sendschedule = new send_schedule();
$sendschedule->set_custom_data(['reportid' => $report->get('id'), 'scheduleid' => $schedule->get('id')]);
$sendschedule->execute();
}
/**
* Test executing task for a schedule that is configured to not send empty reports
*/
public function test_execute_report_empty(): void {
$this->resetAfterTest();
$this->setAdminUser();
/** @var core_reportbuilder_generator $generator */
$generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder');
// Create a report that won't return any data.
$report = $generator->create_report(['name' => 'Myself', 'source' => users::class, 'default' => false]);
$generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:username']);
$generator->create_condition(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:username']);
manager::get_report_from_persistent($report)->set_condition_values([
'user:username_operator' => text::IS_EQUAL_TO,
'user:username_value' => 'baconlettucetomato',
]);
$audience = $generator->create_audience(['reportid' => $report->get('id'), 'configdata' => []]);
$schedule = $generator->create_schedule([
'reportid' => $report->get('id'),
'name' => 'My schedule',
'audiences' => json_encode([$audience->get_persistent()->get('id')]),
'reportempty' => schedule::REPORT_EMPTY_DONT_SEND,
]);
$this->expectOutputString("Sending schedule: My schedule\n" .
" Empty report, skipping\n" .
"Sending schedule complete\n");
$this->expectOutputRegex("/^Sending schedule: My schedule \(Schedule an email\)\n" .
"Invalid schedule view as user: Invalid user/");
$sendschedule = new send_schedule();
$sendschedule->set_custom_data(['reportid' => $report->get('id'), 'scheduleid' => $schedule->get('id')]);
$sendschedule->execute();
@@ -222,7 +78,7 @@ final class send_schedule_test extends advanced_testcase {
$report = $generator->create_report(['name' => 'My report', 'source' => users::class]);
$schedule = $generator->create_schedule(['reportid' => $report->get('id'), 'name' => 'My schedule']);
$this->expectOutputString("Sending schedule: My schedule\n" .
$this->expectOutputString("Sending schedule: My schedule (Schedule an email)\n" .
"Sending schedule complete\n");
$sendschedule = new send_schedule();
$sendschedule->set_custom_data(['reportid' => $report->get('id'), 'scheduleid' => $schedule->get('id')]);
@@ -241,7 +97,8 @@ final class send_schedule_test extends advanced_testcase {
$report = $generator->create_report(['name' => 'My report', 'source' => users::class]);
$schedule = $generator->create_schedule(['reportid' => $report->get('id'), 'name' => 'My schedule', 'usercreated' => 42]);
$this->expectOutputRegex("/^Sending schedule: My schedule\nInvalid schedule creator: Invalid user/");
$this->expectOutputRegex("/^Sending schedule: My schedule \(Schedule an email\)\n" .
"Invalid schedule creator: Invalid user/");
$sendschedule = new send_schedule();
$sendschedule->set_custom_data(['reportid' => $report->get('id'), 'scheduleid' => $schedule->get('id')]);
$sendschedule->execute();
@@ -263,19 +120,4 @@ final class send_schedule_test extends advanced_testcase {
$sendschedule->set_custom_data(['reportid' => $report->get('id'), 'scheduleid' => 42]);
$sendschedule->execute();
}
/**
* Given a multi-part message in MIME format, return the base64 encoded attachment contained within
*
* @param string $messagebody
* @return string
*/
private static function extract_message_attachment(string $messagebody): string {
$mimepart = preg_split('/Content-Disposition: attachment; filename="My schedule.csv"\s+/m', $messagebody);
// Extract the base64 encoded content after the "Content-Disposition" header.
preg_match_all('/^([A-Z0-9\/\+=]+)\s/im', $mimepart[1], $matches);
return base64_decode(implode($matches[0]));
}
}
+1 -1
View File
@@ -29,7 +29,7 @@
defined('MOODLE_INTERNAL') || die();
$version = 2025090200.01; // YYYYMMDD = weekly release date of this DEV branch.
$version = 2025090200.02; // YYYYMMDD = weekly release date of this DEV branch.
// RR = release increments - 00 in DEV branches.
// .XX = incremental changes.
$release = '5.1dev+ (Build: 20250902)'; // Human-friendly version name