$defaultreader, $options));
// Enable/disable time splitting methods.
- $alltimesplittings = \core_analytics\manager::get_all_time_splittings();
+ $alltimesplittings = \core_analytics\manager::get_time_splitting_methods_for_evaluation(true);
$timesplittingoptions = array();
$timesplittingdefaults = array('\core\analytics\time_splitting\quarters_accum',
$temp->add(new admin_setting_configcheckbox('profilingallowme', new lang_string('profilingallowme', 'admin'), new lang_string('profilingallowme_help', 'admin'), false));
// Allow PROFILEALL/PROFILEALLSTOP GPC.
$temp->add(new admin_setting_configcheckbox('profilingallowall', new lang_string('profilingallowall', 'admin'), new lang_string('profilingallowall_help', 'admin'), false));
+ $temp->add(new admin_setting_configtext('profilingslow', new lang_string('profilingslow', 'admin'),
+ new lang_string('profilingslow_help', 'admin'), 0, PARAM_FLOAT));
// TODO: Allow to skip PHP functions (XHPROF_FLAGS_NO_BUILTINS)
// TODO: Allow to skip call_user functions (ignored_functions array)
// Specify the life time (in minutes) of profiling runs.
},
/**
- * Displays a select-evaluation-mode choice.
+ * Displays evaluation mode and time-splitting method choices.
*
* @param {String} actionId
* @param {Boolean} trainedOnlyExternally
*/
- selectEvaluationMode: function(actionId, trainedOnlyExternally) {
+ selectEvaluationOptions: function(actionId, trainedOnlyExternally, timeSplittingMethods) {
$('[data-action-id="' + actionId + '"]').on('click', function(ev) {
ev.preventDefault();
var a = $(ev.currentTarget);
- if (!trainedOnlyExternally) {
- // We can not evaluate trained models if the model was trained using data from this site.
- // Default to evaluate the model configuration if that is the case.
- window.location.href = a.attr('href');
- return;
- }
-
var stringsPromise = Str.get_strings([
{
key: 'evaluatemodel',
component: 'tool_analytics'
}, {
- key: 'evaluationmode',
+ key: 'evaluate',
component: 'tool_analytics'
}
]);
var modalPromise = ModalFactory.create({type: ModalFactory.types.SAVE_CANCEL});
- var bodyPromise = Templates.render('tool_analytics/evaluation_mode_selection', {});
+ var bodyPromise = Templates.render('tool_analytics/evaluation_options', {
+ trainedexternally: trainedOnlyExternally,
+ timesplittingmethods: timeSplittingMethods
+ });
$.when(stringsPromise, modalPromise).then(function(strings, modal) {
modal.getRoot().on(ModalEvents.hidden, modal.destroy.bind(modal));
- modal.setTitle(strings[1]);
- modal.setSaveButtonText(strings[0]);
+ modal.setTitle(strings[0]);
+ modal.setSaveButtonText(strings[1]);
modal.setBody(bodyPromise);
modal.getRoot().on(ModalEvents.save, function() {
+
+ // Evaluation mode.
var evaluationMode = $("input[name='evaluationmode']:checked").val();
if (evaluationMode == 'trainedmodel') {
a.attr('href', a.attr('href') + '&mode=trainedmodel');
}
+
+ // Selected time-splitting id.
+ var timeSplittingMethod = $("#id-evaluation-timesplitting").val();
+ a.attr('href', a.attr('href') + '×plitting=' + timeSplittingMethod);
+
window.location.href = a.attr('href');
return;
});
$mform = $this->_form;
- if ($this->_customdata['trainedmodel']) {
+ if ($this->_customdata['trainedmodel'] && $this->_customdata['staticmodel'] === false) {
$message = get_string('edittrainedwarning', 'tool_analytics');
$mform->addElement('html', $OUTPUT->notification($message, \core\output\notification::NOTIFY_WARNING));
}
$mform->addElement('advcheckbox', 'enabled', get_string('enabled', 'tool_analytics'));
+ // Target.
if (!empty($this->_customdata['targets'])) {
$targets = array('' => '');
foreach ($this->_customdata['targets'] as $classname => $target) {
$mform->addRule('target', get_string('required'), 'required', null, 'client');
}
- $indicators = array();
- foreach ($this->_customdata['indicators'] as $classname => $indicator) {
- $optionname = \tool_analytics\output\helper::class_to_option($classname);
- $indicators[$optionname] = $indicator->get_name();
+ // Indicators.
+ if (!$this->_customdata['staticmodel']) {
+ $indicators = array();
+ foreach ($this->_customdata['indicators'] as $classname => $indicator) {
+ $optionname = \tool_analytics\output\helper::class_to_option($classname);
+ $indicators[$optionname] = $indicator->get_name();
+ }
+ $options = array(
+ 'multiple' => true
+ );
+ $mform->addElement('autocomplete', 'indicators', get_string('indicators', 'tool_analytics'), $indicators, $options);
+ $mform->setType('indicators', PARAM_ALPHANUMEXT);
+ $mform->addHelpButton('indicators', 'indicators', 'tool_analytics');
}
- $options = array(
- 'multiple' => true
- );
- $mform->addElement('autocomplete', 'indicators', get_string('indicators', 'tool_analytics'), $indicators, $options);
- $mform->setType('indicators', PARAM_ALPHANUMEXT);
- $mform->addHelpButton('indicators', 'indicators', 'tool_analytics');
+ // Time-splitting methods.
$timesplittings = array('' => '');
foreach ($this->_customdata['timesplittings'] as $classname => $timesplitting) {
$optionname = \tool_analytics\output\helper::class_to_option($classname);
$timesplittings[$optionname] = $timesplitting->get_name();
}
-
$mform->addElement('select', 'timesplitting', get_string('timesplittingmethod', 'analytics'), $timesplittings);
$mform->addHelpButton('timesplitting', 'timesplittingmethod', 'analytics');
- $defaultprocessor = \core_analytics\manager::get_predictions_processor_name(
- \core_analytics\manager::get_predictions_processor()
- );
- $predictionprocessors = ['' => get_string('defaultpredictoroption', 'analytics', $defaultprocessor)];
- foreach ($this->_customdata['predictionprocessors'] as $classname => $predictionsprocessor) {
- if ($predictionsprocessor->is_ready() !== true) {
- continue;
+ // Predictions processor.
+ if (!$this->_customdata['staticmodel']) {
+ $defaultprocessor = \core_analytics\manager::get_predictions_processor_name(
+ \core_analytics\manager::get_predictions_processor()
+ );
+ $predictionprocessors = ['' => get_string('defaultpredictoroption', 'analytics', $defaultprocessor)];
+ foreach ($this->_customdata['predictionprocessors'] as $classname => $predictionsprocessor) {
+ if ($predictionsprocessor->is_ready() !== true) {
+ continue;
+ }
+ $optionname = \tool_analytics\output\helper::class_to_option($classname);
+ $predictionprocessors[$optionname] = \core_analytics\manager::get_predictions_processor_name($predictionsprocessor);
}
- $optionname = \tool_analytics\output\helper::class_to_option($classname);
- $predictionprocessors[$optionname] = \core_analytics\manager::get_predictions_processor_name($predictionsprocessor);
- }
- $mform->addElement('select', 'predictionsprocessor', get_string('predictionsprocessor', 'analytics'),
- $predictionprocessors);
- $mform->addHelpButton('predictionsprocessor', 'predictionsprocessor', 'analytics');
+ $mform->addElement('select', 'predictionsprocessor', get_string('predictionsprocessor', 'analytics'),
+ $predictionprocessors);
+ $mform->addHelpButton('predictionsprocessor', 'predictionsprocessor', 'analytics');
+ }
if (!empty($this->_customdata['id'])) {
$mform->addElement('hidden', 'id', $this->_customdata['id']);
}
}
- if (empty($data['indicators'])) {
- $errors['indicators'] = get_string('errornoindicators', 'analytics');
- } else {
- foreach ($data['indicators'] as $indicator) {
- $realindicatorname = \tool_analytics\output\helper::option_to_class($indicator);
- if (\core_analytics\manager::is_valid($realindicatorname, '\core_analytics\local\indicator\base') === false) {
- $errors['indicators'] = get_string('errorinvalidindicator', 'analytics', $realindicatorname);
+ if (!$this->_customdata['staticmodel']) {
+ if (empty($data['indicators'])) {
+ $errors['indicators'] = get_string('errornoindicators', 'analytics');
+ } else {
+ foreach ($data['indicators'] as $indicator) {
+ $realindicatorname = \tool_analytics\output\helper::option_to_class($indicator);
+ if (\core_analytics\manager::is_valid($realindicatorname, '\core_analytics\local\indicator\base') === false) {
+ $errors['indicators'] = get_string('errorinvalidindicator', 'analytics', $realindicatorname);
+ }
}
}
}
// Form field is PARAM_ALPHANUMEXT and we are sending fully qualified class names
// as option names, but replacing the backslash for a string that is really unlikely
// to ever be part of a class name.
- return str_replace('\\', '2015102400ouuu', $class);
+ return str_replace('\\', '__', $class);
}
/**
*/
public static function option_to_class($option) {
// Really unlikely but yeah, I'm a bad booyyy.
- return str_replace('2015102400ouuu', '\\', $option);
+ return str_replace('__', '\\', $option);
}
/**
$onlycli = 1;
}
+ // Evaluation options.
+ $timesplittingmethods = [
+ ['id' => 'all', 'text' => get_string('alltimesplittingmethods', 'tool_analytics')],
+ ];
+ foreach (\core_analytics\manager::get_time_splitting_methods_for_evaluation(true) as $timesplitting) {
+ $timesplittingmethods[] = [
+ 'id' => \tool_analytics\output\helper::class_to_option($timesplitting->get_id()),
+ 'text' => $timesplitting->get_name()->out(),
+ ];
+ }
+
$data->models = array();
foreach ($this->models as $model) {
$modeldata = $model->export();
$trainedonlyexternally = !$model->trained_locally() && $model->is_trained();
$actionid = 'evaluate-' . $model->get_id();
- $PAGE->requires->js_call_amd('tool_analytics/model', 'selectEvaluationMode', [$actionid, $trainedonlyexternally]);
+
+ $modeltimesplittingmethods = $timesplittingmethods;
+ // Include the current time-splitting method as the default selection method the model already have one.
+ if ($model->get_model_obj()->timesplitting) {
+ $currenttimesplitting = ['id' => 'current', 'text' => get_string('currenttimesplitting', 'tool_analytics')];
+ array_unshift($modeltimesplittingmethods, $currenttimesplitting);
+ }
+
+ $evaluateparams = [$actionid, $trainedonlyexternally, $modeltimesplittingmethods];
+ $PAGE->requires->js_call_amd('tool_analytics/model', 'selectEvaluationOptions', $evaluateparams);
$urlparams['action'] = 'evaluate';
$url = new \moodle_url('model.php', $urlparams);
$icon = new \action_menu_link_secondary($url, new \pix_icon('i/calc', get_string('evaluate', 'tool_analytics')),
}
// Edit model.
- if (!$model->is_static()) {
- $urlparams['action'] = 'edit';
- $url = new \moodle_url('model.php', $urlparams);
- $icon = new \action_menu_link_secondary($url, new \pix_icon('t/edit', get_string('edit')), get_string('edit'));
- $actionsmenu->add($icon);
- }
+ $urlparams['action'] = 'edit';
+ $url = new \moodle_url('model.php', $urlparams);
+ $icon = new \action_menu_link_secondary($url, new \pix_icon('t/edit', get_string('edit')), get_string('edit'));
+ $actionsmenu->add($icon);
// Enable / disable.
if ($model->is_enabled() || !empty($modeldata->timesplitting)) {
$actionsmenu->add($icon);
}
- $actionid = 'delete-' . $model->get_id();
- $PAGE->requires->js_call_amd('tool_analytics/model', 'confirmAction', [$actionid, 'delete']);
- $urlparams['action'] = 'delete';
- $url = new \moodle_url('model.php', $urlparams);
- $icon = new \action_menu_link_secondary($url, new \pix_icon('t/delete',
- get_string('delete', 'tool_analytics')), get_string('delete', 'tool_analytics'),
- ['data-action-id' => $actionid]);
- $actionsmenu->add($icon);
+ // Delete model.
+ if (!$model->is_static()) {
+ $actionid = 'delete-' . $model->get_id();
+ $PAGE->requires->js_call_amd('tool_analytics/model', 'confirmAction', [$actionid, 'delete']);
+ $urlparams['action'] = 'delete';
+ $url = new \moodle_url('model.php', $urlparams);
+ $icon = new \action_menu_link_secondary($url, new \pix_icon('t/delete',
+ get_string('delete', 'tool_analytics')), get_string('delete', 'tool_analytics'),
+ ['data-action-id' => $actionid]);
+ $actionsmenu->add($icon);
+ }
$modeldata->actions = $actionsmenu->export_for_template($output);
$customdata = array(
'trainedmodel' => false,
+ 'staticmodel' => false,
'targets' => $targets,
'indicators' => \core_analytics\manager::get_all_indicators(),
- 'timesplittings' => \core_analytics\manager::get_enabled_time_splitting_methods(),
+ 'timesplittings' => \core_analytics\manager::get_all_time_splittings(),
'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors(),
);
$mform = new \tool_analytics\output\form\edit_model(null, $customdata);
$string['accuracy'] = 'Accuracy';
$string['allpredictions'] = 'All predictions';
+$string['alltimesplittingmethods'] = 'All time-splitting methods';
$string['analysingsitedata'] = 'Analysing the site';
$string['analyticmodels'] = 'Analytics models';
$string['bettercli'] = 'Evaluating models and generating predictions may involve heavy processing. It is recommended to run these actions from the command line.';
$string['clievaluationandpredictions'] = 'A scheduled task iterates through enabled models and gets predictions. Models evaluation via the web interface is disabled. You can allow these processes to be executed manually via the web interface by disabling the <a href="{$a}">\'onlycli\'</a> analytics setting.';
$string['clievaluationandpredictionsnoadmin'] = 'A scheduled task iterates through enabled models and gets predictions. Models evaluation via the web interface is disabled. It may be enabled by a site administrator.';
$string['createmodel'] = 'Create model';
+$string['currenttimesplitting'] = 'Current time-splitting method';
$string['delete'] = 'Delete';
$string['deletemodelconfirmation'] = 'Are you sure you want to delete "{$a}"? These changes can not be reverted.';
$string['disabled'] = 'Disabled';
$string['errornoenabledandtrainedmodels'] = 'There are no enabled and trained models to predict.';
$string['errornoenabledmodels'] = 'There are no enabled models to train.';
$string['errornoexport'] = 'Only trained models can be exported';
-$string['errornostaticedit'] = 'Models based on assumptions cannot be edited.';
$string['errornostaticevaluated'] = 'Models based on assumptions cannot be evaluated. They are always 100% correct according to how they were defined.';
$string['errornostaticlog'] = 'Models based on assumptions cannot be evaluated because there is no performance log.';
$string['erroronlycli'] = 'Execution only allowed via command line';
$string['previouspage'] = 'Previous page';
$string['samestartdate'] = 'Current start date is good';
$string['sameenddate'] = 'Current end date is good';
+$string['selecttimesplittingforevaluation'] = 'Select the time-splitting method you want to use to evaluate the model configuration.';
$string['target'] = 'Target';
$string['target_help'] = 'The target is what the model will predict.';
$string['target_link'] = 'Targets';
case 'delete':
confirm_sesskey();
- $model->delete();
+ if (!$model->is_static()) {
+ $model->delete();
+ }
redirect($returnurl);
break;
case 'edit':
confirm_sesskey();
- if ($model->is_static()) {
- echo $OUTPUT->header();
- throw new moodle_exception('errornostaticedit', 'tool_analytics');
- }
-
$customdata = array(
'id' => $model->get_id(),
'trainedmodel' => $model->is_trained(),
+ 'staticmodel' => $model->is_static(),
'indicators' => $model->get_potential_indicators(),
'timesplittings' => \core_analytics\manager::get_all_time_splittings(),
'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors()
} else if ($data = $mform->get_data()) {
- // Converting option names to class names.
- $indicators = array();
- foreach ($data->indicators as $indicator) {
- $indicatorclass = \tool_analytics\output\helper::option_to_class($indicator);
- $indicators[] = \core_analytics\manager::get_indicator($indicatorclass);
- }
$timesplitting = \tool_analytics\output\helper::option_to_class($data->timesplitting);
- $predictionsprocessor = \tool_analytics\output\helper::option_to_class($data->predictionsprocessor);
+
+ if (!$model->is_static()) {
+ // Converting option names to class names.
+ $indicators = array();
+ foreach ($data->indicators as $indicator) {
+ $indicatorclass = \tool_analytics\output\helper::option_to_class($indicator);
+ $indicators[] = \core_analytics\manager::get_indicator($indicatorclass);
+ }
+ $predictionsprocessor = \tool_analytics\output\helper::option_to_class($data->predictionsprocessor);
+ } else {
+ // These fields can not be modified.
+ $indicators = false;
+ $predictionsprocessor = false;
+ }
+
$model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor);
redirect($returnurl);
}
$mode = optional_param('mode', false, PARAM_ALPHANUM);
if ($mode == 'trainedmodel') {
$options['mode'] = 'trainedmodel';
+ } else {
+
+ // All is the default in core_analytics\model::evaluate() as well.
+ $timesplitting = optional_param('timesplitting', 'all', PARAM_ALPHANUMEXT);
+ if ($timesplitting === 'current') {
+ $options['timesplitting'] = \core_analytics\manager::get_time_splitting($model->get_model_obj()->timesplitting);
+ } else if ($timesplitting !== 'all') {
+ $options['timesplitting'] = \core_analytics\manager::get_time_splitting(
+ \tool_analytics\output\helper::option_to_class($timesplitting)
+ );
+ }
}
+
$results = $model->evaluate($options);
// We reset the theme and the output as some indicators may be using external functions
+++ /dev/null
-{{!
- 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/>.
-}}
-{{!
- @template tool_analytics/evaluation_mode_selector
-
- Evaluation mode selector.
-
- The purpose of this template is to render the evaluation mode radio button.
-
- Classes required for JS:
- * none
-
- Data attributes required for JS:
- * none
-
- Example context (json):
- {
- }
-}}
-<div class="box mb-4">{{#str}} evaluationmodeinfo, tool_analytics {{/str}}</div>
-<div class="form-check">
- <input class="form-check-input" type="radio" name="evaluationmode" id="id-mode-trainedmodel" value="trainedmodel" checked>
- <label class="form-check-label" for="id-mode-trainedmodel">{{#str}} evaluationmodetrainedmodel, tool_analytics {{/str}}</label>
-</div>
-<div class="form-check">
- <input class="form-check-input" type="radio" name="evaluationmode" id="id-mode-configuration" value="configuration">
- <label class="form-check-label" for="id-mode-configuration">{{#str}} evaluationmodeconfiguration, tool_analytics {{/str}}</label>
-</div>
\ No newline at end of file
--- /dev/null
+{{!
+ 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/>.
+}}
+{{!
+ @template tool_analytics/evaluation_options
+
+ Evaluation selector.
+
+ The purpose of this template is to render the evaluation mode options.
+
+ Classes required for JS:
+ * none
+
+ Data attributes required for JS:
+ * none
+
+ Example context (json):
+ {
+ "trainedexternally": "1",
+ "timesplittingmethods": [
+ {
+ "id": "ou",
+ "name": "Quarters"
+ }, {
+ "id": "yeah",
+ "name": "Tenths"
+ }
+ ]
+ }
+}}
+
+{{#trainedexternally}}
+ <div class="box mb-4">{{#str}} evaluationmodeinfo, tool_analytics {{/str}}</div>
+
+ <div class="custom-control custom-radio">
+ <input class="custom-control-input" type="radio" name="evaluationmode" id="id-mode-trainedmodel" value="trainedmodel" checked>
+ <label class="custom-control-label" for="id-mode-trainedmodel">{{#str}} evaluationmodetrainedmodel, tool_analytics {{/str}}</label>
+ </div>
+
+ <div class="custom-control custom-radio">
+ <input class="custom-control-input" type="radio" name="evaluationmode" id="id-mode-configuration" value="configuration">
+ <label class="custom-control-label" for="id-mode-configuration">{{#str}} evaluationmodeconfiguration, tool_analytics {{/str}}</label>
+ </div>
+{{/trainedexternally}}
+
+{{! Hidden by default if #trainedexternally as the default option is trainedmodel in this case.}}
+<div id="id-evaluation-timesplitting-container" class="m-t-1 {{#trainedexternally}}hidden{{/trainedexternally}}">
+ {{#str}} selecttimesplittingforevaluation, tool_analytics {{/str}}
+ <div>
+ <select id="id-evaluation-timesplitting" name="timesplitting" class="custom-select m-t-1">
+ {{#timesplittingmethods}}
+ <option value="{{id}}">{{text}}</option>
+ {{/timesplittingmethods}}
+ </select>
+ </div>
+</div>
+
+
+{{#js}}
+ require(['jquery'], function($) {
+ $("input[name='evaluationmode']:radio").change(function() {
+ if ($(this).val() == 'configuration') {
+ $('#id-evaluation-timesplitting-container').show();
+ } else {
+ $('#id-evaluation-timesplitting-container').hide();
+ }
+ });
+ });
+{{/js}}
\ No newline at end of file
throw new moodle_exception('errorrequestnotwaitingforapproval', 'tool_dataprivacy');
}
+ // Check if current user has permission to approve delete data request.
+ if ($request->get('type') == self::DATAREQUEST_TYPE_DELETE && !self::can_create_data_deletion_request_for_other()) {
+ throw new required_capability_exception(context_system::instance(),
+ 'tool/dataprivacy:requestdeleteforotheruser', 'nopermissions', '');
+ }
+
// Update the status and the DPO.
$result = self::update_request_status($requestid, self::DATAREQUEST_STATUS_APPROVED, $USER->id);
throw new moodle_exception('errorrequestnotwaitingforapproval', 'tool_dataprivacy');
}
+ // Check if current user has permission to reject delete data request.
+ if ($request->get('type') == self::DATAREQUEST_TYPE_DELETE && !self::can_create_data_deletion_request_for_other()) {
+ throw new required_capability_exception(context_system::instance(),
+ 'tool/dataprivacy:requestdeleteforotheruser', 'nopermissions', '');
+ }
+
// Update the status and the DPO.
return self::update_request_status($requestid, self::DATAREQUEST_STATUS_REJECTED, $USER->id);
}
return true;
}
+ /**
+ * Check if user has permisson to create data deletion request for themselves.
+ *
+ * @param int|null $userid ID of the user.
+ * @return bool
+ * @throws coding_exception
+ */
+ public static function can_create_data_deletion_request_for_self(int $userid = null): bool {
+ global $USER;
+ $userid = $userid ?: $USER->id;
+ return has_capability('tool/dataprivacy:requestdelete', \context_user::instance($userid), $userid);
+ }
+
+ /**
+ * Check if user has permission to create data deletion request for another user.
+ *
+ * @param int|null $userid ID of the user.
+ * @return bool
+ * @throws coding_exception
+ * @throws dml_exception
+ */
+ public static function can_create_data_deletion_request_for_other(int $userid = null): bool {
+ global $USER;
+ $userid = $userid ?: $USER->id;
+ return has_capability('tool/dataprivacy:requestdeleteforotheruser', context_system::instance(), $userid);
+ }
+
+ /**
+ * Check if parent can create data deletion request for their children.
+ *
+ * @param int $userid ID of a user being requested.
+ * @param int|null $requesterid ID of a user making request.
+ * @return bool
+ * @throws coding_exception
+ */
+ public static function can_create_data_deletion_request_for_children(int $userid, int $requesterid = null): bool {
+ global $USER;
+ $requesterid = $requesterid ?: $USER->id;
+ return has_capability('tool/dataprivacy:makedatadeletionrequestsforchildren', \context_user::instance($userid),
+ $requesterid);
+ }
+
/**
* Checks whether a user can download a data request.
*
$values['canreview'] = true;
// Whether the DPO can approve or deny the request.
$values['approvedeny'] = in_array($requesttype, [api::DATAREQUEST_TYPE_EXPORT, api::DATAREQUEST_TYPE_DELETE]);
+ // If the request's type is delete, check if user have permission to approve/deny it.
+ if ($requesttype == api::DATAREQUEST_TYPE_DELETE) {
+ $values['approvedeny'] = api::can_create_data_deletion_request_for_other();
+ }
break;
case api::DATAREQUEST_STATUS_APPROVED:
$values['statuslabelclass'] = 'badge-info';
$coursecontext = \context_course::instance($course->id);
- if (!$course->visible && !has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
- continue;
- }
-
$coursenode = [
'text' => shorten_text(format_string($course->shortname, true, ['context' => $coursecontext])),
'contextid' => $coursecontext->id,
*
* @param stdClass $data The row data.
* @return string
+ * @throws \moodle_exception
+ * @throws coding_exception
*/
public function col_select($data) {
if ($data->status == \tool_dataprivacy\api::DATAREQUEST_STATUS_AWAITING_APPROVAL) {
+ if ($data->type == \tool_dataprivacy\api::DATAREQUEST_TYPE_DELETE
+ && !api::can_create_data_deletion_request_for_other()) {
+ // Don't show checkbox if request's type is delete and user don't have permission.
+ return false;
+ }
+
$stringdata = [
'username' => $data->foruser->fullname,
'requesttype' => \core_text::strtolower($data->typenameshort)
$requestid = $data->id;
$status = $data->status;
+ $persistent = $this->datarequests[$requestid];
// Prepare actions.
$actions = [];
}
break;
case api::DATAREQUEST_STATUS_AWAITING_APPROVAL:
+ // Only show "Approve" and "Deny" button for deletion request if current user has permission.
+ if ($persistent->get('type') == api::DATAREQUEST_TYPE_DELETE &&
+ !api::can_create_data_deletion_request_for_other()) {
+ break;
+ }
// Approve.
$actiondata['data-action'] = 'approve';
$actiontext = get_string('approverequest', 'tool_dataprivacy');
}
if ($this->manage) {
- $persistent = $this->datarequests[$requestid];
$canreset = $persistent->is_active() || empty($this->ongoingrequests[$data->foruser->id]->{$data->type});
$canreset = $canreset && $persistent->is_resettable();
+ // Prevent re-submmit deletion request if current user don't have permission.
+ $canreset = $canreset && ($persistent->get('type') != api::DATAREQUEST_TYPE_DELETE ||
+ api::can_create_data_deletion_request_for_other());
if ($canreset) {
$reseturl = new moodle_url('/admin/tool/dataprivacy/resubmitrequest.php', [
'requestid' => $requestid,
}
}
+ if ($data->type == \tool_dataprivacy\api::DATAREQUEST_TYPE_DELETE) {
+ if ($data->userid == $USER->id) {
+ if (!\tool_dataprivacy\api::can_create_data_deletion_request_for_self()) {
+ throw new moodle_exception('nopermissions', 'error', '',
+ get_string('errorcannotrequestdeleteforself', 'tool_dataprivacy'));
+ }
+ } else if (!\tool_dataprivacy\api::can_create_data_deletion_request_for_other()
+ && !\tool_dataprivacy\api::can_create_data_deletion_request_for_children($data->userid)) {
+ throw new moodle_exception('nopermissions', 'error', '',
+ get_string('errorcannotrequestdeleteforother', 'tool_dataprivacy'));
+ }
+ }
+
\tool_dataprivacy\api::create_data_request($data->userid, $data->type, $data->comments);
if ($manage) {
echo $OUTPUT->header();
echo $OUTPUT->heading($title);
-echo $OUTPUT->box_start();
+echo $OUTPUT->box_start('createrequestform');
$mform->display();
echo $OUTPUT->box_end();
* Form definition.
*
* @throws coding_exception
+ * @throws dml_exception
*/
public function definition() {
global $USER;
// Action buttons.
$this->add_action_buttons();
+ $shouldfreeze = false;
+ if ($this->manage) {
+ $shouldfreeze = !api::can_create_data_deletion_request_for_other();
+ } else {
+ $shouldfreeze = !api::can_create_data_deletion_request_for_self();
+ if ($shouldfreeze && !empty($useroptions)) {
+ foreach ($useroptions as $userid => $useroption) {
+ if (api::can_create_data_deletion_request_for_children($userid)) {
+ $shouldfreeze = false;
+ break;
+ }
+ }
+ }
+ }
+
+ if ($shouldfreeze) {
+ $mform->freeze('type');
+ }
}
/**
* @throws dml_exception
*/
public function validation($data, $files) {
+ global $USER;
$errors = [];
$validrequesttypes = [
$errors['type'] = get_string('errorrequestalreadyexists', 'tool_dataprivacy');
}
+ // Check if current user can create data deletion request.
+ $userid = $data['userid'];
+ if ($data['type'] == api::DATAREQUEST_TYPE_DELETE) {
+ if ($userid == $USER->id) {
+ if (!api::can_create_data_deletion_request_for_self()) {
+ $errors['type'] = get_string('errorcannotrequestdeleteforself', 'tool_dataprivacy');
+ }
+ } else if (!api::can_create_data_deletion_request_for_other()
+ && !api::can_create_data_deletion_request_for_children($userid)) {
+ $errors['type'] = get_string('errorcannotrequestdeleteforother', 'tool_dataprivacy');
+ }
+ }
+
return $errors;
}
}
'archetypes' => []
],
+ // Capability for create new delete data request. Usually given to the site's Protection Officer.
+ 'tool/dataprivacy:requestdeleteforotheruser' => [
+ 'riskbitmask' => RISK_SPAM | RISK_PERSONAL | RISK_XSS | RISK_DATALOSS,
+ 'captype' => 'write',
+ 'contextlevel' => CONTEXT_SYSTEM,
+ 'archetypes' => [],
+ 'clonepermissionsfrom' => 'tool/dataprivacy:managedatarequests'
+ ],
+
// Capability for managing the data registry. Usually given to the site's Data Protection Officer.
'tool/dataprivacy:managedataregistry' => [
'riskbitmask' => RISK_SPAM | RISK_PERSONAL | RISK_XSS | RISK_DATALOSS,
'archetypes' => []
],
+ // Capability for parents/guardians to make delete data requests on behalf of their children.
+ 'tool/dataprivacy:makedatadeletionrequestsforchildren' => [
+ 'riskbitmask' => RISK_SPAM | RISK_PERSONAL,
+ 'captype' => 'write',
+ 'contextlevel' => CONTEXT_USER,
+ 'archetypes' => [],
+ 'clonepermissionsfrom' => 'tool/dataprivacy:makedatarequestsforchildren'
+ ],
+
// Capability for users to download the results of their own data request.
'tool/dataprivacy:downloadownrequest' => [
'riskbitmask' => 0,
'contextlevel' => CONTEXT_USER,
'archetypes' => []
],
+
+ // Capability for users to create delete data request for their own.
+ 'tool/dataprivacy:requestdelete' => [
+ 'riskbitmask' => RISK_DATALOSS,
+ 'captype' => 'write',
+ 'contextlevel' => CONTEXT_USER,
+ 'archetypes' => [
+ 'user' => CAP_ALLOW
+ ]
+ ]
];
$string['contextpurposecategorysaved'] = 'Purpose and category saved.';
$string['contactdpoviaprivacypolicy'] = 'Please contact the privacy officer as described in the privacy policy.';
$string['createcategory'] = 'Create data category';
+$string['createdeletedatarequest'] = 'Create data deletion request';
$string['createnewdatarequest'] = 'Create a new data request';
$string['createpurpose'] = 'Create data purpose';
$string['creationauto'] = 'Automatically';
$string['dataprivacy:managedataregistry'] = 'Manage data registry';
$string['dataprivacy:downloadownrequest'] = 'Download your own exported data';
$string['dataprivacy:downloadallrequests'] = 'Download exported data for everyone';
+$string['dataprivacy:requestdeleteforotheruser'] = 'Request data deletion on behalf of another user';
+$string['dataprivacy:makedatadeletionrequestsforchildren'] = 'Request data deletion for minors';
+$string['dataprivacy:requestdelete'] = 'Request data deletion for yourself';
$string['dataregistry'] = 'Data registry';
$string['dataregistryinfo'] = 'The data registry enables categories (types of data) and purposes (the reasons for processing data) to be set for all content on the site - from users and courses down to activities and blocks. For each purpose, a retention period may be set. When a retention period has expired, the data is flagged and listed for deletion, awaiting admin confirmation.';
$string['dataretentionexplanation'] = 'This summary shows the default categories and purposes for retaining user data. Certain areas may have more specific categories and purposes than those listed here.';
$string['effectiveretentionperiodcourse'] = '{$a} (after the course end date)';
$string['effectiveretentionperioduser'] = '{$a} (since the last time the user accessed the site)';
$string['emailsalutation'] = 'Dear {$a},';
+$string['errorcannotrequestdeleteforself'] = 'You don\'t have permission to create deletion request for yourself.';
+$string['errorcannotrequestdeleteforother'] = 'You don\'t have permission to create deletion request for this user.';
$string['errorinvalidrequestcreationmethod'] = 'Invalid request creation method!';
$string['errorinvalidrequeststatus'] = 'Invalid request status!';
$string['errorinvalidrequesttype'] = 'Invalid request type!';
// Check if the user has an ongoing data deletion request.
$hasdeleterequest = \tool_dataprivacy\api::has_ongoing_request($user->id, \tool_dataprivacy\api::DATAREQUEST_TYPE_DELETE);
- // Show data deletion link only if the user doesn't have an ongoing data deletion request.
- if (!$hasdeleterequest) {
+ // Show data deletion link only if the user doesn't have an ongoing data deletion request and has permission
+ // to create data deletion request.
+ if (!$hasdeleterequest && \tool_dataprivacy\api::can_create_data_deletion_request_for_self()) {
$deleteparams = ['type' => \tool_dataprivacy\api::DATAREQUEST_TYPE_DELETE];
$deleteurl = new moodle_url('/admin/tool/dataprivacy/createdatarequest.php', $deleteparams);
$deletenode = new core_user\output\myprofile\node('privacyandpolicies', 'requestdatadeletion',
];
if (null !== $confirm && confirm_sesskey()) {
+ if ($originalrequest->get('type') == \tool_dataprivacy\api::DATAREQUEST_TYPE_DELETE
+ && !\tool_dataprivacy\api::can_create_data_deletion_request_for_other()) {
+ throw new required_capability_exception(context_system::instance(),
+ 'tool/dataprivacy:requestdeleteforotheruser', 'nopermissions', '');
+ }
$originalrequest->resubmit_request();
redirect($manageurl, get_string('resubmittedrequest', 'tool_dataprivacy', $stringparams));
}
return $request;
}
+
+ /**
+ * Test user cannot create data deletion request for themselves if they don't have
+ * "tool/dataprivacy:requestdelete" capability.
+ *
+ * @throws coding_exception
+ */
+ public function test_can_create_data_deletion_request_for_self_no() {
+ $this->resetAfterTest();
+ $userid = $this->getDataGenerator()->create_user()->id;
+ $roleid = $this->getDataGenerator()->create_role();
+ assign_capability('tool/dataprivacy:requestdelete', CAP_PROHIBIT, $roleid, context_user::instance($userid));
+ role_assign($roleid, $userid, context_user::instance($userid));
+ $this->setUser($userid);
+ $this->assertFalse(api::can_create_data_deletion_request_for_self());
+ }
+
+ /**
+ * Test user can create data deletion request for themselves if they have
+ * "tool/dataprivacy:requestdelete" capability.
+ *
+ * @throws coding_exception
+ */
+ public function test_can_create_data_deletion_request_for_self_yes() {
+ $this->resetAfterTest();
+ $userid = $this->getDataGenerator()->create_user()->id;
+ $this->setUser($userid);
+ $this->assertTrue(api::can_create_data_deletion_request_for_self());
+ }
+
+ /**
+ * Test user cannot create data deletion request for another user if they
+ * don't have "tool/dataprivacy:requestdeleteforotheruser" capability.
+ *
+ * @throws coding_exception
+ * @throws dml_exception
+ */
+ public function test_can_create_data_deletion_request_for_other_no() {
+ $this->resetAfterTest();
+ $userid = $this->getDataGenerator()->create_user()->id;
+ $this->setUser($userid);
+ $this->assertFalse(api::can_create_data_deletion_request_for_other());
+ }
+
+ /**
+ * Test user can create data deletion request for another user if they
+ * don't have "tool/dataprivacy:requestdeleteforotheruser" capability.
+ *
+ * @throws coding_exception
+ */
+ public function test_can_create_data_deletion_request_for_other_yes() {
+ $this->resetAfterTest();
+ $userid = $this->getDataGenerator()->create_user()->id;
+ $roleid = $this->getDataGenerator()->create_role();
+ $contextsystem = context_system::instance();
+ assign_capability('tool/dataprivacy:requestdeleteforotheruser', CAP_ALLOW, $roleid, $contextsystem);
+ role_assign($roleid, $userid, $contextsystem);
+ $this->setUser($userid);
+ $this->assertTrue(api::can_create_data_deletion_request_for_other($userid));
+ }
+
+ /**
+ * Check parents can create data deletion request for their children but not others.
+ *
+ * @throws coding_exception
+ * @throws dml_exception
+ */
+ public function test_can_create_data_deletion_request_for_children() {
+ $this->resetAfterTest();
+
+ $parent = $this->getDataGenerator()->create_user();
+ $child = $this->getDataGenerator()->create_user();
+ $otheruser = $this->getDataGenerator()->create_user();
+
+ $contextsystem = \context_system::instance();
+ $parentrole = $this->getDataGenerator()->create_role();
+ assign_capability('tool/dataprivacy:makedatarequestsforchildren', CAP_ALLOW,
+ $parentrole, $contextsystem);
+ assign_capability('tool/dataprivacy:makedatadeletionrequestsforchildren', CAP_ALLOW,
+ $parentrole, $contextsystem);
+ role_assign($parentrole, $parent->id, \context_user::instance($child->id));
+
+ $this->setUser($parent);
+ $this->assertTrue(api::can_create_data_deletion_request_for_children($child->id));
+ $this->assertFalse(api::can_create_data_deletion_request_for_children($otheruser->id));
+ }
}
Background:
Given the following "users" exist:
- | username | firstname | lastname |
- | victim | Victim User | 1 |
- | parent | Long-suffering | Parent |
+ | username | firstname | lastname |
+ | victim | Victim User | 1 |
+ | parent | Long-suffering | Parent |
+ | privacyofficer | Privacy Officer | One |
And the following "roles" exist:
| shortname | name | archetype |
| tired | Tired | |
And the following "permission overrides" exist:
- | capability | permission | role | contextlevel | reference |
- | tool/dataprivacy:makedatarequestsforchildren | Allow | tired | System | |
+ | capability | permission | role | contextlevel | reference |
+ | tool/dataprivacy:makedatarequestsforchildren | Allow | tired | System | |
+ | tool/dataprivacy:makedatadeletionrequestsforchildren | Allow | tired | System | |
+ | tool/dataprivacy:managedatarequests | Allow | manager | System | |
And the following "role assigns" exist:
| user | role | contextlevel | reference |
| parent | tired | User | victim |
+ And the following "system role assigns" exist:
+ | user | role | contextlevel |
+ | privacyofficer | manager | User |
And the following config values are set as admin:
| contactdataprotectionofficer | 1 | tool_dataprivacy |
And the following data privacy "categories" exist:
And the following data privacy "purposes" exist:
| name | retentionperiod |
| Site purpose | P10Y |
+ And the following config values are set as admin:
+ | contactdataprotectionofficer | 1 | tool_dataprivacy |
+ | privacyrequestexpiry | 55 | tool_dataprivacy |
+ | dporoles | 1 | tool_dataprivacy |
And I set the site category and purpose to "Site category" and "Site purpose"
@javascript
And I run all adhoc tasks
And I reload the page
And I should see "You don't have any personal data requests"
+
+ @javascript
+ Scenario: As a Privacy Officer, I cannot create data deletion request unless I have permission.
+ Given I log in as "privacyofficer"
+ And I navigate to "Users > Privacy and policies > Data requests" in site administration
+ And I follow "New request"
+ And I open the autocomplete suggestions list
+ And I click on "Victim User 1" item in the autocomplete list
+ Then I should see "Export all of my personal data"
+ And "Type" "select" should not be visible
+ And the following "permission overrides" exist:
+ | capability | permission | role | contextlevel | reference |
+ | tool/dataprivacy:requestdeleteforotheruser | Allow | manager | System | |
+ And I reload the page
+ And I open the autocomplete suggestions list
+ And I click on "Victim User 1" item in the autocomplete list
+ And "Type" "select" should be visible
+
+ @javascript
+ Scenario: As a student, I cannot create data deletion request unless I have permission.
+ Given I log in as "victim"
+ And I follow "Profile" in the user menu
+ And I follow "Data requests"
+ And I follow "New request"
+ Then "Type" "select" should exist
+ And the following "permission overrides" exist:
+ | capability | permission | role | contextlevel | reference |
+ | tool/dataprivacy:requestdelete | Prevent | user | System | |
+ And I reload the page
+ And I should see "Export all of my personal data"
+ And "Type" "select" should not exist
+
+ @javascript
+ Scenario: As a parent, I cannot create data deletion request unless I have permission.
+ Given I log in as "parent"
+ And the following "permission overrides" exist:
+ | capability | permission | role | contextlevel | reference |
+ | tool/dataprivacy:makedatadeletionrequestsforchildren | Prevent | tired | System | victim |
+ And I follow "Profile" in the user menu
+ And I follow "Data requests"
+ And I follow "New request"
+ And I open the autocomplete suggestions list
+ And I click on "Victim User 1" item in the autocomplete list
+ And I set the field "Type" to "Delete all of my personal data"
+ And I press "Save changes"
+ And I should see "You don't have permission to create deletion request for this user."
+ And the following "permission overrides" exist:
+ | capability | permission | role | contextlevel | reference |
+ | tool/dataprivacy:makedatadeletionrequestsforchildren | Allow | tired | System | victim |
+ | tool/dataprivacy:requestdelete | Prevent | user | System | |
+ And I open the autocomplete suggestions list
+ And I click on "Long-suffering Parent" item in the autocomplete list
+ And I press "Save changes"
+ And I should see "You don't have permission to create deletion request for yourself."
+
+ @javascript
+ Scenario: As a student, link to create data deletion should not be shown if I don't have permission.
+ Given the following "permission overrides" exist:
+ | capability | permission | role | contextlevel | reference |
+ | tool/dataprivacy:requestdelete | Prohibit | user | System | |
+ When I log in as "victim"
+ And I follow "Profile" in the user menu
+ Then I should not see "Delete my account"
+
+ @javascript
+ Scenario: As a Privacy Officer, I cannot Approve to Deny deletion data request without permission.
+ Given the following "permission overrides" exist:
+ | capability | permission | role | contextlevel | reference |
+ | tool/dataprivacy:requestdeleteforotheruser | Allow | manager | System | |
+ When I log in as "privacyofficer"
+ And I navigate to "Users > Privacy and policies > Data requests" in site administration
+ And I follow "New request"
+ And I open the autocomplete suggestions list
+ And I click on "Victim User 1" item in the autocomplete list
+ And I set the field "Type" to "Delete all of my personal data"
+ And I press "Save changes"
+ And the following "permission overrides" exist:
+ | capability | permission | role | contextlevel | reference |
+ | tool/dataprivacy:requestdeleteforotheruser | Prohibit | manager | System | |
+ And I reload the page
+ Then ".selectrequests" "css_element" should not exist
+ And I open the action menu in "region-main" "region"
+ And I should not see "Approve request"
+ And I should not see "Deny request"
+ And I choose "View the request" in the open action menu
+ And "Approve" "button" should not exist
+ And "Deny" "button" should not exist
+
+ @javascript
+ Scenario: As a Privacy Officer, I cannot re-submit deletion data request without permission.
+ Given the following "permission overrides" exist:
+ | capability | permission | role | contextlevel | reference |
+ | tool/dataprivacy:requestdeleteforotheruser | Allow | manager | System | |
+ When I log in as "privacyofficer"
+ And I navigate to "Users > Privacy and policies > Data requests" in site administration
+ And I follow "New request"
+ And I open the autocomplete suggestions list
+ And I click on "Victim User 1" item in the autocomplete list
+ And I set the field "Type" to "Delete all of my personal data"
+ And I press "Save changes"
+ And I open the action menu in "region-main" "region"
+ And I follow "Deny request"
+ And I press "Deny request"
+ And the following "permission overrides" exist:
+ | capability | permission | role | contextlevel | reference |
+ | tool/dataprivacy:requestdeleteforotheruser | Prohibit | manager | System | |
+ And I reload the page
+ And I open the action menu in "region-main" "region"
+ Then I should not see "Resubmit as new request"
defined('MOODLE_INTERNAL') || die;
-$plugin->version = 2019011500;
+$plugin->version = 2019040800;
$plugin->requires = 2018112800; // Moodle 3.5dev (Build 2018031600) and upwards.
$plugin->component = 'tool_dataprivacy';
tool_generator_testplan_backend::get_size_choices());
$mform->setDefault('size', tool_generator_testplan_backend::DEFAULT_SIZE);
- $mform->addElement('select', 'courseid', get_string('targetcourse', 'tool_generator'),
- tool_generator_testplan_backend::get_course_options());
+ $mform->addElement('course', 'courseid', get_string('targetcourse', 'tool_generator'));
$mform->addElement('advcheckbox', 'updateuserspassword', get_string('updateuserspassword', 'tool_generator'));
$mform->addHelpButton('updateuserspassword', 'updateuserspassword', 'tool_generator');
return $options;
}
- /**
- * Gets the list of courses that can be used used to generate a test.
- *
- * @return array The list of options as courseid => name
- */
- public static function get_course_options() {
- $courses = get_courses('all', 'c.sortorder ASC', 'c.id, c.shortname, c.fullname');
- if (!$courses) {
- print_error('error_nocourses', 'tool_generator');
- }
-
- $options = array();
- unset($courses[1]);
- foreach ($courses as $course) {
- $options[$course->id] = $course->fullname . '(' . $course->shortname . ')';
- }
- return $options;
- }
-
/**
* Getter for moodle-performance-comparison project URL.
*
--- /dev/null
+This files describes API changes in core libraries and APIs,
+information provided here is intended especially for developers.
+
+=== 3.7 ===
+
+* Function tool_generator_testplan_backend::get_course_options() is removed, the 'course' form element is used instead.
require_once($CFG->dirroot.'/message/lib.php');
require_once('user_message_form.php');
-$msg = optional_param('msg', '', PARAM_CLEANHTML);
+$msg = optional_param('msg', '', PARAM_RAW);
$confirm = optional_param('confirm', 0, PARAM_BOOL);
admin_externalpage_setup('userbulk');
$options->para = false;
$options->newlines = true;
$options->smiley = false;
+ $options->trusted = trusttext_trusted(\context_system::instance());
$msg = format_text($formdata->messagebody['text'], $formdata->messagebody['format'], $options);
return true;
}
+ /**
+ * Is this method valid to evaluate prediction models?
+ *
+ * @return bool
+ */
+ public function valid_for_evaluation(): bool {
+ return true;
+ }
+
/**
* Validates the time splitting method ranges.
*
public function cache_indicator_calculations(): bool {
return false;
}
+
+ /**
+ * Overriden as these time-splitting methods are based on future dates.
+ *
+ * @return bool
+ */
+ public function valid_for_evaluation(): bool {
+ return false;
+ }
}
}
/**
- * Returns the default time splitting methods for model evaluation.
+ * Returns the time-splitting methods for model evaluation.
*
+ * @param bool $all Return all the time-splitting methods that can potentially be used for evaluation or the default ones.
* @return \core_analytics\local\time_splitting\base[]
*/
- public static function get_time_splitting_methods_for_evaluation() {
+ public static function get_time_splitting_methods_for_evaluation(bool $all = false) {
- if ($enabledtimesplittings = get_config('analytics', 'defaulttimesplittingsevaluation')) {
- $enabledtimesplittings = array_flip(explode(',', $enabledtimesplittings));
+ if ($all === false) {
+ if ($enabledtimesplittings = get_config('analytics', 'defaulttimesplittingsevaluation')) {
+ $enabledtimesplittings = array_flip(explode(',', $enabledtimesplittings));
+ }
}
$timesplittings = self::get_all_time_splittings();
foreach ($timesplittings as $key => $timesplitting) {
- // We remove the ones that are not enabled. This also respects the default value (all methods enabled).
- if (!empty($enabledtimesplittings) && !isset($enabledtimesplittings[$key])) {
+ if (!$timesplitting->valid_for_evaluation()) {
unset($timesplittings[$key]);
}
+
+ if ($all === false) {
+ // We remove the ones that are not enabled. This also respects the default value (all methods enabled).
+ if (!empty($enabledtimesplittings) && !isset($enabledtimesplittings[$key])) {
+ unset($timesplittings[$key]);
+ }
+ }
}
return $timesplittings;
}
// Just in case...
$element = clean_param($element, PARAM_ALPHANUMEXT);
- // Core analytics classes (analytics subsystem should not contain uses of the analytics API).
- $classes = \core_component::get_component_classes_in_namespace('core', 'analytics\\' . $element);
-
- // Plugins.
- foreach (\core_component::get_plugin_types() as $type => $unusedplugintypepath) {
- foreach (\core_component::get_plugin_list($type) as $pluginname => $unusedpluginpath) {
- $frankenstyle = $type . '_' . $pluginname;
- $classes += \core_component::get_component_classes_in_namespace($frankenstyle, 'analytics\\' . $element);
- }
- }
-
- // Core subsystems.
- foreach (\core_component::get_core_subsystems() as $subsystemname => $unusedsubsystempath) {
- $componentname = 'core_' . $subsystemname;
- $classes += \core_component::get_component_classes_in_namespace($componentname, 'analytics\\' . $element);
- }
+ $classes = \core_component::get_component_classes_in_namespace(null, 'analytics\\' . $element);
return $classes;
}
$this->assertSame([], $repeated);
}
+
+ /**
+ * test_get_time_splitting_methods description
+ * @return null
+ */
+ public function test_get_time_splitting_methods() {
+ $this->resetAfterTest(true);
+
+ $all = \core_analytics\manager::get_all_time_splittings();
+ $this->assertArrayHasKey('\core\analytics\time_splitting\upcoming_week', $all);
+ $this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $all);
+
+ $allforevaluation = \core_analytics\manager::get_time_splitting_methods_for_evaluation(true);
+ $this->assertArrayNotHasKey('\core\analytics\time_splitting\upcoming_week', $allforevaluation);
+ $this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $allforevaluation);
+
+ $defaultforevaluation = \core_analytics\manager::get_time_splitting_methods_for_evaluation(false);
+ $this->assertArrayNotHasKey('\core\analytics\time_splitting\upcoming_week', $defaultforevaluation);
+ $this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $defaultforevaluation);
+
+ $sometimesplittings = '\core\analytics\time_splitting\single_range,' .
+ '\core\analytics\time_splitting\tenths';
+ set_config('defaulttimesplittingsevaluation', $sometimesplittings, 'analytics');
+
+ $defaultforevaluation = \core_analytics\manager::get_time_splitting_methods_for_evaluation(false);
+ $this->assertArrayNotHasKey('\core\analytics\time_splitting\quarters', $defaultforevaluation);
+ }
}
$this->setAdminuser();
set_config('enabled_stores', 'logstore_standard', 'tool_log');
- $sometimesplittings = '\core\analytics\time_splitting\weekly,' .
- '\core\analytics\time_splitting\single_range,' .
+ $sometimesplittings = '\core\analytics\time_splitting\single_range,' .
'\core\analytics\time_splitting\quarters';
set_config('defaulttimesplittingsevaluation', $sometimesplittings, 'analytics');
'modelquality' => 'random',
'ncourses' => 50,
'expectedresults' => array(
- // The course duration is too much to be processed by in weekly basis.
- '\core\analytics\time_splitting\weekly' => \core_analytics\model::NO_DATASET,
'\core\analytics\time_splitting\single_range' => \core_analytics\model::LOW_SCORE,
'\core\analytics\time_splitting\quarters' => \core_analytics\model::LOW_SCORE,
)
'modelquality' => 'perfect',
'ncourses' => 50,
'expectedresults' => array(
- // The course duration is too much to be processed by in weekly basis.
- '\core\analytics\time_splitting\weekly' => \core_analytics\model::NO_DATASET,
'\core\analytics\time_splitting\single_range' => \core_analytics\model::OK,
'\core\analytics\time_splitting\quarters' => \core_analytics\model::OK,
)
number of ranges.
* Can now overwrite cache_indicator_calculations(). You should return false if the time frames generated
by your time-splitting method are unique and / or can hardly be reused by further models.
+ * Can now overwrite valid_for_evaluation(). You can return false if the time-splitting method can not be
+ used to evaluate prediction models or if it does not make sense to evaluate prediction models with it,
+ as for example upcoming_periodic children classes.
* \core_analytics\local\analyser\base::get_most_recent_prediction_range has been moved to
\core_analytics\local\time_splitting\base::get_most_recent_prediction_range and it is not overwritable
by time splitting methods.
And I log in as "user1"
And I open my profile in edit mode
And the field "First name" matches value "User1"
+
+ Scenario Outline: Email validation during email registration
+ Given the following config values are set as admin:
+ | allowaccountssameemail | <allowsameemail> |
+ | registerauth | email |
+ | passwordpolicy | 0 |
+ And the following "users" exist:
+ | username | firstname | lastname | email |
+ | s1 | John | Doe | s1@example.com |
+ And I am on site homepage
+ And I follow "Log in"
+ When I press "Create new account"
+ And I set the following fields to these values:
+ | Username | s2 |
+ | Password | test |
+ | Email address | <email1> |
+ | Email (again) | <email2> |
+ | First name | Jane |
+ | Surname | Doe |
+ And I press "Create my new account"
+ Then I should <expect> "This email address is already registered. Perhaps you created an account in the past?"
+ And I should <expect2> "Invalid email address"
+
+ Examples:
+ | allowsameemail | email1 | email2 | expect | expect2 |
+ | 0 | s1@example.com | s1@example.com | see | not see |
+ | 0 | S1@EXAMPLE.COM | S1@EXAMPLE.COM | see | not see |
+ | 0 | s1@example.com | S1@EXAMPLE.COM | see | not see |
+ | 0 | s2@example.com | s1@example.com | not see | see |
+ | 1 | s1@example.com | s1@example.com | not see | not see |
+ | 1 | S1@EXAMPLE.COM | S1@EXAMPLE.COM | not see | not see |
+ | 1 | s1@example.com | S1@EXAMPLE.COM | not see | not see |
+ | 1 | s1@example.com | s2@example.com | not see | see |
require_once($CFG->dirroot . '/course/lib.php');
$buttonarray = array();
- // Get courses with enabled completion.
- $courses = $DB->get_records('course', array('enablecompletion' => COMPLETION_ENABLED));
- if (!empty($courses)) {
- $list = core_course_category::make_categories_list();
-
- $select = array();
- $selected = array();
- foreach ($courses as $c) {
- $select[$c->id] = $list[$c->category] . ' / ' . format_string($c->fullname, true, array('context' => context_course::instance($c->id)));
- }
-
- if ($this->id !== 0) {
- $selected = array_keys($this->params);
- }
- $settings = array('multiple' => 'multiple', 'size' => 20, 'style' => 'width:300px');
- $mform->addElement('select', 'courses', get_string('addcourse', 'badges'), $select, $settings);
+ $hasselectablecourses = core_course_category::search_courses(['onlywithcompletion' => true], ['limit' => 1]);
+ if ($hasselectablecourses) {
+ $settings = array('multiple' => 'multiple', 'onlywithcompletion' => 1);
+ $mform->addElement('course', 'courses', get_string('addcourse', 'badges'), $settings);
$mform->addRule('courses', get_string('requiredcourse', 'badges'), 'required');
$mform->addHelpButton('courses', 'addcourse', 'badges');
$mform->addElement('hidden', 'addcourse', 'addcourse');
$mform->setType('addcourse', PARAM_TEXT);
if ($this->id !== 0) {
+ $selected = array_keys($this->params);
$mform->setDefault('courses', $selected);
}
$mform->setType('agg', PARAM_INT);
}
}
+ $allcourselink =
+ (has_capability('moodle/course:update', context_system::instance())
+ || empty($CFG->block_course_list_hideallcourseslink)) &&
+ core_course_category::user_top();
+
if (empty($CFG->disablemycourses) and isloggedin() and !isguestuser() and
!(has_capability('moodle/course:update', context_system::instance()) and $adminseesall)) { // Just print My Courses
if ($courses = enrol_get_my_courses()) {
}
$this->title = get_string('mycourses');
/// If we can update any course of the view all isn't hidden, show the view all courses link
- if (has_capability('moodle/course:update', context_system::instance()) || empty($CFG->block_course_list_hideallcourseslink)) {
+ if ($allcourselink) {
$this->content->footer = "<a href=\"$CFG->wwwroot/course/index.php\">".get_string("fulllistofcourses")."</a> ...";
}
}
}
}
- $categories = core_course_category::get(0)->get_children(); // Parent = 0 ie top-level categories only
- if ($categories) { //Check we have categories
+ // User is not enrolled in any courses, show list of available categories or courses (if there is only one category).
+ $topcategory = core_course_category::top();
+ if ($topcategory->is_uservisible() && ($categories = $topcategory->get_children())) { // Check we have categories.
if (count($categories) > 1 || (count($categories) == 1 && $DB->count_records('course') > 200)) { // Just print top level category links
foreach ($categories as $category) {
$categoryname = $category->get_formatted_name();
$this->content->items[]="<a $linkcss href=\"$CFG->wwwroot/course/index.php?categoryid=$category->id\">".$icon . $categoryname . "</a>";
}
/// If we can update any course of the view all isn't hidden, show the view all courses link
- if (has_capability('moodle/course:update', context_system::instance()) || empty($CFG->block_course_list_hideallcourseslink)) {
+ if ($allcourselink) {
$this->content->footer .= "<a href=\"$CFG->wwwroot/course/index.php\">".get_string('fulllistofcourses').'</a> ...';
}
$this->title = get_string('categories');
} else { // Just print course names of single category
$category = array_shift($categories);
- $courses = get_courses($category->id);
+ $courses = $category->get_courses();
if ($courses) {
foreach ($courses as $course) {
$linkcss = $course->visible ? "" : " class=\"dimmed\" ";
$this->content->items[]="<a $linkcss title=\""
- . format_string($course->shortname, true, array('context' => $coursecontext))."\" ".
+ . s($course->get_formatted_shortname())."\" ".
"href=\"$CFG->wwwroot/course/view.php?id=$course->id\">"
- .$icon. format_string(get_course_display_name_for_list($course), true, array('context' => context_course::instance($course->id))) . "</a>";
+ .$icon. $course->get_formatted_name() . "</a>";
}
/// If we can update any course of the view all isn't hidden, show the view all courses link
- if (has_capability('moodle/course:update', context_system::instance()) || empty($CFG->block_course_list_hideallcourseslink)) {
+ if ($allcourselink) {
$this->content->footer .= "<a href=\"$CFG->wwwroot/course/index.php\">".get_string('fulllistofcourses').'</a> ...';
}
$this->get_remote_courses();
$parentcontext = $context->get_parent_context();
if ($parentcontext->contextlevel === CONTEXT_COURSECAT) {
// Check if category is visible and user can view this category.
- $category = $DB->get_record('course_categories', array('id' => $parentcontext->instanceid), '*', MUST_EXIST);
- if (!$category->visible) {
- require_capability('moodle/category:viewhiddencategories', $parentcontext);
+ if (!core_course_category::get($parentcontext->instanceid, IGNORE_MISSING)) {
+ send_file_not_found();
}
} else if ($parentcontext->contextlevel === CONTEXT_USER && $parentcontext->instanceid != $USER->id) {
// The block is in the context of a user, it is only visible to the user who it belongs to.
use external_files;
use renderer_base;
use context_system;
+use core_tag\external\tag_item_exporter;
/**
* Class for exporting a blog post (entry).
'multiple' => true,
'optional' => true
),
+ 'tags' => array(
+ 'type' => tag_item_exporter::read_properties_definition(),
+ 'description' => 'Tags.',
+ 'multiple' => true,
+ 'optional' => true,
+ ),
);
}
$values['summaryfiles'] = external_util::get_area_files($context->id, 'blog', 'post', $this->data->id);
$values['attachmentfiles'] = external_util::get_area_files($context->id, 'blog', 'attachment', $this->data->id);
+ if ($this->data->module == 'blog_external') {
+ // For external blogs, the content field has the external blog id.
+ $values['tags'] = \core_tag\external\util::get_item_tags('core', 'blog_external', $this->data->content);
+ } else {
+ $values['tags'] = \core_tag\external\util::get_item_tags('core', 'post', $this->data->id);
+ }
return $values;
}
$result = core_blog\external::get_entries();
$result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
$this->assertCount(1, $result['entries']);
+ $this->assertCount(1, $result['entries'][0]['tags']);
+ $this->assertEquals('tag1', $result['entries'][0]['tags'][0]['rawname']);
+
$this->assertEquals($this->postid, $result['entries'][0]['id']);
}
$result = core_blog\external::get_entries();
$result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
$this->assertCount(1, $result['entries']);
+ $this->assertCount(1, $result['entries'][0]['tags']);
+ $this->assertEquals('tag1', $result['entries'][0]['tags'][0]['rawname']);
+
$this->assertEquals($this->postid, $result['entries'][0]['id']);
}
$result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
$this->assertCount(2, $result['entries']);
$this->assertEquals(2, $result['totalentries']);
+ $this->assertCount(0, $result['entries'][0]['tags']);
+ $this->assertCount(1, $result['entries'][1]['tags']);
+ $this->assertEquals('tag1', $result['entries'][1]['tags'][0]['rawname']);
$result = core_blog\external::get_entries(array(), 0, 1);
$result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
$result = core_blog\external::get_entries(array(array('name' => 'courseid', 'value' => $this->courseid)));
$result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
$this->assertCount(1, $result['entries']);
+ $this->assertCount(1, $result['entries'][0]['tags']);
+ $this->assertEquals('tag1', $result['entries'][0]['tags'][0]['rawname']);
// There is no entry associated with a wrong course.
$result = core_blog\external::get_entries(array(array('name' => 'courseid', 'value' => $anothercourse->id)));
+This files describes API changes in /blog/* ,
+information provided here is intended especially for developers.
+
+=== 3.7 ===
+ * External function get_entries now returns an additional field "tags" returning the post tags.
+
=== 2.7 ===
* blog_entry->add_associations() does not accept any params.
return -1;
}
}
+
+ /**
+ * Subclasses may support purging cache of all data belonging to the
+ * current user.
+ */
+ public function purge_current_user() {
+ }
}
/**
$fieldlist = explode(',', $fields);
$prefixedfields = array_map(function($value) {
- return 'c.' . trim($value);
+ return 'c.' . trim(strtolower($value));
}, $fieldlist);
+ if (!in_array('c.visible', $prefixedfields) && !in_array('c.*', $prefixedfields)) {
+ $prefixedfields[] = 'c.visible';
+ }
$courses = get_courses('all', 'c.shortname', implode(',', $prefixedfields));
} else {
$courses = enrol_get_users_courses($userid, true, $fields);
if ($courseid != SITEID && !empty($courseid)) {
navigation_node::override_active_url(new moodle_url('/course/view.php', array('id' => $course->id)));
} else if (!empty($categoryid)) {
+ core_course_category::get($categoryid); // Check that category exists and can be accessed.
$PAGE->set_category_by_id($categoryid);
navigation_node::override_active_url(new moodle_url('/course/index.php', array('categoryid' => $categoryid)));
} else {
protected $theme = false;
/** @var bool */
- protected $fromcache;
-
- /** @var bool */
- protected $hasmanagecapability = null;
+ protected $fromcache = false;
/**
* Magic setter method, we do not want anybody to modify properties from the outside
*/
public static function get($id, $strictness = MUST_EXIST, $alwaysreturnhidden = false, $user = null) {
if (!$id) {
- if (!isset(self::$coursecat0)) {
- $record = new stdClass();
- $record->id = 0;
- $record->visible = 1;
- $record->depth = 0;
- $record->path = '';
- $record->locked = 0;
- self::$coursecat0 = new self($record);
+ // Top-level category.
+ if ($alwaysreturnhidden || self::top()->is_uservisible()) {
+ return self::top();
}
- return self::$coursecat0;
+ if ($strictness == MUST_EXIST) {
+ throw new moodle_exception('cannotviewcategory');
+ }
+ return null;
}
+
+ // Try to get category from cache or retrieve from the DB.
$coursecatrecordcache = cache::make('core', 'coursecatrecords');
$coursecat = $coursecatrecordcache->get($id);
if ($coursecat === false) {
$coursecatrecordcache->set($id, $coursecat);
}
}
- if ($coursecat && ($alwaysreturnhidden || $coursecat->is_uservisible($user))) {
- return $coursecat;
- } else {
+
+ if (!$coursecat) {
+ // Course category not found.
if ($strictness == MUST_EXIST) {
throw new moodle_exception('unknowncategory');
}
+ $coursecat = null;
+ } else if (!$alwaysreturnhidden && !$coursecat->is_uservisible($user)) {
+ // Course category is found but user can not access it.
+ if ($strictness == MUST_EXIST) {
+ throw new moodle_exception('cannotviewcategory');
+ }
+ $coursecat = null;
}
- return null;
+ return $coursecat;
+ }
+
+ /**
+ * Returns the pseudo-category representing the whole system (id=0, context_system)
+ *
+ * @return core_course_category
+ */
+ public static function top() {
+ if (!isset(self::$coursecat0)) {
+ $record = new stdClass();
+ $record->id = 0;
+ $record->visible = 1;
+ $record->depth = 0;
+ $record->path = '';
+ $record->locked = 0;
+ self::$coursecat0 = new self($record);
+ }
+ return self::$coursecat0;
+ }
+
+ /**
+ * Returns the top-most category for the current user
+ *
+ * Examples:
+ * 1. User can browse courses everywhere - return self::top() - pseudo-category with id=0
+ * 2. User does not have capability to browse courses on the system level but
+ * has it in ONE course category - return this course category
+ * 3. User has capability to browse courses in two course categories - return self::top()
+ *
+ * @return core_course_category|null
+ */
+ public static function user_top() {
+ $children = self::top()->get_children();
+ if (count($children) == 1) {
+ // User has access to only one category on the top level. Return this category as "user top category".
+ return reset($children);
+ }
+ if (count($children) > 1) {
+ // User has access to more than one category on the top level. Return the top as "user top category".
+ // In this case user actually may not have capability 'moodle/course:browse' on the top level.
+ return self::top();
+ }
+ // User can not access any categories on the top level.
+ // TODO MDL-10965 find ANY/ALL categories in the tree where user has access to.
+ return self::get(0, IGNORE_MISSING);
}
/**
* @return core_course_category
*/
public static function get_default() {
- if ($visiblechildren = self::get(0)->get_children()) {
+ if ($visiblechildren = self::top()->get_children()) {
$defcategory = reset($visiblechildren);
} else {
$toplevelcategories = self::get_tree(0);
* during {@link fix_course_sortorder()}
*/
protected function restore() {
+ if (!$this->id) {
+ return;
+ }
// Update all fields in the current object.
$newrecord = self::get($this->id, MUST_EXIST, true);
foreach (self::$coursecatfields as $key => $unused) {
}
if (empty($data->parent)) {
- $parent = self::get(0);
+ $parent = self::top();
} else {
$parent = self::get($data->parent, MUST_EXIST, true);
}
* @return bool
*/
public function is_uservisible($user = null) {
- return !$this->id || $this->visible ||
- has_capability('moodle/category:viewhiddencategories', $this->get_context(), $user);
+ return self::can_view_category($this, $user);
+ }
+
+ /**
+ * Checks if current user has access to the category
+ *
+ * @param stdClass|core_course_category $category
+ * @param int|stdClass $user The user id or object. By default (null) checks access for the current user.
+ * @return bool
+ */
+ public static function can_view_category($category, $user = null) {
+ if (!$category->id) {
+ return has_capability('moodle/course:browse', context_system::instance(), $user);
+ }
+ $context = context_coursecat::instance($category->id);
+ if (!$category->visible && !has_capability('moodle/category:viewhiddencategories', $context, $user)) {
+ return false;
+ }
+ return has_capability('moodle/course:browse', $context, $user);
+ }
+
+ /**
+ * Checks if current user can view course information or enrolment page.
+ *
+ * This method does not check if user is already enrolled in the course
+ *
+ * @param stdClass $course course object (must have 'id', 'visible' and 'category' fields)
+ * @param null|stdClass $user The user id or object. By default (null) checks access for the current user.
+ */
+ public static function can_view_course_info($course, $user = null) {
+ if ($course->id == SITEID) {
+ return true;
+ }
+ if (!$course->visible) {
+ $coursecontext = context_course::instance($course->id);
+ if (!has_capability('moodle/course:viewhiddencourses', $coursecontext, $user)) {
+ return false;
+ }
+ }
+ $categorycontext = isset($course->category) ? context_coursecat::instance($course->category) :
+ context_course::instance($course->id)->get_parent_context();
+ return has_capability('moodle/course:browse', $categorycontext, $user);
}
/**
* Returns number of ALL categories in the system regardless if
* they are visible to current user or not
*
+ * @deprecated since Moodle 3.7
* @return int
*/
public static function count_all() {
+ debugging('Method core_course_category::count_all() is deprecated. Please use ' .
+ 'core_course_category::is_simple_site()', DEBUG_DEVELOPER);
return self::get_tree('countall');
}
+ /**
+ * Checks if the site has only one category and it is visible and available.
+ *
+ * In many situations we won't show this category at all
+ * @return bool
+ */
+ public static function is_simple_site() {
+ if (self::get_tree('countall') != 1) {
+ return false;
+ }
+ $default = self::get_default();
+ return $default->visible && $default->is_uservisible();
+ }
+
/**
* Retrieves number of records from course_categories table
*
*
* @param string $whereclause
* @param array $params
- * @param array $options may indicate that summary and/or coursecontacts need to be retrieved
+ * @param array $options may indicate that summary needs to be retrieved
* @param bool $checkvisibility if true, capability 'moodle/course:viewhiddencourses' will be checked
- * on not visible courses
+ * on not visible courses and 'moodle/course:browse' on all courses
* @return array array of stdClass objects
*/
protected static function get_course_records($whereclause, $params, $options, $checkvisibility = false) {
array('contextcourse' => CONTEXT_COURSE) + $params);
if ($checkvisibility) {
+ $mycourses = enrol_get_my_courses();
// Loop through all records and make sure we only return the courses accessible by user.
foreach ($list as $course) {
if (isset($list[$course->id]->hassummary)) {
$list[$course->id]->hassummary = strlen($list[$course->id]->hassummary) > 0;
}
- if (empty($course->visible)) {
- // Load context only if we need to check capability.
- context_helper::preload_from_record($course);
- if (!has_capability('moodle/course:viewhiddencourses', context_course::instance($course->id))) {
- unset($list[$course->id]);
- }
+ context_helper::preload_from_record($course);
+ $context = context_course::instance($course->id);
+ // Check that course is accessible by user.
+ if (!array_key_exists($course->id, $mycourses) && !self::can_view_course_info($course)) {
+ unset($list[$course->id]);
}
}
}
- // Preload course contacts if necessary.
- if (!empty($options['coursecontacts'])) {
- self::preload_course_contacts($list);
- }
- // Preload custom fields if necessary - saves DB queries later to do it for each course separately.
- if (!empty($options['customfields'])) {
- self::preload_custom_fields($list);
- }
return $list;
}
if (($invisibleids = $coursecatcache->get('ic'. $this->id)) === false) {
// We never checked visible children before.
$hidden = self::get_tree($this->id.'i');
+ $catids = self::get_tree($this->id);
$invisibleids = array();
- if ($hidden) {
+ if ($catids) {
// Preload categories contexts.
- list($sql, $params) = $DB->get_in_or_equal($hidden, SQL_PARAMS_NAMED, 'id');
+ list($sql, $params) = $DB->get_in_or_equal($catids, SQL_PARAMS_NAMED, 'id');
$ctxselect = context_helper::get_preload_record_columns_sql('ctx');
$contexts = $DB->get_records_sql("SELECT $ctxselect FROM {context} ctx
WHERE ctx.contextlevel = :contextcoursecat AND ctx.instanceid ".$sql,
foreach ($contexts as $record) {
context_helper::preload_from_record($record);
}
- // Check that user has 'viewhiddencategories' capability for each hidden category.
- foreach ($hidden as $id) {
- if (!has_capability('moodle/category:viewhiddencategories', context_coursecat::instance($id))) {
+ // Check access for each category.
+ foreach ($catids as $id) {
+ $cat = (object)['id' => $id, 'visible' => in_array($id, $hidden) ? 0 : 1];
+ if (!self::can_view_category($cat)) {
$invisibleids[] = $id;
}
}
* - blocklist - id of block (if we are searching for courses containing specific block0
* - modulelist - name of module (if we are searching for courses containing specific module
* - tagid - id of tag
+ * - onlywithcompletion - set to true if we only need courses with completion enabled
* @param array $options display options, same as in get_courses() except 'recursive' is ignored -
* search is always category-independent
* @param array $requiredcapabilities List of capabilities required to see return course.
if (empty($search['blocklist']) && empty($search['modulelist']) && empty($search['tagid'])) {
// Search courses that have specified words in their names/summaries.
$searchterms = preg_split('|\s+|', trim($search['search']), 0, PREG_SPLIT_NO_EMPTY);
-
- $courselist = get_courses_search($searchterms, 'c.sortorder ASC', 0, 9999999, $totalcount, $requiredcapabilities);
+ $searchcond = $searchcondparams = [];
+ if (!empty($search['onlywithcompletion'])) {
+ $searchcond = ['c.enablecompletion = :p1'];
+ $searchcondparams = ['p1' => 1];
+ }
+ $courselist = get_courses_search($searchterms, 'c.sortorder ASC', 0, 9999999, $totalcount,
+ $requiredcapabilities, $searchcond, $searchcondparams);
self::sort_records($courselist, $sortfields);
$coursecatcache->set($cachekey, array_keys($courselist));
$coursecatcache->set($cntcachekey, $totalcount);
$limit = !empty($options['limit']) ? $options['limit'] : null;
$sortfields = !empty($options['sort']) ? $options['sort'] : array('sortorder' => 1);
- // Check if this category is hidden.
- // Also 0-category never has courses unless this is recursive call.
- if (!$this->is_uservisible() || (!$this->id && !$recursive)) {
- return array();
+ if (!$this->id && !$recursive) {
+ // There are no courses on system level unless we need recursive list.
+ return [];
}
$coursecatcache = cache::make('core', 'coursecat');
$thislist = preg_split('|,|', $thislist, -1, PREG_SPLIT_NO_EMPTY);
}
} else if ($baselist !== false) {
- $thislist = array_keys($baselist);
+ $thislist = array_keys(array_filter($baselist, function($el) {
+ return $el['name'] !== false;
+ }));
}
if ($baselist === false) {
$baselist = array();
$thislist = array();
foreach ($rs as $record) {
- // If the category's parent is not visible to the user, it is not visible as well.
- if (!$record->parent || isset($baselist[$record->parent])) {
- context_helper::preload_from_record($record);
- $context = context_coursecat::instance($record->id);
- if (!$record->visible && !has_capability('moodle/category:viewhiddencategories', $context)) {
- // No cap to view category, added to neither $baselist nor $thislist.
- continue;
- }
- $baselist[$record->id] = array(
- 'name' => format_string($record->name, true, array('context' => $context)),
- 'path' => $record->path
- );
- if (!empty($requiredcapability) && !has_all_capabilities($requiredcapability, $context)) {
- // No required capability, added to $baselist but not to $thislist.
- continue;
- }
- $thislist[] = $record->id;
+ context_helper::preload_from_record($record);
+ $context = context_coursecat::instance($record->id);
+ $canview = self::can_view_category($record);
+ $baselist[$record->id] = array(
+ 'name' => $canview ? format_string($record->name, true, array('context' => $context)) : false,
+ 'path' => $record->path
+ );
+ if (!$canview || (!empty($requiredcapability) && !has_all_capabilities($requiredcapability, $context))) {
+ // No required capability, added to $baselist but not to $thislist.
+ continue;
}
+ $thislist[] = $record->id;
}
$rs->close();
$coursecatcache->set($basecachekey, $baselist);
$contexts = $DB->get_records_sql($sql, array('contextcoursecat' => CONTEXT_COURSECAT));
$thislist = array();
foreach (array_keys($baselist) as $id) {
- context_helper::preload_from_record($contexts[$id]);
- if (has_all_capabilities($requiredcapability, context_coursecat::instance($id))) {
- $thislist[] = $id;
+ if ($baselist[$id]['name'] !== false) {
+ context_helper::preload_from_record($contexts[$id]);
+ if (has_all_capabilities($requiredcapability, context_coursecat::instance($id))) {
+ $thislist[] = $id;
+ }
}
}
$coursecatcache->set($thiscachekey, join(',', $thislist));
if (!$excludeid || !in_array($excludeid, $path)) {
$namechunks = array();
foreach ($path as $parentid) {
- $namechunks[] = $baselist[$parentid]['name'];
+ if (array_key_exists($parentid, $baselist) && $baselist[$parentid]['name'] !== false) {
+ $namechunks[] = $baselist[$parentid]['name'];
+ }
}
$names[$id] = join($separator, $namechunks);
}
* @return bool
*/
public static function can_create_top_level_category() {
- return has_capability('moodle/category:manage', context_system::instance());
+ return self::top()->has_manage_capability();
}
/**
* @return bool
*/
public function has_manage_capability() {
- if ($this->hasmanagecapability === null) {
- $this->hasmanagecapability = has_capability('moodle/category:manage', $this->get_context());
+ if (!$this->is_uservisible()) {
+ return false;
}
- return $this->hasmanagecapability;
+ return has_capability('moodle/category:manage', $this->get_context());
}
/**
* @return bool
*/
public function parent_has_manage_capability() {
- return has_capability('moodle/category:manage', get_category_or_system_context($this->parent));
+ return ($parent = $this->get_parent_coursecat()) && $parent->has_manage_capability();
}
/**
* @return bool
*/
public function can_change_sortorder() {
- return $this->id && $this->get_parent_coursecat()->can_resort_subcategories();
+ return ($parent = $this->get_parent_coursecat()) && $parent->can_resort_subcategories();
}
/**
* @return bool
*/
public function can_create_course() {
- return has_capability('moodle/course:create', $this->get_context());
+ return $this->is_uservisible() && has_capability('moodle/course:create', $this->get_context());
}
/**
* @return bool
*/
public function can_review_roles() {
- return has_capability('moodle/role:assign', $this->get_context());
+ return $this->is_uservisible() && has_capability('moodle/role:assign', $this->get_context());
}
/**
* @return bool
*/
public function can_review_permissions() {
- return has_any_capability(array(
+ return $this->is_uservisible() &&
+ has_any_capability(array(
'moodle/role:assign',
'moodle/role:safeoverride',
'moodle/role:override',
* @return bool
*/
public function can_review_cohorts() {
- return has_any_capability(array('moodle/cohort:view', 'moodle/cohort:manage'), $this->get_context());
+ return $this->is_uservisible() &&
+ has_any_capability(array('moodle/cohort:view', 'moodle/cohort:manage'), $this->get_context());
}
/**
* @return bool
*/
public function can_review_filters() {
- return has_capability('moodle/filter:manage', $this->get_context()) &&
- count(filter_get_available_in_context($this->get_context())) > 0;
+ return $this->is_uservisible() &&
+ has_capability('moodle/filter:manage', $this->get_context()) &&
+ count(filter_get_available_in_context($this->get_context())) > 0;
}
/**
* @return bool
*/
public function can_restore_courses_into() {
- return has_capability('moodle/restore:restorecourse', $this->get_context());
+ return $this->is_uservisible() && has_capability('moodle/restore:restorecourse', $this->get_context());
}
/**
/**
* Returns the parent core_course_category object for this category.
*
- * @return core_course_category
+ * Only returns parent if it exists and is visible to the current user
+ *
+ * @return core_course_category|null
*/
public function get_parent_coursecat() {
- return self::get($this->parent);
+ if (!$this->id) {
+ return null;
+ }
+ return self::get($this->parent, IGNORE_MISSING);
}
return context_course::instance($this->__get('id'));
}
- /**
- * Returns true if this course is visible to the current user.
- * @return bool
- */
- public function is_uservisible() {
- return $this->visible || has_capability('moodle/course:viewhiddencourses', $this->get_context());
- }
-
/**
* Returns true if the current user can review enrolments for this course.
*
$baseurl = new \moodle_url('/course/management.php', $params);
$actions = array();
// View.
- if ($course->is_uservisible()) {
- $actions['view'] = array(
- 'url' => new \moodle_url('/course/view.php', array('id' => $course->id)),
- 'string' => \get_string('view')
- );
- }
+ $actions['view'] = array(
+ 'url' => new \moodle_url('/course/view.php', array('id' => $course->id)),
+ 'string' => \get_string('view')
+ );
// Edit.
if ($course->can_edit()) {
$actions['edit'] = array(
$searchcriteria = array('modulelist' => $modulelist);
}
- $courses = \core_course_category::get(0)->search_courses($searchcriteria, array(
+ $topcat = \core_course_category::top();
+ $courses = $topcat->search_courses($searchcriteria, array(
'recursive' => true,
'offset' => $page * $perpage,
'limit' => $perpage,
'sort' => array('fullname' => 1)
));
- $totalcount = \core_course_category::get(0)->search_courses_count($searchcriteria, array('recursive' => true));
+ $totalcount = $topcat->search_courses_count($searchcriteria, array('recursive' => true));
return array($courses, \count($courses), $totalcount);
}
$catatlevel[] = array_shift($selectedparents);
$catatlevel = array_unique($catatlevel);
- $listing = core_course_category::get(0)->get_children();
+ $listing = core_course_category::top()->get_children();
$attributes = array(
'class' => 'ml-1 list-unstyled',
'aria-expanded' => $isexpanded ? 'true' : 'false'
);
$text = $category->get_formatted_name();
- if ($category->parent) {
+ if (($parent = $category->get_parent_coursecat()) && $parent->id) {
$a = new stdClass;
$a->category = $text;
- $a->parentcategory = $category->get_parent_coursecat()->get_formatted_name();
+ $a->parentcategory = $parent->get_formatted_name();
$textlabel = get_string('categorysubcategoryof', 'moodle', $a);
}
$courseicon = $this->output->pix_icon('i/course', get_string('courses'));
$cancreatecategory = $category && $category->can_create_subcategory();
$cancreatecategory = $cancreatecategory || core_course_category::can_create_top_level_category();
if ($category === null) {
- $category = core_course_category::get(0);
+ $category = core_course_category::top();
}
if ($cancreatecategory) {
}
if (core_course_category::can_change_parent_any()) {
$options = array();
- if (has_capability('moodle/category:manage', context_system::instance())) {
- $options[0] = core_course_category::get(0)->get_formatted_name();
+ if (core_course_category::top()->has_manage_capability()) {
+ $options[0] = core_course_category::top()->get_formatted_name();
}
$options += core_course_category::make_categories_list('moodle/category:manage');
$select = html_writer::select(
return \core_search\manager::ACCESS_DELETED;
}
- $coursecontext = \context_course::instance($course->id);
-
- if ($course->visible || has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
+ if (\core_course_category::can_view_course_info($course)) {
return \core_search\manager::ACCESS_GRANTED;
}
if (!$course) {
return \core_search\manager::ACCESS_DELETED;
}
- $coursecontext = \context_course::instance($course->id);
- if ($course->visible || has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
+ if (\core_course_category::can_view_course_info($course)) {
return \core_search\manager::ACCESS_GRANTED;
}
return \core_search\manager::ACCESS_DENIED;
}
// Get applicable courses (prerequisites).
- $courses = $DB->get_records_sql("
- SELECT DISTINCT c.id, c.category, c.fullname, cc.id AS selected
- FROM {course} c
- LEFT JOIN {course_completion_criteria} cc ON cc.courseinstance = c.id AND cc.course = {$course->id}
- INNER JOIN {course_completion_criteria} ccc ON ccc.course = c.id
- WHERE c.enablecompletion = ".COMPLETION_ENABLED."
- AND c.id <> {$course->id}");
-
- if (!empty($courses)) {
- // Get category list.
- $list = core_course_category::make_categories_list();
-
- // Get course list for select box.
- $selectbox = array();
- $selected = array();
- foreach ($courses as $c) {
- $selectbox[$c->id] = $list[$c->category] . ' / ' . format_string($c->fullname, true,
- array('context' => context_course::instance($c->id)));
-
- // If already selected ...
- if ($c->selected) {
- $selected[] = $c->id;
- }
- }
-
+ $selectedcourses = $DB->get_fieldset_sql("SELECT cc.courseinstance
+ FROM {course_completion_criteria} cc WHERE cc.course = ?", [$course->id]);
+ $hasselectablecourses = core_course_category::search_courses(['onlywithcompletion' => true], ['limit' => 2]);
+ unset($hasselectablecourses[$course->id]);
+ if ($hasselectablecourses) {
// Show multiselect box.
- $mform->addElement('select', 'criteria_course', get_string('coursesavailable', 'completion'), $selectbox,
- array('multiple' => 'multiple', 'size' => 6));
-
- // Select current criteria.
- $mform->setDefault('criteria_course', $selected);
-
- // Explain list.
- $mform->addElement('static', 'criteria_courses_explaination', '', get_string('coursesavailableexplaination', 'completion'));
+ $mform->addElement('course', 'criteria_course', get_string('coursesavailable', 'completion'),
+ array('multiple' => 'multiple', 'onlywithcompletion' => true, 'exclude' => $course->id));
+ $mform->setDefault('criteria_course', $selectedcourses);
- if (count($courses) > 1) {
- // Map aggregation methods to context-sensitive human readable dropdown menu.
- $courseaggregationmenu = array();
- foreach ($aggregation_methods as $methodcode => $methodname) {
- if ($methodcode === COMPLETION_AGGREGATION_ALL) {
- $courseaggregationmenu[COMPLETION_AGGREGATION_ALL] = get_string('courseaggregation_all', 'core_completion');
- } else if ($methodcode === COMPLETION_AGGREGATION_ANY) {
- $courseaggregationmenu[COMPLETION_AGGREGATION_ANY] = get_string('courseaggregation_any', 'core_completion');
- } else {
- $courseaggregationmenu[$methodcode] = $methodname;
- }
+ // Map aggregation methods to context-sensitive human readable dropdown menu.
+ $courseaggregationmenu = array();
+ foreach ($aggregation_methods as $methodcode => $methodname) {
+ if ($methodcode === COMPLETION_AGGREGATION_ALL) {
+ $courseaggregationmenu[COMPLETION_AGGREGATION_ALL] = get_string('courseaggregation_all', 'core_completion');
+ } else if ($methodcode === COMPLETION_AGGREGATION_ANY) {
+ $courseaggregationmenu[COMPLETION_AGGREGATION_ANY] = get_string('courseaggregation_any', 'core_completion');
+ } else {
+ $courseaggregationmenu[$methodcode] = $methodname;
}
- $mform->addElement('select', 'course_aggregation', get_string('courseaggregation', 'core_completion'), $courseaggregationmenu);
- $mform->setDefault('course_aggregation', $completion->get_aggregation_method(COMPLETION_CRITERIA_TYPE_COURSE));
}
-
+ $mform->addElement('select', 'course_aggregation', get_string('courseaggregation', 'core_completion'), $courseaggregationmenu);
+ $mform->setDefault('course_aggregation', $completion->get_aggregation_method(COMPLETION_CRITERIA_TYPE_COURSE));
} else {
$mform->addElement('static', 'nocourses', '', get_string('err_nocourses', 'completion'));
}
'userid' => new external_value(PARAM_INT, 'User who added this content to moodle'),
'author' => new external_value(PARAM_TEXT, 'Content owner'),
'license' => new external_value(PARAM_TEXT, 'Content license'),
+ 'tags' => new external_multiple_structure(
+ \core_tag\external\tag_item_exporter::get_read_structure(), 'Tags',
+ VALUE_OPTIONAL
+ ),
)
), VALUE_DEFAULT, array()
),
if (!isset($excludedcats[$category->id])) {
// Final check to see if the category is visible to the user.
- if ($category->visible or has_capability('moodle/category:viewhiddencategories', $context)) {
+ if (core_course_category::can_view_category($category)) {
$categoryinfo = array();
$categoryinfo['id'] = $category->id;
'Optional list of required capabilities (used to filter the list)', VALUE_DEFAULT, array()
),
'limittoenrolled' => new external_value(PARAM_BOOL, 'limit to enrolled courses', VALUE_DEFAULT, 0),
+ 'onlywithcompletion' => new external_value(PARAM_BOOL, 'limit to courses where completion is enabled',
+ VALUE_DEFAULT, 0),
)
);
}
* @param int $perpage Items per page
* @param array $requiredcapabilities Optional list of required capabilities (used to filter the list).
* @param int $limittoenrolled Limit to only enrolled courses
+ * @param int onlywithcompletion Limit to only courses where completion is enabled
* @return array of course objects and warnings
* @since Moodle 3.0
* @throws moodle_exception
$page=0,
$perpage=0,
$requiredcapabilities=array(),
- $limittoenrolled=0) {
+ $limittoenrolled=0,
+ $onlywithcompletion=0) {
global $CFG;
$warnings = array();
'criteriavalue' => $criteriavalue,
'page' => $page,
'perpage' => $perpage,
- 'requiredcapabilities' => $requiredcapabilities
+ 'requiredcapabilities' => $requiredcapabilities,
+ 'limittoenrolled' => $limittoenrolled,
+ 'onlywithcompletion' => $onlywithcompletion
);
$params = self::validate_parameters(self::search_courses_parameters(), $parameters);
self::validate_context(context_system::instance());
// Prepare the search API options.
$searchcriteria = array();
$searchcriteria[$params['criterianame']] = $params['criteriavalue'];
+ if ($params['onlywithcompletion']) {
+ $searchcriteria['onlywithcompletion'] = true;
+ }
$options = array();
if ($params['perpage'] != 0) {
}
// Get the public course information, even if we are not enrolled.
$courseinlist = new core_course_list_element($course);
- $coursesdata[$course->id] = self::get_course_public_information($courseinlist, $context);
// Now, check if we have access to the course.
try {
self::validate_context($context);
} catch (Exception $e) {
+ // User can not access the course, check if they can see the public information about the course and return it.
+ if (core_course_category::can_view_course_info($course)) {
+ $coursesdata[$course->id] = self::get_course_public_information($courseinlist, $context);
+ }
continue;
}
+ $coursesdata[$course->id] = self::get_course_public_information($courseinlist, $context);
// Return information for any user that can access the course.
$coursefields = array('format', 'showgrades', 'newsitems', 'startdate', 'enddate', 'maxbytes', 'showreports', 'visible',
'groupmode', 'groupmodeforce', 'defaultgroupingid', 'enablecompletion', 'completionnotify', 'lang', 'theme',
$categoryid = optional_param('categoryid', 0, PARAM_INT); // Category id
$site = get_site();
+if ($CFG->forcelogin) {
+ require_login();
+}
+
+$heading = $site->fullname;
if ($categoryid) {
+ $category = core_course_category::get($categoryid); // This will validate access.
$PAGE->set_category_by_id($categoryid);
$PAGE->set_url(new moodle_url('/course/index.php', array('categoryid' => $categoryid)));
$PAGE->set_pagetype('course-index-category');
- // And the object has been loaded for us no need for another DB call
- $category = $PAGE->category;
-} else {
- // Check if there is only one category, if so use that.
- if (core_course_category::count_all() == 1) {
- $category = core_course_category::get_default();
-
- $categoryid = $category->id;
+ $heading = $category->get_formatted_name();
+} else if ($category = core_course_category::user_top()) {
+ // Check if there is only one top-level category, if so use that.
+ $categoryid = $category->id;
+ $PAGE->set_url('/course/index.php');
+ if ($category->is_uservisible() && $categoryid) {
$PAGE->set_category_by_id($categoryid);
- $PAGE->set_pagetype('course-index-category');
+ $PAGE->set_context($category->get_context());
+ if (!core_course_category::is_simple_site()) {
+ $PAGE->set_url(new moodle_url('/course/index.php', array('categoryid' => $categoryid)));
+ $heading = $category->get_formatted_name();
+ }
} else {
$PAGE->set_context(context_system::instance());
}
-
- $PAGE->set_url('/course/index.php');
+ $PAGE->set_pagetype('course-index-category');
+} else {
+ throw new moodle_exception('cannotviewcategory');
}
$PAGE->set_pagelayout('coursecategory');
$courserenderer = $PAGE->get_renderer('core', 'course');
-if ($CFG->forcelogin) {
- require_login();
-}
-
-if ($categoryid && !$category->visible && !has_capability('moodle/category:viewhiddencategories', $PAGE->context)) {
- throw new moodle_exception('unknowncategory');
-}
-
-$PAGE->set_heading($site->fullname);
+$PAGE->set_heading($heading);
$content = $courserenderer->course_category($categoryid);
echo $OUTPUT->header();
}
$context = context_course::instance($course->id);
- if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $context)) {
- print_error('coursehidden', '', $CFG->wwwroot .'/');
+ if (!core_course_category::can_view_course_info($course) && !is_enrolled($context, null, '', true)) {
+ print_error('cannotviewcategory', '', $CFG->wwwroot .'/');
}
$PAGE->set_course($course);
echo $OUTPUT->single_button(new moodle_url('/course/request.php'), get_string('requestcourse'), 'get');
}
/// Print a button to manage pending requests
- if ($context->contextlevel == CONTEXT_SYSTEM && has_capability('moodle/site:approvecourse', $context)) {
+ if (has_capability('moodle/site:approvecourse', $context)) {
$disabled = !$DB->record_exists('course_request', array());
echo $OUTPUT->single_button(new moodle_url('/course/pending.php'), get_string('coursespending'), 'get', array('disabled' => $disabled));
}
} else {
$course = null;
$courseid = null;
- $category = core_course_category::get_default();
- $categoryid = $category->id;
+ $topchildren = core_course_category::top()->get_children();
+ $category = reset($topchildren);
+ $categoryid = $category ? $category->id : 0;
$context = context_coursecat::instance($category->id);
$url->param('categoryid', $category->id);
}
}
$categories = core_course_category::get_many($categoryids);
} else if ($for === 'allcategories') {
- if ($sortcategoriesby && core_course_category::get(0)->can_resort_subcategories()) {
+ if ($sortcategoriesby && core_course_category::top()->can_resort_subcategories()) {
\core_course\management\helper::action_category_resort_subcategories(
- core_course_category::get(0), $sortcategoriesby);
+ core_course_category::top(), $sortcategoriesby);
}
$categorieslist = core_course_category::make_categories_list('moodle/category:manage');
$categoryids = array_keys($categorieslist);
*/
public function course_category($category) {
global $CFG;
- $coursecat = core_course_category::get(is_object($category) ? $category->id : $category);
+ $usertop = core_course_category::user_top();
+ if (empty($category)) {
+ $coursecat = $usertop;
+ } else if (is_object($category) && $category instanceof core_course_category) {
+ $coursecat = $category;
+ } else {
+ $coursecat = core_course_category::get(is_object($category) ? $category->id : $category);
+ }
$site = get_site();
$output = '';
- if (can_edit_in_category($coursecat->id)) {
+ if ($coursecat->can_create_course() || $coursecat->has_manage_capability()) {
// Add 'Manage' button if user has permissions to edit this category.
$managebutton = $this->single_button(new moodle_url('/course/management.php',
array('categoryid' => $coursecat->id)), get_string('managecourses'), 'get');
$this->page->set_button($managebutton);
}
- if (!$coursecat->id) {
- if (core_course_category::count_all() == 1) {
- // There exists only one category in the system, do not display link to it
- $coursecat = core_course_category::get_default();
- $strfulllistofcourses = get_string('fulllistofcourses');
- $this->page->set_title("$site->shortname: $strfulllistofcourses");
- } else {
- $strcategories = get_string('categories');
- $this->page->set_title("$site->shortname: $strcategories");
- }
+
+ if (core_course_category::is_simple_site()) {
+ // There is only one category in the system, do not display link to it.
+ $strfulllistofcourses = get_string('fulllistofcourses');
+ $this->page->set_title("$site->shortname: $strfulllistofcourses");
+ } else if (!$coursecat->id || !$coursecat->is_uservisible()) {
+ $strcategories = get_string('categories');
+ $this->page->set_title("$site->shortname: $strcategories");
} else {
- $title = $site->shortname;
- if (core_course_category::count_all() > 1) {
- $title .= ": ". $coursecat->get_formatted_name();
- }
- $this->page->set_title($title);
+ $strfulllistofcourses = get_string('fulllistofcourses');
+ $this->page->set_title("$site->shortname: $strfulllistofcourses");
// Print the category selector
- if (core_course_category::count_all() > 1) {
+ $categorieslist = core_course_category::make_categories_list();
+ if (count($categorieslist) > 1) {
$output .= html_writer::start_tag('div', array('class' => 'categorypicker'));
$select = new single_select(new moodle_url('/course/index.php'), 'categoryid',
core_course_category::make_categories_list(), $coursecat->id, null, 'switchcategory');
}
$coursedisplayoptions['limit'] = $perpage;
$catdisplayoptions['limit'] = $perpage;
- if ($browse === 'courses' || !$coursecat->has_children()) {
+ if ($browse === 'courses' || !$coursecat->get_children_count()) {
$coursedisplayoptions['offset'] = $page * $perpage;
$coursedisplayoptions['paginationurl'] = new moodle_url($baseurl, array('browse' => 'courses'));
$catdisplayoptions['nodisplay'] = true;
$catdisplayoptions['viewmoreurl'] = new moodle_url($baseurl, array('browse' => 'categories'));
$catdisplayoptions['viewmoretext'] = new lang_string('viewallsubcategories');
- } else if ($browse === 'categories' || !$coursecat->has_courses()) {
+ } else if ($browse === 'categories' || !$coursecat->get_courses_count()) {
$coursedisplayoptions['nodisplay'] = true;
$catdisplayoptions['offset'] = $page * $perpage;
$catdisplayoptions['paginationurl'] = new moodle_url($baseurl, array('browse' => 'categories'));
// Add action buttons
$output .= $this->container_start('buttons');
- $context = get_category_or_system_context($coursecat->id);
- if (has_capability('moodle/course:create', $context)) {
- // Print link to create a new course, for the 1st available category.
- if ($coursecat->id) {
- $url = new moodle_url('/course/edit.php', array('category' => $coursecat->id, 'returnto' => 'category'));
- } else {
- $url = new moodle_url('/course/edit.php', array('category' => $CFG->defaultrequestcategory, 'returnto' => 'topcat'));
+ if ($coursecat->is_uservisible()) {
+ $context = get_category_or_system_context($coursecat->id);
+ if (has_capability('moodle/course:create', $context)) {
+ // Print link to create a new course, for the 1st available category.
+ if ($coursecat->id) {
+ $url = new moodle_url('/course/edit.php', array('category' => $coursecat->id, 'returnto' => 'category'));
+ } else {
+ $url = new moodle_url('/course/edit.php',
+ array('category' => $CFG->defaultrequestcategory, 'returnto' => 'topcat'));
+ }
+ $output .= $this->single_button($url, get_string('addnewcourse'), 'get');
}
- $output .= $this->single_button($url, get_string('addnewcourse'), 'get');
- }
- ob_start();
- if (core_course_category::count_all() == 1) {
- print_course_request_buttons(context_system::instance());
- } else {
+ ob_start();
print_course_request_buttons($context);
+ $output .= ob_get_contents();
+ ob_end_clean();
}
- $output .= ob_get_contents();
- ob_end_clean();
$output .= $this->container_end();
return $output;
if (empty($displayoptions)) {
$displayoptions = array();
}
- $showcategories = core_course_category::count_all() > 1;
+ $showcategories = !core_course_category::is_simple_site();
$displayoptions += array('limit' => $CFG->coursesperpage, 'offset' => 0);
$chelper = new coursecat_helper();
$searchcriteria = array('tagid' => $tagid, 'ctx' => $ctx, 'rec' => $rec);
if (!empty($courses) || !empty($rcourses) || !empty($rhosts)) {
$chelper = new coursecat_helper();
+ $totalcount = count($courses);
if (count($courses) > $CFG->frontpagecourselimit) {
// There are more enrolled courses than we can display, display link to 'My courses'.
- $totalcount = count($courses);
$courses = array_slice($courses, 0, $CFG->frontpagecourselimit, true);
$chelper->set_courses_display_options(array(
'viewmoreurl' => new moodle_url('/my/'),
'viewmoretext' => new lang_string('mycourses')
));
- } else {
+ } else if (core_course_category::top()->is_uservisible()) {
// All enrolled courses are displayed, display link to 'All courses' if there are more courses in system.
$chelper->set_courses_display_options(array(
'viewmoreurl' => new moodle_url('/course/index.php'),
'viewmoretext' => new lang_string('fulllistofcourses')));
$chelper->set_attributes(array('class' => 'frontpage-course-list-all'));
- $courses = core_course_category::get(0)->get_courses($chelper->get_courses_display_options());
- $totalcount = core_course_category::get(0)->get_courses_count($chelper->get_courses_display_options());
+ $courses = core_course_category::top()->get_courses($chelper->get_courses_display_options());
+ $totalcount = core_course_category::top()->get_courses_count($chelper->get_courses_display_options());
if (!$totalcount && !$this->page->user_is_editing() && has_capability('moodle/course:create', context_system::instance())) {
// Print link to create a new course, for the 1st available category.
return $this->add_new_course_button();
*/
public function frontpage_combo_list() {
global $CFG;
+ // TODO MDL-10965 improve.
+ $tree = core_course_category::top();
+ if (!$tree->get_children_count()) {
+ return '';
+ }
$chelper = new coursecat_helper();
$chelper->set_subcat_depth($CFG->maxcategorydepth)->
set_categories_display_options(array(
array('browse' => 'courses', 'page' => 1))
))->
set_attributes(array('class' => 'frontpage-category-combo'));
- return $this->coursecat_tree($chelper, core_course_category::get(0));
+ return $this->coursecat_tree($chelper, $tree);
}
/**
*/
public function frontpage_categories_list() {
global $CFG;
+ // TODO MDL-10965 improve.
+ $tree = core_course_category::top();
+ if (!$tree->get_children_count()) {
+ return '';
+ }
$chelper = new coursecat_helper();
$chelper->set_subcat_depth($CFG->maxcategorydepth)->
set_show_courses(self::COURSECAT_SHOW_COURSES_COUNT)->
array('browse' => 'categories', 'page' => 1))
))->
set_attributes(array('class' => 'frontpage-category-names'));
- return $this->coursecat_tree($chelper, core_course_category::get(0));
+ return $this->coursecat_tree($chelper, $tree);
}
/**
* @return string
*/
protected function frontpage_part($skipdivid, $contentsdivid, $header, $contents) {
+ if (strval($contents) === '') {
+ return '';
+ }
$output = html_writer::link('#' . $skipdivid,
get_string('skipa', 'access', core_text::strtolower(strip_tags($header))),
array('class' => 'skip-block skip'));
case FRONTPAGEALLCOURSELIST:
$availablecourseshtml = $this->frontpage_available_courses();
- if (!empty($availablecourseshtml)) {
- $output .= $this->frontpage_part('skipavailablecourses', 'frontpage-available-course-list',
- get_string('availablecourses'), $availablecourseshtml);
- }
+ $output .= $this->frontpage_part('skipavailablecourses', 'frontpage-available-course-list',
+ get_string('availablecourses'), $availablecourseshtml);
break;
case FRONTPAGECATEGORYNAMES:
* @return string|null
*/
public function get_category_formatted_description($coursecat, $options = null) {
- if ($coursecat->id && !empty($coursecat->description)) {
+ if ($coursecat->id && $coursecat->is_uservisible() && !empty($coursecat->description)) {
if (!isset($coursecat->descriptionformat)) {
$descriptionformat = FORMAT_MOODLE;
} else {
$strsearchresults = new lang_string("searchresults");
$strnovalidcourses = new lang_string('novalidcourses');
-$PAGE->navbar->add($strcourses, new moodle_url('/course/index.php'));
+$courseurl = core_course_category::user_top() ? new moodle_url('/course/index.php') : null;
+$PAGE->navbar->add($strcourses, $courseurl);
$PAGE->navbar->add($strsearch, new moodle_url('/course/search.php'));
if (!empty($search)) {
$PAGE->navbar->add(s($search));
--- /dev/null
+@core @core_course
+Feature: Restricting access to course lists
+ In order to provide more targeted content
+ As a Moodle Administrator
+ I need to be able to give/revoke capabilities to view list of courses
+
+ Background:
+ Given the following "categories" exist:
+ | name | category | idnumber |
+ | Science category | 0 | SCI |
+ | English category | 0 | ENG |
+ | Other category | 0 | MISC |
+ And the following "courses" exist:
+ | fullname | shortname | category |
+ | Biology Y1 | BIO1 | SCI |
+ | Biology Y2 | BI02 | SCI |
+ | English Y1 | ENG1 | ENG |
+ | English Y2 | ENG2 | ENG |
+ | Humanities Y1 | HUM2 | MISC |
+ Given the following "users" exist:
+ | username | firstname | lastname | email |
+ | user0 | User | Z | user0@example.com |
+ | userb | User | B | userb@example.com |
+ | usere | User | E | usere@example.com |
+ Given the following "roles" exist:
+ | name | shortname | description | archetype |
+ | Category viewer | coursebrowse | My custom role 1 | |
+ Given I log in as "admin"
+ And I set the following system permissions of "Authenticated user" role:
+ | capability | permission |
+ | moodle/course:browse | Prevent |
+ And I set the following system permissions of "Guest" role:
+ | capability | permission |
+ | moodle/course:browse | Prevent |
+ And I set the following system permissions of "Category viewer" role:
+ | capability | permission |
+ | moodle/course:browse | Allow |
+ And I am on site homepage
+ And I turn editing mode on
+ And I add the "Navigation" block if not present
+ And I log out
+ And the following "role assigns" exist:
+ | user | role | contextlevel | reference |
+ | usere | coursebrowse | Category | ENG |
+ | userb | coursebrowse | Category | ENG |
+ | userb | coursebrowse | Category | SCI |
+
+ Scenario: Browse courses as a user without any browse capability
+ When I log in as "user0"
+ And I am on site homepage
+ Then I should not see "Available courses"
+ And "Courses" "link" should not exist in the "Navigation" "block"
+ And I log out
+
+ Scenario: Browse own courses as a user without any browse capability
+ Given the following "course enrolments" exist:
+ | user | course | role |
+ | user0 | BIO1 | student |
+ When I log in as "user0"
+ And I am on site homepage
+ And I should see "Available courses"
+ And I should see "Biology Y1"
+ And "Courses" "link" should not exist in the "Navigation" "block"
+ And I log out
+
+ Scenario: Browse courses as a user who has access to only one category
+ When I log in as "usere"
+ And I am on site homepage
+ Then I should see "Available courses"
+ And I should see "English Y1"
+ And I should see "English Y2"
+ And I should not see "Biology"
+ And I should not see "Humanities"
+ And I click on "Courses" "link" in the "Navigation" "block"
+ And "English category" "text" should exist in the ".breadcrumb" "css_element"
+ And I should see "English Y1"
+ And I should see "English Y2"
+ And I should not see "Biology"
+ And I should not see "Humanities"
+ And I should not see "Other category"
+ And I follow "English Y2"
+ And I should see "You can not enrol yourself in this course."
+ And I log out
+
+ Scenario: Browse courses as a user who has access to several but not all categories
+ When I log in as "userb"
+ And I am on site homepage
+ Then I should see "Available courses"
+ And I should see "English Y1"
+ And I should see "English Y2"
+ And I should see "Biology"
+ And I should not see "Humanities"
+ And I click on "Courses" "link" in the "Navigation" "block"
+ And "category" "text" should not exist in the ".breadcrumb" "css_element"
+ And I should see "Science category"
+ And I should see "English category"
+ And I should not see "Other category"
+ And I follow "Science category"
+ And I should see "Biology Y2"
+ And I should not see "English Y1"
+ And the "Course categories" select box should contain "Science category"
+ And the "Course categories" select box should contain "English category"
+ And the "Course categories" select box should not contain "Other category"
+ And I follow "Biology Y1"
+ And I should see "You can not enrol yourself in this course."
+ And I log out
// Dont assume there is just one. An add-on might create a category as part of the install.
$numcategories = $DB->count_records('course_categories');
$this->assertEquals($numcategories, core_course_category::count_all());
+ $this->assertDebuggingCalled('Method core_course_category::count_all() is deprecated. Please use ' .
+ 'core_course_category::is_simple_site()', DEBUG_DEVELOPER);
$category1 = core_course_category::create(array('name' => 'Cat1'));
$category2 = core_course_category::create(array('name' => 'Cat2', 'parent' => $category1->id));
$category3 = core_course_category::create(array('name' => 'Cat3', 'parent' => $category2->id, 'visible' => 0));
// Now we've got three more.
$this->assertEquals($numcategories + 3, core_course_category::count_all());
+ $this->assertDebuggingCalled('Method core_course_category::count_all() is deprecated. Please use ' .
+ 'core_course_category::is_simple_site()', DEBUG_DEVELOPER);
cache_helper::purge_by_event('changesincoursecat');
// We should still have 4.
$this->assertEquals($numcategories + 3, core_course_category::count_all());
+ $this->assertDebuggingCalled('Method core_course_category::count_all() is deprecated. Please use ' .
+ 'core_course_category::is_simple_site()', DEBUG_DEVELOPER);
+ }
+
+ /**
+ * Test the is_simple_site function
+ */
+ public function test_is_simple_site() {
+ // By default site has one category and is considered simple.
+ $this->assertEquals(true, core_course_category::is_simple_site());
+ $default = core_course_category::get_default();
+ // When there is only one category but it is hidden, it is not a simple site.
+ $default->update(['visible' => 0]);
+ $this->assertEquals(false, core_course_category::is_simple_site());
+ $default->update(['visible' => 1]);
+ $this->assertEquals(true, core_course_category::is_simple_site());
+ // As soon as there is more than one category, site is not simple any more.
+ core_course_category::create(array('name' => 'Cat1'));
+ $this->assertEquals(false, core_course_category::is_simple_site());
}
/**
// Expecting to get an exception as this new user does not have the moodle/category:viewhiddencategories capability.
$this->expectException('moodle_exception');
- $this->expectExceptionMessage('unknowncategory');
+ $this->expectExceptionMessage(get_string('cannotviewcategory', 'error'));
core_course_category::get($category2->id);
}
$this->assertEquals($category1->id, core_course_category::get($category1->id, MUST_EXIST, false, $user2)->id);
$this->expectException('moodle_exception');
- $this->expectExceptionMessage('unknowncategory');
+ $this->expectExceptionMessage(get_string('cannotviewcategory', 'error'));
core_course_category::get($category2->id, MUST_EXIST, false, $user2);
}
* External function core_course_external::get_course_contents new returns the following additional completiondata field:
- valueused (indicates whether the completion state affects the availability of other content)
* External function core_course_external::get_course_contents now returns a new contentsinfo field with summary files information.
+ * External function core_course_external::get_course_contents now returns an additional field "tags" returning the content tags.
+
=== 3.6 ===
self::validate_context(context_system::instance());
$course = $DB->get_record('course', array('id' => $params['courseid']), '*', MUST_EXIST);
- $context = context_course::instance($course->id);
- if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $context)) {
+ if (!core_course_category::can_view_course_info($course) && !can_access_course($course)) {
throw new moodle_exception('coursehidden');
}
$enrolinstance = $DB->get_record('enrol', array('id' => $params['instanceid']), '*', MUST_EXIST);
$course = $DB->get_record('course', array('id' => $enrolinstance->courseid), '*', MUST_EXIST);
- $context = context_course::instance($course->id);
- if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $context)) {
+ if (!core_course_category::can_view_course_info($course) && !can_access_course($course)) {
throw new moodle_exception('coursehidden');
}
print_error('loginasnoenrol', '', $CFG->wwwroot.'/course/view.php?id='.$USER->loginascontext->instanceid);
}
+// Check if user has access to the category where the course is located.
+if (!core_course_category::can_view_course_info($course) && !is_enrolled($context, $USER, '', true)) {
+ print_error('coursehidden', '', $CFG->wwwroot . '/');
+}
+
// get all enrol forms available in this course
$enrols = enrol_get_plugins(true);
$enrolinstances = enrol_get_instances($course->id, true);
$enrolinstance = $DB->get_record('enrol', array('id' => $params['instanceid']), '*', MUST_EXIST);
$course = $DB->get_record('course', array('id' => $enrolinstance->courseid), '*', MUST_EXIST);
- $context = context_course::instance($course->id);
- if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $context)) {
+ if (!core_course_category::can_view_course_info($course) && !can_access_course($course)) {
throw new moodle_exception('coursehidden');
}
$context = context_course::instance($course->id);
self::validate_context(context_system::instance());
- if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $context)) {
+ if (!core_course_category::can_view_course_info($course)) {
throw new moodle_exception('coursehidden');
}
$string['profilingincluded_help'] = 'List of (comma separated, absolute skipping wwwroot, callable) URLs that will be automatically profiled. Examples: /index.php, /course/view.php. Also accepts the * wildchar at any position. Examples: /mod/forum/*, /mod/*/view.php.';
$string['profilinglifetime'] = 'Keep profiling runs';
$string['profilinglifetime_help'] = 'Specify the time you want to keep information about old profiling runs. Older ones will be pruned periodically. Note that this excludes any profiling run marked as \'reference run\'.';
+$string['profilingslow'] = 'Profile slow pages';
+$string['profilingslow_help'] = 'By setting a minimum time in seconds all pages slower will be profiled. Only profiles which are slower than an existing profile for the same script will be kept. Set to 0 to disable. Note that this observes the exclude settings.';
$string['protectusernames'] = 'Protect usernames';
$string['proxybypass'] = 'Proxy bypass hosts';
$string['proxyhost'] = 'Proxy host';
$string['cannotuseadminadminorteacher'] = 'You need to be a teacher or admin user to use this page';
$string['cannotusepage'] = 'Only teachers and administrators can use this page';
$string['cannotusepage2'] = 'Sorry, you may not use this page';
+$string['cannotviewcategory'] = 'You don\'t have permission to view courses here';
$string['cannotviewprofile'] = 'You cannot view the profile of this user';
$string['cannotviewreport'] = 'You cannot view this report';
$string['cannotwritefile'] = 'Cannot write to file ({$a})';
$string['categorycurrentcontents'] = 'Contents of {$a}';
$string['categorydeleted'] = 'The category \'{$a}\' was deleted';
$string['categoryduplicate'] = 'A category named \'{$a}\' already exists!';
+$string['categoryhidden'] = '(hidden)';
$string['categorymodifiedcancel'] = 'Category was modified! Please cancel and try again.';
$string['categoryname'] = 'Category name';
$string['categorysubcategoryof'] = '{$a->category} - subcategory of {$a->parentcategory}';
$string['timesplitting:quartersaccum_help'] = 'This time-splitting method divides the course into quarters (4 equal parts), with each prediction being based on the data of all previous quarters.';
$string['timesplitting:singlerange'] = 'Single range';
$string['timesplitting:singlerange_help'] = 'This time-splitting method considers the entire course as a single span.';
+$string['timesplitting:upcoming3days'] = 'Upcoming 3 days';
+$string['timesplitting:upcoming3days_help'] = 'This time-splitting method generates predictions every 3 days. The indicators calculations will be based on the upcoming 3 days.';
+$string['timesplitting:upcomingfortnight'] = 'Upcoming fortnight';
+$string['timesplitting:upcomingfortnight_help'] = 'This time-splitting method generates predictions every fortnight. The indicators calculations will be based on the upcoming fortnight.';
$string['timesplitting:upcomingweek'] = 'Upcoming week';
$string['timesplitting:upcomingweek_help'] = 'This time-splitting method generates predictions every week. The indicators calculations will be based on the upcoming week.';
$string['thanks'] = 'Thanks';
$string['confirmunassignno'] = 'Cancel';
$string['context'] = 'Context';
$string['course:activityvisibility'] = 'Hide/show activities';
+$string['course:browse'] = 'View list of courses where user is not enrolled';
$string['course:bulkmessaging'] = 'Send a message to many people';
$string['course:create'] = 'Create courses';
$string['course:creategroupconversations'] = 'Create group conversations';
return true;
}
+ if (!core_course_category::can_view_course_info($course)) {
+ // No guest access if user does not have capability to browse courses.
+ return false;
+ }
+
// if not enrolled try to gain temporary guest access
$instances = $DB->get_records('enrol', array('courseid'=>$course->id, 'status'=>ENROL_INSTANCE_ENABLED), 'sortorder, id ASC');
$enrols = enrol_get_plugins(true);
var limittoenrolled = el.data('limittoenrolled');
var includefrontpage = el.data('includefrontpage');
+ var onlywithcompletion = el.data('onlywithcompletion');
// Build the query.
var promises = null;
page: 0,
perpage: 100,
requiredcapabilities: requiredcapabilities,
- limittoenrolled: limittoenrolled
+ limittoenrolled: limittoenrolled,
+ onlywithcompletion: onlywithcompletion
};
var calls = [{
if (! validate_email($data['email'])) {
$errors['email'] = get_string('invalidemail');
- } else if ($DB->record_exists('user', array('email' => $data['email']))) {
- $errors['email'] = get_string('emailexists') . ' ' .
- get_string('emailexistssignuphint', 'moodle',
- html_writer::link(new moodle_url('/login/forgot_password.php'), get_string('emailexistshintlink')));
+ } else if (empty($CFG->allowaccountssameemail)) {
+ // Make a case-insensitive query for the given email address.
+ $select = $DB->sql_equal('email', ':email', false) . ' AND mnethostid = :mnethostid';
+ $params = array(
+ 'email' => $data['email'],
+ 'mnethostid' => $CFG->mnet_localhost_id,
+ );
+ // If there are other user(s) that already have the same email, show an error.
+ if ($DB->record_exists_select('user', $select, $params)) {
+ $forgotpasswordurl = new moodle_url('/login/forgot_password.php');
+ $forgotpasswordlink = html_writer::link($forgotpasswordurl, get_string('emailexistshintlink'));
+ $errors['email'] = get_string('emailexists') . ' ' . get_string('emailexistssignuphint', 'moodle', $forgotpasswordlink);
+ }
}
if (empty($data['email2'])) {
$errors['email2'] = get_string('missingemail');
- } else if ($data['email2'] != $data['email']) {
+ } else if (core_text::strtolower($data['email2']) != core_text::strtolower($data['email'])) {
$errors['email2'] = get_string('invalidemail');
}
if (!isset($errors['email'])) {
--- /dev/null
+<?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/>.
+
+/**
+ * Time splitting method that generates insights every three days and calculates indicators using upcoming dates.
+ *
+ * @package core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\analytics\time_splitting;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Time splitting method that generates insights every three days and calculates indicators using upcoming dates.
+ *
+ * @package core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class upcoming_3_days extends \core_analytics\local\time_splitting\upcoming_periodic {
+
+ /**
+ * The time splitting method name.
+ * @return \lang_string
+ */
+ public static function get_name() : \lang_string {
+ return new \lang_string('timesplitting:upcoming3days');
+ }
+
+ /**
+ * Once every three days.
+ * @return \DateInterval
+ */
+ public function periodicity() {
+ return new \DateInterval('P3D');
+ }
+}
\ No newline at end of file
--- /dev/null
+<?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/>.
+
+/**
+ * Time splitting method that generates insights every fortnight and calculates indicators using upcoming dates.
+ *
+ * @package core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\analytics\time_splitting;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Time splitting method that generates insights every fortnight and calculates indicators using upcoming dates.
+ *
+ * @package core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class upcoming_fortnight extends \core_analytics\local\time_splitting\upcoming_periodic {
+
+ /**
+ * The time splitting method name.
+ * @return \lang_string
+ */
+ public static function get_name() : \lang_string {
+ return new \lang_string('timesplitting:upcomingfortnight');
+ }
+
+ /**
+ * Every two weeks.
+ * @return \DateInterval
+ */
+ public function periodicity() {
+ return new \DateInterval('P2W');
+ }
+}
\ No newline at end of file
*
* e.g. get_component_classes_in_namespace('mod_forum', 'event')
*
- * @param string $component A valid moodle component (frankenstyle)
- * @param string $namespace Namespace from the component name or empty if all $component namespace classes.
- * @return array The full class name as key and the class path as value.
+ * @param string|null $component A valid moodle component (frankenstyle) or null if searching all components
+ * @param string $namespace Namespace from the component name or empty string if all $component classes.
+ * @return array The full class name as key and the class path as value, empty array if $component is `null`
+ * and $namespace is empty.
*/
- public static function get_component_classes_in_namespace($component, $namespace = '') {
+ public static function get_component_classes_in_namespace($component = null, $namespace = '') {
- $component = self::normalize_componentname($component);
+ $classes = array();
- if ($namespace) {
+ // Only look for components if a component name is set or a namespace is set.
+ if (isset($component) || !empty($namespace)) {
- // We will add them later.
- $namespace = trim($namespace, '\\');
+ // If a component parameter value is set we only want to look in that component.
+ // Otherwise we want to check all components.
+ $component = (isset($component)) ? self::normalize_componentname($component) : '\w+';
+ if ($namespace) {
- // We need add double backslashes as it is how classes are stored into self::$classmap.
- $namespace = implode('\\\\', explode('\\', $namespace));
- $namespace = $namespace . '\\\\';
- }
+ // We will add them later.
+ $namespace = trim($namespace, '\\');
- $regex = '|^' . $component . '\\\\' . $namespace . '|';
- $it = new RegexIterator(new ArrayIterator(self::$classmap), $regex, RegexIterator::GET_MATCH, RegexIterator::USE_KEY);
+ // We need add double backslashes as it is how classes are stored into self::$classmap.
+ $namespace = implode('\\\\', explode('\\', $namespace));
+ $namespace = $namespace . '\\\\';
+ }
+ $regex = '|^' . $component . '\\\\' . $namespace . '|';
+ $it = new RegexIterator(new ArrayIterator(self::$classmap), $regex, RegexIterator::GET_MATCH, RegexIterator::USE_KEY);
- // We want to be sure that they exist.
- $classes = array();
- foreach ($it as $classname => $classpath) {
- if (class_exists($classname)) {
- $classes[$classname] = $classpath;
+ // We want to be sure that they exist.
+ foreach ($it as $classname => $classpath) {
+ if (class_exists($classname)) {
+ $classes[$classname] = $classpath;
+ }
}
}
$countparams = array('owner' => $token, 'resourcekey' => $resource);
$result = $this->db->count_records('lock_db', $countparams);
$locked = $result === 1;
- if (!$locked) {
+ if (!$locked && $timeout > 0) {
usleep(rand(10000, 250000)); // Sleep between 10 and 250 milliseconds.
}
// Try until the giveup time.
// Will block on windows. So sad.
$wouldblock = false;
$locked = flock($filehandle, LOCK_EX | LOCK_NB, $wouldblock);
- if (!$locked && $wouldblock) {
+ if (!$locked && $wouldblock && $timeout > 0) {
usleep(rand(10000, 250000)); // Sleep between 10 and 250 milliseconds.
}
// Try until the giveup time.
do {
$result = $this->db->get_record_sql('SELECT pg_try_advisory_lock(:locktype, :token) AS locked', $params);
$locked = $result->locked === 't';
- if (!$locked) {
+ if (!$locked && $timeout > 0) {
usleep(rand(10000, 250000)); // Sleep between 10 and 250 milliseconds.
}
// Try until the giveup time.
/** @var int The time the message was created.*/
private $timecreated;
+ /** @var boolean Mark trust content. */
+ private $fullmessagetrust;
+
/** @var array a list of properties that is allowed for each message. */
private $properties = array(
'courseid',
'savedmessageid',
'attachment',
'attachname',
- 'timecreated'
+ 'timecreated',
+ 'fullmessagetrust'
);
/** @var array property to store any additional message processor specific content */
* we are using distinct. You almost _NEVER_ need all the fields
* in such a large SELECT
*
+ * Consider using core_course_category::get_courses()
+ * or core_course_category::search_courses() instead since they use caching.
+ *
* @global object
* @global object
* @global object
// loop throught them
foreach ($courses as $course) {
context_helper::preload_from_record($course);
- if (isset($course->visible) && $course->visible <= 0) {
- // for hidden courses, require visibility check
- if (has_capability('moodle/course:viewhiddencourses', context_course::instance($course->id))) {
- $visiblecourses [$course->id] = $course;
- }
- } else {
- $visiblecourses [$course->id] = $course;
- }
- }
- }
- return $visiblecourses;
-}
-
-
-/**
- * Returns list of courses, for whole site, or category
- *
- * Similar to get_courses, but allows paging
- * Important: Using c.* for fields is extremely expensive because
- * we are using distinct. You almost _NEVER_ need all the fields
- * in such a large SELECT
- *
- * @global object
- * @global object
- * @global object
- * @uses CONTEXT_COURSE
- * @param string|int $categoryid Either a category id or 'all' for everything
- * @param string $sort A field and direction to sort by
- * @param string $fields The additional fields to return
- * @param int $totalcount Reference for the number of courses
- * @param string $limitfrom The course to start from
- * @param string $limitnum The number of courses to limit to
- * @return array Array of courses
- */
-function get_courses_page($categoryid="all", $sort="c.sortorder ASC", $fields="c.*",
- &$totalcount, $limitfrom="", $limitnum="") {
- global $USER, $CFG, $DB;
-
- $params = array();
-
- $categoryselect = "";
- if ($categoryid !== "all" && is_numeric($categoryid)) {
- $categoryselect = "WHERE c.category = :catid";
- $params['catid'] = $categoryid;
- } else {
- $categoryselect = "";
- }
-
- $ccselect = ', ' . context_helper::get_preload_record_columns_sql('ctx');
- $ccjoin = "LEFT JOIN {context} ctx ON (ctx.instanceid = c.id AND ctx.contextlevel = :contextlevel)";
- $params['contextlevel'] = CONTEXT_COURSE;
-
- $totalcount = 0;
- if (!$limitfrom) {
- $limitfrom = 0;
- }
- $visiblecourses = array();
-
- $sql = "SELECT $fields $ccselect
- FROM {course} c
- $ccjoin
- $categoryselect
- ORDER BY $sort";
-
- // pull out all course matching the cat
- $rs = $DB->get_recordset_sql($sql, $params);
- // iteration will have to be done inside loop to keep track of the limitfrom and limitnum
- foreach($rs as $course) {
- context_helper::preload_from_record($course);
- if ($course->visible <= 0) {
- // for hidden courses, require visibility check
- if (has_capability('moodle/course:viewhiddencourses', context_course::instance($course->id))) {
- $totalcount++;
- if ($totalcount > $limitfrom && (!$limitnum or count($visiblecourses) < $limitnum)) {
- $visiblecourses [$course->id] = $course;
- }
- }
- } else {
- $totalcount++;
- if ($totalcount > $limitfrom && (!$limitnum or count($visiblecourses) < $limitnum)) {
+ if (core_course_category::can_view_course_info($course)) {
$visiblecourses [$course->id] = $course;
}
}
}
- $rs->close();
return $visiblecourses;
}
* @param int $recordsperpage The number of records per page
* @param int $totalcount Passed in by reference.
* @param array $requiredcapabilities Extra list of capabilities used to filter courses
- * @return object {@link $COURSE} records
+ * @param array $searchcond additional search conditions, for example ['c.enablecompletion = :p1']
+ * @param array $params named parameters for additional search conditions, for example ['p1' => 1]
+ * @return stdClass[] {@link $COURSE} records
*/
function get_courses_search($searchterms, $sort, $page, $recordsperpage, &$totalcount,
- $requiredcapabilities = array()) {
+ $requiredcapabilities = array(), $searchcond = [], $params = []) {
global $CFG, $DB;
if ($DB->sql_regex_supported()) {
$NOTREGEXP = $DB->sql_regex(false);
}
- $searchcond = array();
- $params = array();
$i = 0;
// Thanks Oracle for your non-ansi concat and type limits in coalesce. MDL-29912
WHERE $searchcond AND c.id <> ".SITEID."
ORDER BY $sort";
+ $mycourses = enrol_get_my_courses();
$rs = $DB->get_recordset_sql($sql, $params);
foreach($rs as $course) {
// Preload contexts only for hidden courses or courses we need to return.
context_helper::preload_from_record($course);
$coursecontext = context_course::instance($course->id);
- if (!$course->visible && !has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
+ if (!array_key_exists($course->id, $mycourses) && !core_course_category::can_view_course_info($course)) {
continue;
}
if (!empty($requiredcapabilities)) {
'clonepermissionsfrom' => 'moodle/category:update'
),
+ 'moodle/course:browse' => array(
+
+ 'captype' => 'read',
+ 'contextlevel' => CONTEXT_COURSE,
+ 'archetypes' => array(
+ 'guest' => CAP_ALLOW,
+ 'user' => CAP_ALLOW,
+ )
+ ),
+
'moodle/category:viewhiddencategories' => array(
'captype' => 'read',
<FIELD NAME="fullmessagehtml" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="smallmessage" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+ <FIELD NAME="fullmessagetrust" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
'description' => 'Gets tag index page for one tag and one tag area',
'type' => 'read',
'ajax' => true,
+ 'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
),
'core_tag_get_tags' => array(
'classname' => 'core_tag_external',
'type' => 'write',
'ajax' => true,
),
+ 'core_tag_get_tagindex_per_area' => array(
+ 'classname' => 'core_tag_external',
+ 'methodname' => 'get_tagindex_per_area',
+ 'description' => 'Gets tag index page per different areas.',
+ 'type' => 'read',
+ 'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+ ),
+ 'core_tag_get_tag_areas' => array(
+ 'classname' => 'core_tag_external',
+ 'methodname' => 'get_tag_areas',
+ 'description' => 'Retrieves existing tag areas.',
+ 'type' => 'read',
+ 'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+ ),
+ 'core_tag_get_tag_collections' => array(
+ 'classname' => 'core_tag_external',
+ 'methodname' => 'get_tag_collections',
+ 'description' => 'Retrieves existing tag collections.',
+ 'type' => 'read',
+ 'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+ ),
+ 'core_tag_get_tag_cloud' => array(
+ 'classname' => 'core_tag_external',
+ 'methodname' => 'get_tag_cloud',
+ 'description' => 'Retrieves a tag cloud for the given collection and/or query search.',
+ 'type' => 'read',
+ 'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+ ),
'core_update_inplace_editable' => array(
'classname' => 'core_external',
'methodname' => 'update_inplace_editable',
upgrade_main_savepoint(true, 2019040600.04);
}
+ if ($oldversion < 2019041000.02) {
+
+ // Define field fullmessagetrust to be added to messages.
+ $table = new xmldb_table('messages');
+ $field = new xmldb_field('fullmessagetrust', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, '0', 'timecreated');
+
+ // Conditionally launch add field fullmessagetrust.
+ if (!$dbman->field_exists($table, $field)) {
+ $dbman->add_field($table, $field);
+ }
+
+ // Main savepoint reached.
+ upgrade_main_savepoint(true, 2019041000.02);
+ }
+
return true;
}
return \core_message\api::get_contact($USER->id, $contactid);
}
+
+/**
+ * Returns list of courses, for whole site, or category
+ *
+ * Similar to get_courses, but allows paging
+ * Important: Using c.* for fields is extremely expensive because
+ * we are using distinct. You almost _NEVER_ need all the fields
+ * in such a large SELECT
+ *
+ * @deprecated since Moodle 3.7
+ * @todo The final deprecation of this function will take place in Moodle 41 - see MDL-65319.
+ *
+ * @param string|int $categoryid Either a category id or 'all' for everything
+ * @param string $sort A field and direction to sort by
+ * @param string $fields The additional fields to return
+ * @param int $totalcount Reference for the number of courses
+ * @param string $limitfrom The course to start from
+ * @param string $limitnum The number of courses to limit to
+ * @return array Array of courses
+ */
+function get_courses_page($categoryid="all", $sort="c.sortorder ASC", $fields="c.*",
+ &$totalcount, $limitfrom="", $limitnum="") {
+ debugging('Function get_courses_page() is deprecated. Please use core_course_category::get_courses() ' .
+ 'or core_course_category::search_courses()', DEBUG_DEVELOPER);
+ global $USER, $CFG, $DB;
+
+ $params = array();
+
+ $categoryselect = "";
+ if ($categoryid !== "all" && is_numeric($categoryid)) {
+ $categoryselect = "WHERE c.category = :catid";
+ $params['catid'] = $categoryid;
+ } else {
+ $categoryselect = "";
+ }
+
+ $ccselect = ', ' . context_helper::get_preload_record_columns_sql('ctx');
+ $ccjoin = "LEFT JOIN {context} ctx ON (ctx.instanceid = c.id AND ctx.contextlevel = :contextlevel)";
+ $params['contextlevel'] = CONTEXT_COURSE;
+
+ $totalcount = 0;
+ if (!$limitfrom) {
+ $limitfrom = 0;
+ }
+ $visiblecourses = array();
+
+ $sql = "SELECT $fields $ccselect
+ FROM {course} c
+ $ccjoin
+ $categoryselect
+ ORDER BY $sort";
+
+ // Pull out all course matching the cat.
+ $rs = $DB->get_recordset_sql($sql, $params);
+ // Iteration will have to be done inside loop to keep track of the limitfrom and limitnum.
+ foreach ($rs as $course) {
+ context_helper::preload_from_record($course);
+ if (core_course_category::can_view_course_info($course)) {
+ $totalcount++;
+ if ($totalcount > $limitfrom && (!$limitnum or count($visiblecourses) < $limitnum)) {
+ $visiblecourses [$course->id] = $course;
+ }
+ }
+ }
+ $rs->close();
+ return $visiblecourses;
+}
public function get_file_info($component, $filearea, $itemid, $filepath, $filename) {
global $DB;
- if (!$this->category->visible and !has_capability('moodle/category:viewhiddencategories', $this->context)) {
+ if (!core_course_category::can_view_category($this->category)) {
if (empty($component)) {
// we can not list the category contents, so try parent, or top system
if ($this->category->parent and $pc = $DB->get_record('course_categories', array('id'=>$this->category->parent))) {
// No coursecat description area for "system".
return null;
}
- if (!$this->category->visible and !has_capability('moodle/category:viewhiddencategories', $this->context)) {
+ if (!core_course_category::can_view_category($this->category)) {
return null;
}
if (!has_capability('moodle/category:manage', $this->context)) {
foreach ($coursecats as $id => &$category) {
context_helper::preload_from_record($category);
- $context = context_coursecat::instance($category->id);
- if (!$category->visible && !has_capability('moodle/category:viewhiddencategories', $context)) {
+ if (!core_course_category::can_view_category($category)) {
$hiddencats[$id] = $coursecats[$id];
unset($coursecats[$id]);
}
}
// Check if user can view this category.
- if (!has_capability('moodle/category:viewhiddencategories', $context)) {
- $coursecatvisible = $DB->get_field('course_categories', 'visible', array('id' => $context->instanceid));
- if (!$coursecatvisible) {
- send_file_not_found();
- }
+ if (!core_course_category::get($context->instanceid, IGNORE_MISSING)) {
+ send_file_not_found();
}
$filename = array_pop($args);
* 'requiredcapabilities' - array of capabilities. Uses ANY to combine them.
* 'limittoenrolled' - boolean Limits to enrolled courses.
* 'includefrontpage' - boolean Enables the frontpage to be selected.
+ * 'onlywithcompletion' - only courses where completion is enabled
*/
public function __construct($elementname = null, $elementlabel = null, $options = array()) {
if (isset($options['multiple'])) {
if (!empty($options['includefrontpage'])) {
$validattributes['data-includefrontpage'] = SITEID;
}
+ if (!empty($options['onlywithcompletion'])) {
+ $validattributes['data-onlywithcompletion'] = 1;
+ }
parent::__construct($elementname, $elementlabel, array(), $validattributes);
}
WHERE c.id ". $whereclause." ORDER BY c.sortorder";
$list = $DB->get_records_sql($sql, array('contextcourse' => CONTEXT_COURSE) + $params);
+ $mycourses = enrol_get_my_courses(null, null, 0, array_keys($list));
$coursestoselect = array();
foreach ($list as $course) {
context_helper::preload_from_record($course);
$context = context_course::instance($course->id);
// Make sure we can see the course.
- if (!$course->visible && !has_capability('moodle/course:viewhiddencourses', $context)) {
+ if (!array_key_exists($course->id, $mycourses) && !core_course_category::can_view_course_info($course)) {
continue;
}
$label = format_string(get_course_display_name_for_list($course), true, ['context' => $context]);
$tabledata->smallmessage = $eventdata->smallmessage;
$tabledata->timecreated = time();
+ // The Trusted Content system.
+ // Texts created or uploaded by such users will be marked as trusted and will not be cleaned before display.
+ if (trusttext_active()) {
+ // Individual conversations are always in system context.
+ $messagecontext = \context_system::instance();
+ // We need to know the type of conversation and the contextid if it is a group conversation.
+ if ($conv = $DB->get_record('message_conversations', ['id' => $conversationid], 'id, type, contextid')) {
+ if ($conv->type == \core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP && $conv->contextid) {
+ $messagecontext = \context::instance_by_id($conv->contextid);
+ }
+ }
+ $tabledata->fullmessagetrust = trusttext_trusted($messagecontext);
+ } else {
+ $tabledata->fullmessagetrust = false;
+ }
+
if ($messageid = message_handle_phpunit_redirection($eventdata, $table, $tabledata)) {
return $messageid;
}
// We need at least 2 samples belonging to each target.
$counts = array_count_values($targets);
+ $ntargets = count(explode(',', $metadata['targetclasses']));
foreach ($counts as $count) {
if ($count < 2) {
$notenoughdata = true;
}
}
+ if ($ntargets > count($counts)) {
+ $notenoughdata = true;
+ }
if (!empty($notenoughdata)) {
$resultobj = new \stdClass();
$resultobj->status = \core_analytics\model::NOT_ENOUGH_DATA;
$USER->enrol['enrolled'][$course->id] = $until;
$access = true;
- } else {
+ } else if (core_course_category::can_view_course_info($course)) {
$params = array('courseid' => $course->id, 'status' => ENROL_INSTANCE_ENABLED);
$instances = $DB->get_records('enrol', $params, 'sortorder, id ASC');
$enrols = enrol_get_plugins(true);
}
}
}
+ } else {
+ // User is not enrolled and is not allowed to browse courses here.
+ if ($preventredirect) {
+ throw new require_login_exception('Course is not available');
+ }
+ $PAGE->set_context(null);
+ // We need to override the navigation URL as the course won't have been added to the navigation and thus
+ // the navigation will mess up when trying to find it.
+ navigation_node::override_active_url(new moodle_url('/'));
+ notice(get_string('coursehidden'), $CFG->wwwroot .'/');
}
}
}
$this->rootnodes['currentcourse'] = $this->add(get_string('currentcourse'), null, self::TYPE_ROOTNODE, null, 'currentcourse');
$this->rootnodes['mycourses'] = $this->add(get_string('mycourses'), null, self::TYPE_ROOTNODE, null, 'mycourses', new pix_icon('i/course', ''));
$this->rootnodes['courses'] = $this->add(get_string('courses'), new moodle_url('/course/index.php'), self::TYPE_ROOTNODE, null, 'courses');
+ if (!core_course_category::user_top()) {
+ $this->rootnodes['courses']->hide();
+ }
$this->rootnodes['users'] = $this->add(get_string('users'), null, self::TYPE_ROOTNODE, null, 'users');
// We always load the frontpage course to ensure it is available without
protected function show_my_categories() {
global $CFG;
if ($this->showmycategories === null) {
- $this->showmycategories = !empty($CFG->navshowmycoursecategories) && core_course_category::count_all() > 1;
+ $this->showmycategories = !empty($CFG->navshowmycoursecategories) && !core_course_category::is_simple_site();
}
return $this->showmycategories;
}
if (array_key_exists($category->id, $this->addedcategories)) {
return;
}
- $url = new moodle_url('/course/index.php', array('categoryid' => $category->id));
+ $canview = core_course_category::can_view_category($category);
+ $url = $canview ? new moodle_url('/course/index.php', array('categoryid' => $category->id)) : null;
$context = context_coursecat::instance($category->id);
- $categoryname = format_string($category->name, true, array('context' => $context));
+ $categoryname = $canview ? format_string($category->name, true, array('context' => $context)) :
+ get_string('categoryhidden');
$categorynode = $parent->add($categoryname, $url, $nodetype, $categoryname, $category->id);
- if (empty($category->visible)) {
- if (has_capability('moodle/category:viewhiddencategories', context_system::instance())) {
- $categorynode->hidden = true;
- } else {
- $categorynode->display = false;
- }
+ if (!$canview) {
+ // User does not have required capabilities to view category.
+ $categorynode->display = false;
+ } else if (!$category->visible) {
+ // Category is hidden but user has capability to view hidden categories.
+ $categorynode->hidden = true;
}
$this->addedcategories[$category->id] = $categorynode;
}
$coursecontext = context_course::instance($course->id);
- if ($course->id != $SITE->id && !$course->visible) {
+ if ($coursetype != self::COURSE_MY && $coursetype != self::COURSE_CURRENT && $course->id != $SITE->id) {
if (is_role_switched($course->id)) {
// user has to be able to access course in order to switch, let's skip the visibility test here
- } else if (!has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
+ } else if (!core_course_category::can_view_course_info($course)) {
return false;
}
}
if (isset($this->addedcategories[$coursecat->id])) {
continue;
}
+ // Skip categories that are not visible.
+ if (!$coursecat->is_uservisible()) {
+ continue;
+ }
// Get this course category's parent node.
$parent = null;
}
require_course_login($course, true, null, false, true);
$this->page->set_context(context_course::instance($course->id));
- $coursenode = $this->add_course($course);
+ $coursenode = $this->add_course($course, false, self::COURSE_CURRENT);
$this->add_course_essentials($coursenode, $course);
$this->load_course_sections($course, $coursenode);
break;
$course = $DB->get_record_sql($sql, array($this->instanceid), MUST_EXIST);
require_course_login($course, true, null, false, true);
$this->page->set_context(context_course::instance($course->id));
- $coursenode = $this->add_course($course);
+ $coursenode = $this->add_course($course, false, self::COURSE_CURRENT);
$this->add_course_essentials($coursenode, $course);
$this->load_course_sections($course, $coursenode, $course->sectionnumber);
break;
$cm = $modinfo->get_cm($this->instanceid);
require_course_login($course, true, $cm, false, true);
$this->page->set_context(context_module::instance($cm->id));
- $coursenode = $this->load_course($course);
+ $coursenode = $this->add_course($course, false, self::COURSE_CURRENT);
$this->load_course_sections($course, $coursenode, null, $cm);
$activitynode = $coursenode->find($cm->id, self::TYPE_ACTIVITY);
if ($activitynode) {
$categories = array();
$cap = 'moodle/category:viewhiddencategories';
- $showcategories = core_course_category::count_all() > 1;
+ $showcategories = !core_course_category::is_simple_site();
if ($showcategories) {
foreach ($this->page->categories as $category) {
- if (!$category->visible && !has_capability($cap, get_category_or_system_context($category->parent))) {
+ $context = context_coursecat::instance($category->id);
+ if (!core_course_category::can_view_category($category)) {
continue;
}
$url = new moodle_url('/course/index.php', array('categoryid' => $category->id));
- $name = format_string($category->name, true, array('context' => context_coursecat::instance($category->id)));
+ $name = format_string($category->name, true, array('context' => $context));
$categorynode = breadcrumb_navigation_node::create($name, $url, self::TYPE_CATEGORY, null, $category->id);
if (!$category->visible) {
$categorynode->hidden = true;
*/
function is_major_upgrade_required() {
global $CFG;
- $lastmajordbchanges = 2019032900.00;
+ $lastmajordbchanges = 2019041000.03;
$required = empty($CFG->version);
$required = $required || (float)$CFG->version < $lastmajordbchanges;
$this->assertEquals(array(), array_keys($list));
}
- public function test_get_component_classes_int_namespace() {
+ public function test_get_component_classes_in_namespace() {
// Unexisting.
$this->assertCount(0, core_component::get_component_classes_in_namespace('core_unexistingcomponent', 'something'));
$this->assertCount(1, core_component::get_component_classes_in_namespace('auth_cas', 'task'));
$this->assertCount(1, core_component::get_component_classes_in_namespace('auth_cas', '\\task'));
- // Core as a component works, the funcion can normalise the component name.
+ // Core as a component works, the function can normalise the component name.
$this->assertCount(7, core_component::get_component_classes_in_namespace('core', 'update'));
$this->assertCount(7, core_component::get_component_classes_in_namespace('', 'update'));
$this->assertCount(7, core_component::get_component_classes_in_namespace('moodle', 'update'));
// Without namespace it returns classes/ classes.
$this->assertCount(3, core_component::get_component_classes_in_namespace('tool_mobile', ''));
$this->assertCount(2, core_component::get_component_classes_in_namespace('tool_filetypes'));
+
+ // When no component is specified, classes are returned for the namespace in all components.
+ // (We don't assert exact amounts here as the count of `output` classes will change depending on plugins installed).
+ $this->assertGreaterThan(
+ count(\core_component::get_component_classes_in_namespace('core', 'output')),
+ count(\core_component::get_component_classes_in_namespace(null, 'output')));
+
+ // Without either a component or namespace it returns an empty array.
+ $this->assertEmpty(\core_component::get_component_classes_in_namespace());
+ $this->assertEmpty(\core_component::get_component_classes_in_namespace(null));
+ $this->assertEmpty(\core_component::get_component_classes_in_namespace(null, ''));
}
/**
$lock2 = $lockfactory->get_lock('abc', 2);
$this->assertNotEmpty($lock2, 'Get a stacked lock');
$this->assertTrue($lock2->release(), 'Release a stacked lock');
+
+ // This stacked lock should be gained almost instantly.
+ $duration = -microtime(true);
+ $lock3 = $lockfactory->get_lock('abc', 0);
+ $duration += microtime(true);
+ $lock3->release();
+ $this->assertTrue($duration < 0.100, 'Lock should be gained almost instantly');
+
+ // We should also assert that locks fail instantly if locked
+ // from another process but this is hard to unit test.
+
} else {
- // This should timeout.
+ // This should timeout after 2 seconds.
+ $duration = -microtime(true);
$lock2 = $lockfactory->get_lock('abc', 2);
+ $duration += microtime(true);
+ $this->assertFalse($lock2, 'Cannot get a stacked lock');
+ $this->assertTrue($duration > 1, 'Lock should timeout after more than 1 second');
+
+ // This should timeout almost instantly.
+ $duration = -microtime(true);
+ $lock2 = $lockfactory->get_lock('abc', 0);
+ $duration += microtime(true);
$this->assertFalse($lock2, 'Cannot get a stacked lock');
+ $this->assertTrue($duration < 0.100, 'Lock should timeout almost instantly < 100ms');
}
}
// Release the lock.
When the parameter is set to that constant, the function won't process file merging, keeping the original state of the file area.
* Introduced new callback for plugin developers '<component>_pre_processor_message_send($procname, $proceventdata)':
This will allow any plugin to manipulate messages or notifications before they are sent by a processor (email, mobile...)
+* New capability 'moodle/course:browse' in category context that controls whether user is able to browse list of courses
+ in this category. To work with list of courses use API methods in core_course_category and also 'course' form element.
+* It is possible to pass additional conditions to get_courses_search();
+ core_course_category::search_courses() now allows to search only among courses with completion enabled.
=== 3.6 ===
20110318 - MDL-26891 - Eloy Lafuente (stronk7): Implemented earlier profiling runs
20130621 - MDL-39733 - Eloy Lafuente (stronk7): Export & import of profiling runs
20160721 - MDL-55292 - Russell Smith (mr-russ): Add support for tideways profiler collection for PHP7
-20171002 - MDL-60313 - Marina Glancy (marinaglancy): Upgrade to 0.9.4 release; patched for PHP7.2
\ No newline at end of file
+20171002 - MDL-60313 - Marina Glancy (marinaglancy): Upgrade to 0.9.4 release; patched for PHP7.2
+20190314 - MDL-64543 - Brendan Heywood (brendanheywood): Add support for conditional slow profiling
+
$profileauto = (mt_rand(1, $CFG->profilingautofrec) === 1);
}
- // See if the $script matches any of the included patterns
+ // Profile potentially slow pages.
+ $profileslow = false;
+ if (!empty($CFG->profilingslow) && !CLI_SCRIPT) {
+ $profileslow = true;
+ }
+
+ // See if the $script matches any of the included patterns.
$included = empty($CFG->profilingincluded) ? '' : $CFG->profilingincluded;
$profileincluded = profiling_string_matches($script, $included);
// Decide if profile by match must happen (only if profileauto is disabled)
$profilematch = $profileincluded && !$profileexcluded && empty($CFG->profilingautofrec);
- // If not auto, me, all, match have been detected, nothing to do
- if (!$profileauto && !$profileme && !$profileall && !$profilematch) {
+ // Decide if slow profile has been excluded.
+ $profileslow = $profileslow && !$profileexcluded;
+
+ // If not auto, me, all, match have been detected, nothing to do.
+ if (!$profileauto && !$profileme && !$profileall && !$profilematch && !$profileslow) {
return false;
}
+ // If we have only been triggered by a *potentially* slow page then remember this for later.
+ if ((!$profileauto && !$profileme && !$profileall && !$profilematch) && $profileslow) {
+ $CFG->profilepotentialslowpage = microtime(true); // Neither $PAGE or $SESSION are guaranteed here.
+ }
+
// Arrived here, the script is going to be profiled, let's do it
$ignore = array('call_user_func', 'call_user_func_array');
if (extension_loaded('tideways_xhprof')) {
return false;
}
+ // If we only profiled because it was potentially slow then...
+ if (!empty($CFG->profilepotentialslowpage)) {
+ $duration = microtime(true) - $CFG->profilepotentialslowpage;
+ if ($duration < $CFG->profilingslow) {
+ // Wasn't slow enough.
+ return false;
+ }
+
+ $sql = "SELECT max(totalexecutiontime)
+ FROM {profiling}
+ WHERE url = ?";
+ $slowest = $DB->get_field_sql($sql, array($script));
+ if (!empty($slowest) && $duration * 1000000 < $slowest) {
+ // Already have a worse profile stored.
+ return false;
+ }
+ }
+
$run = new moodle_xhprofrun();
$run->prepare_run($script);
$runid = $run->save_run($data, null);
$typesql = !is_null($type) ? " AND mc.type = :convtype " : "";
$sql = "SELECT m.id as messageid, mc.id as id, mc.name as conversationname, mc.type as conversationtype, m.useridfrom,
- m.smallmessage, m.fullmessage, m.fullmessageformat, m.fullmessagehtml, m.timecreated, mc.component,
- mc.itemtype, mc.itemid, mc.contextid, mca.action as ismuted
+ m.smallmessage, m.fullmessage, m.fullmessageformat, m.fullmessagetrust, m.fullmessagehtml, m.timecreated,
+ mc.component, mc.itemtype, mc.itemid, mc.contextid, mca.action as ismuted
FROM {message_conversations} mc
INNER JOIN {message_conversation_members} mcm
ON (mcm.conversationid = mc.id AND mcm.userid = :userid3)
$eventdata->notification = 0;
$messageid = message_send($eventdata);
- $messagerecord = $DB->get_record('messages', ['id' => $messageid], 'id, useridfrom, fullmessage, timecreated');
+ $messagerecord = $DB->get_record('messages', ['id' => $messageid], 'id, useridfrom, fullmessage,
+ timecreated, fullmessagetrust');
$message = (object) [
'id' => $messagerecord->id,
'useridfrom' => $messagerecord->useridfrom,
'text' => $messagerecord->fullmessage,
- 'timecreated' => $messagerecord->timecreated
+ 'timecreated' => $messagerecord->timecreated,
+ 'fullmessagetrust' => $messagerecord->fullmessagetrust
];
return $message;
}
$hash = self::get_conversation_hash([$userid, $otheruserid]);
$sql = "SELECT m.id, m.useridfrom, m.subject, m.fullmessage, m.fullmessagehtml,
- m.fullmessageformat, m.smallmessage, m.timecreated, muaread.timecreated AS timeread
+ m.fullmessageformat, m.fullmessagetrust, m.smallmessage, m.timecreated,
+ mc.contextid, muaread.timecreated AS timeread
FROM {message_conversations} mc
INNER JOIN {messages} m
ON m.conversationid = mc.id
global $DB;
$sql = "SELECT m.id, m.useridfrom, m.subject, m.fullmessage, m.fullmessagehtml,
- m.fullmessageformat, m.smallmessage, m.timecreated, muaread.timecreated AS timeread
+ m.fullmessageformat, m.fullmessagetrust, m.smallmessage, m.timecreated,
+ mc.contextid, muaread.timecreated AS timeread
FROM {message_conversations} mc
INNER JOIN {messages} m
ON m.conversationid = mc.id
$message['textformat']);
$createdmessage->text = message_format_message_text((object) [
'smallmessage' => $createdmessage->text,
- 'fullmessageformat' => external_validate_format($message['textformat'])
+ 'fullmessageformat' => external_validate_format($message['textformat']),
+ 'fullmessagetrust' => $createdmessage->fullmessagetrust
]);
$messages[] = $createdmessage;
}
}
if ($success) {
$resultmsg['msgid'] = $success;
- $resultmsg['text'] = message_format_message_text((object) [
- 'smallmessage' => $message['text'],
- 'fullmessageformat' => external_validate_format($message['textformat'])
- ]);
$resultmsg['timecreated'] = time();
$messageids[] = $success;
} else {
}
if (!empty($messageids)) {
- $messagerecords = $DB->get_records_list('messages', 'id', $messageids, '', 'id, conversationid');
+ $messagerecords = $DB->get_records_list(
+ 'messages',
+ 'id',
+ $messageids,
+ '',
+ 'id, conversationid, smallmessage, fullmessageformat, fullmessagetrust');
$resultmessages = array_map(function($resultmessage) use ($messagerecords, $USER) {
$id = $resultmessage['msgid'];
$resultmessage['conversationid'] = isset($messagerecords[$id]) ? $messagerecords[$id]->conversationid : null;
$resultmessage['useridfrom'] = $USER->id;
+ $resultmessage['text'] = message_format_message_text((object) [
+ 'smallmessage' => $messagerecords[$id]->smallmessage,
+ 'fullmessageformat' => external_validate_format($messagerecords[$id]->fullmessageformat),
+ 'fullmessagetrust' => $messagerecords[$id]->fullmessagetrust
+ ]);
return $resultmessage;
}, $resultmessages);
}
$options = new stdClass();
$options->para = false;
$options->blanktarget = true;
+ $options->trusted = isset($message->fullmessagetrust) ? $message->fullmessagetrust : false;
$format = $message->fullmessageformat;
$chapterindexfile['userid'] = null;
$chapterindexfile['author'] = null;
$chapterindexfile['license'] = null;
+ $chapterindexfile['tags'] = \core_tag\external\util::get_item_tags('mod_book', 'book_chapters', $chapter->id);
$contents[] = $chapterindexfile;
// Chapter files (images usually).
}
public function test_export_contents() {
- global $DB;
+ global $DB, $CFG;
+ require_once($CFG->dirroot . '/course/externallib.php');
$user = $this->getDataGenerator()->create_user();
$course = $this->getDataGenerator()->create_course(array('enablecomment' => 1));
$cm = get_coursemodule_from_id('book', $book->cmid);
$bookgenerator = $this->getDataGenerator()->get_plugin_generator('mod_book');
- $chapter1 = $bookgenerator->create_chapter(array('bookid' => $book->id, "pagenum" => 1));
+ $chapter1 = $bookgenerator->create_chapter(array('bookid' => $book->id, "pagenum" => 1,
+ 'tags' => array('Cats', 'Dogs')));
+ $tag = core_tag_tag::get_by_name(0, 'Cats');
+
$chapter2 = $bookgenerator->create_chapter(array('bookid' => $book->id, "pagenum" => 2));
$subchapter = $bookgenerator->create_chapter(array('bookid' => $book->id, "pagenum" => 3, "subchapter" => 1));
$chapter3 = $bookgenerator->create_chapter(array('bookid' => $book->id, "pagenum" => 4, "hidden" => 1));
$this->assertEquals('structure', $contents[0]['filename']);
$this->assertEquals('index.html', $contents[1]['filename']);
$this->assertEquals('Chapter 1', $contents[1]['content']);
+ $this->assertCount(2, $contents[1]['tags']);
+ $this->assertEquals('Cats', $contents[1]['tags'][0]['rawname']);
+ $this->assertEquals($tag->id, $contents[1]['tags'][0]['id']);
+ $this->assertEquals('Dogs', $contents[1]['tags'][1]['rawname']);
$this->assertEquals('index.html', $contents[2]['filename']);
$this->assertEquals('Chapter 2', $contents[2]['content']);
$this->assertEquals('index.html', $contents[3]['filename']);
$this->assertEquals('Chapter 3', $contents[3]['content']);
+ // Now, test the function via the external API.
+ $contents = core_course_external::get_course_contents($course->id, array());
+ $contents = external_api::clean_returnvalue(core_course_external::get_course_contents_returns(), $contents);
+ $this->assertEquals('book', $contents[0]['modules'][0]['modname']);
+ $this->assertEquals($cm->id, $contents[0]['modules'][0]['id']);
+ $this->assertCount(2, $contents[0]['modules'][0]['contents'][1]['tags']);
+ $this->assertEquals('Cats', $contents[0]['modules'][0]['contents'][1]['tags'][0]['rawname']);
+ $this->assertEquals('Dogs', $contents[0]['modules'][0]['contents'][1]['tags'][1]['rawname']);
+
// Test empty book.
$emptybook = $this->getDataGenerator()->create_module('book', array('course' => $course->id));
$cm = get_coursemodule_from_id('book', $emptybook->cmid);
This files describes API changes in the book code.
+=== 3.7 ===
+
+* book_export_contents() callback now returns tags information for every chapter.
+
=== 3.1 ===
* The following functions, previously used (exclusively) by upgrade steps are not available
use core\external\exporter;
use renderer_base;
use core_user;
+use core_tag\external\tag_item_exporter;
/**
* Class for exporting record data.
'multiple' => true,
'optional' => true,
),
+ 'tags' => array(
+ 'type' => tag_item_exporter::read_properties_definition(),
+ 'description' => 'Tags.',
+ 'multiple' => true,
+ 'optional' => true,
+ ),
);
}
}
$values['contents'] = $contents;
}
+
+ $values['tags'] = \core_tag\external\util::get_item_tags('mod_data', 'data_records', $this->data->id);
+
return $values;
}
}
}
$this->setUser($this->student1);
- $entry11 = $generator->create_entry($this->database, $fieldcontents, $this->group1->id);
+ $entry11 = $generator->create_entry($this->database, $fieldcontents, $this->group1->id, ['Cats', 'Dogs']);
$this->setUser($this->student2);
- $entry12 = $generator->create_entry($this->database, $fieldcontents, $this->group1->id);
+ $entry12 = $generator->create_entry($this->database, $fieldcontents, $this->group1->id, ['Cats']);
$entry13 = $generator->create_entry($this->database, $fieldcontents, $this->group1->id);
// Entry not in group.
$entry14 = $generator->create_entry($this->database, $fieldcontents, 0);
$this->assertCount(3, $result['entries']);
$this->assertEquals(3, $result['totalcount']);
$this->assertEquals($entry11, $result['entries'][0]['id']);
+ $this->assertCount(2, $result['entries'][0]['tags']);
$this->assertEquals($this->student1->id, $result['entries'][0]['userid']);
$this->assertEquals($this->group1->id, $result['entries'][0]['groupid']);
$this->assertEquals($this->database->id, $result['entries'][0]['dataid']);
$this->assertEquals($entry12, $result['entries'][1]['id']);
+ $this->assertCount(1, $result['entries'][1]['tags']);
+ $this->assertEquals('Cats', $result['entries'][1]['tags'][0]['rawname']);
$this->assertEquals($this->student2->id, $result['entries'][1]['userid']);
$this->assertEquals($this->group1->id, $result['entries'][1]['groupid']);
$this->assertEquals($this->database->id, $result['entries'][1]['dataid']);
This files describes API changes in /mod/data - plugins,
information provided here is intended especially for developers.
+=== 3.7 ===
+* External functions get_entries, get_entry and search_entries now return an additional field "tags" containing the entry tags.
+
=== 3.4 ===
* External function mod_data_external::search_entries() now returns the maxcount field: Total count of records that the user could
see in the database (if all the search criterias were removed).
// Define each element separated
$forum = new backup_nested_element('forum', array('id'), array(
- 'type', 'name', 'intro', 'introformat',
+ 'type', 'name', 'intro', 'introformat', 'duedate', 'cutoffdate',
'assessed', 'assesstimestart', 'assesstimefinish', 'scale',
'maxbytes', 'maxattachments', 'forcesubscribe', 'trackingtype',
'rsstype', 'rssarticles', 'timemodified', 'warnafter',
// Any changes to the list of dates that needs to be rolled should be same during course restore and course reset.
// See MDL-9367.
+ if (!isset($data->duedate)) {
+ $data->duedate = 0;
+ }
+ $data->duedate = $this->apply_date_offset($data->duedate);
+ if (!isset($data->cutoffdate)) {
+ $data->cutoffdate = 0;
+ }
+ $data->cutoffdate = $this->apply_date_offset($data->cutoffdate);
$data->assesstimestart = $this->apply_date_offset($data->assesstimestart);
$data->assesstimefinish = $this->apply_date_offset($data->assesstimefinish);
if ($data->scale < 0) { // scale found, get mapping
'completionreplies' => $forum->get_completion_replies(),
'completionposts' => $forum->get_completion_posts(),
'displaywordcount' => $forum->should_display_word_count(),
- 'lockdiscussionafter' => $forum->get_lock_discussions_after()
+ 'lockdiscussionafter' => $forum->get_lock_discussions_after(),
+ 'duedate' => $forum->get_due_date(),
+ 'cutoffdate' => $forum->get_cutoff_date()
];
}, $forums);
}
private $displaywordcounts;
/** @var bool $lockdiscussionafter Timestamp after which discussions should be locked */
private $lockdiscussionafter;
+ /** @var int $duedate Timestamp that represents the due date for forum posts */
+ private $duedate;
+ /** @var int $cutoffdate Timestamp after which forum posts will no longer be accepted */
+ private $cutoffdate;
/**
* Constructor
* @param int $completionposts Completion posts
* @param bool $displaywordcount Should display word counts in posts
* @param int $lockdiscussionafter Timestamp after which discussions should be locked
+ * @param int $duedate Timestamp that represents the due date for forum posts
+ * @param int $cutoffdate Timestamp after which forum posts will no longer be accepted
*/
public function __construct(
context $context,
int $completionreplies,
int $completionposts,
bool $displaywordcount,
- int $lockdiscussionafter
+ int $lockdiscussionafter,
+ int $duedate,
+ int $cutoffdate
) {
$this->context = $context;
$this->coursemodule = $coursemodule;
$this->completionposts = $completionposts;
$this->displaywordcount = $displaywordcount;
$this->lockdiscussionafter = $lockdiscussionafter;
+ $this->duedate = $duedate;
+ $this->cutoffdate = $cutoffdate;
}
/**
return (($discussion->get_time_modified() + $this->get_lock_discussions_after()) < time());
}
+
+ /**
+ * Get the cutoff date.
+ *
+ * @return int
+ */
+ public function get_cutoff_date() : int {
+ return $this->cutoffdate;
+ }
+
+ /**
+ * Does the forum have a cutoff date?
+ *
+ * @return bool
+ */
+ public function has_cutoff_date() : bool {
+ return !empty($this->get_cutoff_date());
+ }
+
+ /**
+ * Is the cutoff date for the forum reached?
+ *
+ * @return bool
+ */
+ public function is_cutoff_date_reached() : bool {
+ if ($this->has_cutoff_date() && ($this->get_cutoff_date() < time())) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the due date.
+ *
+ * @return int
+ */
+ public function get_due_date() : int {
+ return $this->duedate;
+ }
+
+ /**
+ * Does the forum have a due date?
+ *
+ * @return bool
+ */
+ public function has_due_date() : bool {
+ return !empty($this->get_due_date());
+ }
+
+ /**
+ * Is the due date for the forum reached?
+ *
+ * @return bool
+ */
+ public function is_due_date_reached() : bool {
+ if ($this->has_due_date() && ($this->get_due_date() < time())) {
+ return true;
+ }
+
+ return false;
+ }
}
$record->completionreplies,
$record->completionposts,
$record->displaywordcount,
- $record->lockdiscussionafter
+ $record->lockdiscussionafter,
+ $record->duedate,
+ $record->cutoffdate
);
}
return false;
}
+ if ($this->forum->is_cutoff_date_reached()) {
+ if (!has_capability('mod/forum:canoverridecutoff', $this->get_context())) {
+ return false;
+ }
+ }
+
switch ($this->forum->get_type()) {
case 'news':
$capability = 'mod/forum:addnews';
$forum = $this->forum;
$renderer = $this->renderer;
+ if ($forum->is_cutoff_date_reached()) {
+ $notifications[] = (new notification(
+ get_string('cutoffdatereached', 'forum'),
+ notification::NOTIFY_INFO
+ ))->set_show_closebutton();
+ } else if ($forum->is_due_date_reached()) {
+ $notifications[] = (new notification(
+ get_string('thisforumisdue', 'forum', userdate($forum->get_due_date())),
+ notification::NOTIFY_INFO
+ ))->set_show_closebutton();
+ } else if ($forum->has_due_date()) {
+ $notifications[] = (new notification(
+ get_string('thisforumhasduedate', 'forum', userdate($forum->get_due_date())),
+ notification::NOTIFY_INFO
+ ))->set_show_closebutton();
+ }
+
if ($forum->is_discussion_locked($discussion)) {
$notifications[] = (new notification(
get_string('discussionlocked', 'forum'),
$renderer = $this->renderer;
$capabilitymanager = $this->capabilitymanager;
+ if ($forum->is_cutoff_date_reached()) {
+ $notifications[] = (new notification(
+ get_string('cutoffdatereached', 'forum'),
+ notification::NOTIFY_INFO
+ ))->set_show_closebutton();
+ } else if ($forum->is_due_date_reached()) {
+ $notifications[] = (new notification(
+ get_string('thisforumisdue', 'forum', userdate($forum->get_due_date())),
+ notification::NOTIFY_INFO
+ ))->set_show_closebutton();
+ } else if ($forum->has_due_date()) {
+ $notifications[] = (new notification(
+ get_string('thisforumhasduedate', 'forum', userdate($forum->get_due_date())),
+ notification::NOTIFY_INFO
+ ))->set_show_closebutton();
+ }
+
if ($forum->has_blocking_enabled()) {
$notifications[] = (new notification(
get_string('thisforumisthrottled', 'forum', [
'manager' => CAP_ALLOW
)
),
+ 'mod/forum:canoverridecutoff' => array(
+ 'captype' => 'write',
+ 'contextlevel' => CONTEXT_MODULE,
+ 'archetypes' => array(
+ 'teacher' => CAP_ALLOW,
+ 'editingteacher' => CAP_ALLOW,
+ 'manager' => CAP_ALLOW
+ ),
+ 'clonepermissionsfrom' => 'mod/forum:canoverridediscussionlock'
+ ),
);