$predictors[$fullclassname] = new lang_string('pluginname', $pluginname);
}
$settings->add(new \core_analytics\admin_setting_predictor('analytics/predictionsprocessor',
- new lang_string('predictionsprocessor', 'analytics'), new lang_string('predictionsprocessor_help', 'analytics'),
- '\mlbackend_php\processor', $predictors)
+ new lang_string('defaultpredictionsprocessor', 'analytics'), new lang_string('predictionsprocessor_help', 'analytics'),
+ \core_analytics\manager::default_mlbackend(), $predictors)
);
// Log store.
--- /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/>.
+
+/**
+ * This file gives information about Moodle Services
+ *
+ * @package core
+ * @copyright 2018 Amaia Anabitarte <amaia@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+if ($hassiteconfig) {
+
+ // Create Moodle Services information.
+ $moodleservices->add(new admin_setting_heading('moodleservicesintro', '',
+ new lang_string('moodleservices_help', 'admin')));
+
+ // Moodle Partners information.
+ if (empty($CFG->disableserviceads_partner)) {
+ $moodleservices->add(new admin_setting_heading('moodlepartners',
+ new lang_string('moodlepartners', 'admin'),
+ new lang_string('moodlepartners_help', 'admin')));
+ }
+
+ // Moodle app information.
+ $moodleservices->add(new admin_setting_heading('moodleapp',
+ new lang_string('moodleapp', 'admin'),
+ new lang_string('moodleapp_help', 'admin')));
+
+ // Branded Moodle app information.
+ if (empty($CFG->disableserviceads_branded)) {
+ $moodleservices->add(new admin_setting_heading('moodlebrandedapp',
+ new lang_string('moodlebrandedapp', 'admin'),
+ new lang_string('moodlebrandedapp_help', 'admin')));
+ }
+}
+
+
// "systempaths" settingpage
$temp = new admin_settingpage('systempaths', new lang_string('systempaths','admin'));
-
+$temp->add(new admin_setting_configexecutable('pathtophp', new lang_string('pathtophp', 'admin'),
+ new lang_string('configpathtophp', 'admin'), ''));
$temp->add(new admin_setting_configexecutable('pathtodu', new lang_string('pathtodu', 'admin'), new lang_string('configpathtodu', 'admin'), ''));
$temp->add(new admin_setting_configexecutable('aspellpath', new lang_string('aspellpath', 'admin'), new lang_string('edhelpaspellpath'), ''));
$temp->add(new admin_setting_configexecutable('pathtodot', new lang_string('pathtodot', 'admin'), new lang_string('pathtodot_help', 'admin'), ''));
// hidden upgrade script
$ADMIN->add('root', new admin_externalpage('upgradesettings', new lang_string('upgradesettings', 'admin'), "$CFG->wwwroot/$CFG->admin/upgradesettings.php", 'moodle/site:config', true));
+// Adding Moodle Services information page.
+$moodleservices = new admin_settingpage('moodleservices', new lang_string('moodleservices',
+ 'admin'));
+$ADMIN->add('root', $moodleservices);
+
if ($hassiteconfig) {
$optionalsubsystems = new admin_settingpage('optionalsubsystems', new lang_string('advancedfeatures', 'admin'));
$ADMIN->add('root', $optionalsubsystems);
--- /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 core_admin/setting_description
+
+ Admin setting description template.
+
+ Context variables required for this template:
+ * labelfor - id of the form element
+ * title - Setting title
+ * name - Setting name
+
+ Example context (json):
+ {
+ "title": "Setting title",
+ "name": "Name",
+ "description": "Description goes here"
+ }
+}}
+{{!
+ Setting description.
+}}
+<div class="form-item form-horizontal clearfix">
+ <div class="form-label">
+ <label>
+ {{{title}}}
+ </label>
+ <span class="form-shortname ">{{{name}}}</span>
+ </div>
+ <div class="controls felement fstatic">{{{description}}}</div>
+</div>
\ No newline at end of file
$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;
+ }
+ $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('hidden', 'id', $this->_customdata['id']);
$mform->setType('id', PARAM_INT);
'id' => $model->get_id(),
'model' => $model,
'indicators' => $model->get_potential_indicators(),
- 'timesplittings' => \core_analytics\manager::get_enabled_time_splitting_methods()
+ 'timesplittings' => \core_analytics\manager::get_enabled_time_splitting_methods(),
+ 'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors()
);
$mform = new \tool_analytics\output\form\edit_model(null, $customdata);
$indicators[] = \core_analytics\manager::get_indicator($indicatorclass);
}
$timesplitting = \tool_analytics\output\helper::option_to_class($data->timesplitting);
- $model->update($data->enabled, $indicators, $timesplitting);
+ $predictionsprocessor = \tool_analytics\output\helper::option_to_class($data->predictionsprocessor);
+ $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor);
redirect(new \moodle_url('/admin/tool/analytics/index.php'));
}
$callable = array('\tool_analytics\output\helper', 'class_to_option');
$modelobj->indicators = array_map($callable, json_decode($modelobj->indicators));
$modelobj->timesplitting = \tool_analytics\output\helper::class_to_option($modelobj->timesplitting);
+ $modelobj->predictionsprocessor = \tool_analytics\output\helper::class_to_option($modelobj->predictionsprocessor);
$mform->set_data($modelobj);
$mform->display();
break;
* @param int $forcedvalue Use this categoryid value as if this was this context instance category.
* @return category|false
*/
- public static function get_effective_context_category(\context $context, $forcedvalue=false) {
+ public static function get_effective_context_category(\context $context, $forcedvalue = false) {
if (!data_registry::defaults_set()) {
return false;
}
* Returns the effective category given a context level.
*
* @param int $contextlevel
- * @param int $forcedvalue Use this categoryid value as if this was this context level category.
* @return category|false
*/
- public static function get_effective_contextlevel_category($contextlevel, $forcedvalue=false) {
+ public static function get_effective_contextlevel_category($contextlevel) {
if (!data_registry::defaults_set()) {
return false;
}
- return data_registry::get_effective_contextlevel_value($contextlevel, 'category', $forcedvalue);
+ return data_registry::get_effective_contextlevel_value($contextlevel, 'category');
}
/**
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class data_registry {
-
- /**
- * @var array Inheritance between context levels.
- */
- private static $contextlevelinheritance = [
- CONTEXT_USER => [CONTEXT_SYSTEM],
- CONTEXT_COURSECAT => [CONTEXT_SYSTEM],
- CONTEXT_COURSE => [CONTEXT_COURSECAT, CONTEXT_SYSTEM],
- CONTEXT_MODULE => [CONTEXT_COURSE, CONTEXT_COURSECAT, CONTEXT_SYSTEM],
- CONTEXT_BLOCK => [CONTEXT_COURSE, CONTEXT_COURSECAT, CONTEXT_SYSTEM],
- ];
-
/**
* Returns purpose and category var names from a context class name
*
* @return int[]|false[]
*/
public static function get_defaults($contextlevel, $pluginname = '') {
-
$classname = \context_helper::get_class_for_level($contextlevel);
list($purposevar, $categoryvar) = self::var_names_from_context($classname, $pluginname);
}
if (empty($purposeid)) {
- $purposeid = false;
+ $purposeid = context_instance::NOTSET;
}
if (empty($categoryid)) {
- $categoryid = false;
+ $categoryid = context_instance::NOTSET;
}
return [$purposeid, $categoryid];
* @return persistent|false It return a 'purpose' instance or a 'category' instance, depending on $element
*/
public static function get_effective_context_value(\context $context, $element, $forcedvalue = false) {
+ global $DB;
if ($element !== 'purpose' && $element !== 'category') {
throw new coding_exception('Only \'purpose\' and \'category\' are supported.');
}
$fieldname = $element . 'id';
- if (empty($forcedvalue)) {
- $instance = context_instance::get_record_by_contextid($context->id, false);
-
- if (!$instance) {
- // If the instance does not have a value defaults to not set, so we grab the context level default as its value.
- $instancevalue = context_instance::NOTSET;
- } else {
- $instancevalue = $instance->get($fieldname);
- }
+ if (!empty($forcedvalue) && ($forcedvalue === context_instance::INHERIT)) {
+ // Do not include the current context when calculating the value.
+ // This has the effect that an inheritted value is calculated.
+ $parentcontextids = $context->get_parent_context_ids(false);
+ } else if (!empty($forcedvalue) && ($forcedvalue !== context_instance::NOTSET)) {
+ return self::get_element_instance($element, $forcedvalue);
} else {
- $instancevalue = $forcedvalue;
+ // Fetch all parent contexts, including self.
+ $parentcontextids = $context->get_parent_context_ids(true);
}
+ list($insql, $inparams) = $DB->get_in_or_equal($parentcontextids, SQL_PARAMS_NAMED);
+ $inparams['contextmodule'] = CONTEXT_MODULE;
- // Not set.
- if ($instancevalue == context_instance::NOTSET) {
-
- // The effective value varies depending on the context level.
- if ($context->contextlevel == CONTEXT_USER) {
- // Use the context level value as we don't allow people to set specific instances values.
+ if ('purpose' === $element) {
+ $elementjoin = 'LEFT JOIN {tool_dataprivacy_purpose} ele ON ctxins.purposeid = ele.id';
+ $elementfields = purpose::get_sql_fields('ele', 'ele');
+ } else {
+ $elementjoin = 'LEFT JOIN {tool_dataprivacy_category} ele ON ctxins.categoryid = ele.id';
+ $elementfields = category::get_sql_fields('ele', 'ele');
+ }
+ $contextfields = \context_helper::get_preload_record_columns_sql('ctx');
+ $fields = implode(', ', ['ctx.id', 'm.name AS modname', $contextfields, $elementfields]);
+
+ $sql = "SELECT $fields
+ FROM {context} ctx
+ LEFT JOIN {tool_dataprivacy_ctxinstance} ctxins ON ctx.id = ctxins.contextid
+ LEFT JOIN {course_modules} cm ON ctx.contextlevel = :contextmodule AND ctx.instanceid = cm.id
+ LEFT JOIN {modules} m ON m.id = cm.module
+ {$elementjoin}
+ WHERE ctx.id {$insql}
+ ORDER BY ctx.path DESC";
+ $contextinstances = $DB->get_records_sql($sql, $inparams);
+
+ // Check whether this context is a user context, or a child of a user context.
+ // All children of a User context share the same context and cannot be set individually.
+ foreach ($contextinstances as $record) {
+ \context_helper::preload_from_record($record);
+ $parent = \context::instance_by_id($record->id, false);
+
+ if ($parent->contextlevel == CONTEXT_USER) {
+ // Use the context level value for the user.
return self::get_effective_contextlevel_value(CONTEXT_USER, $element);
}
+ }
- $parents = $context->get_parent_contexts(true);
- foreach ($parents as $parent) {
- if ($parent->contextlevel == CONTEXT_USER) {
- // Use the context level value as we don't allow people to set specific instances values.
- return self::get_effective_contextlevel_value(CONTEXT_USER, $element);
- }
+ foreach ($contextinstances as $record) {
+ $parent = \context::instance_by_id($record->id, false);
+
+ $checkcontextlevel = false;
+ if (empty($record->eleid)) {
+ $checkcontextlevel = true;
}
- // Check if we need to pass the plugin name of an activity.
- $forplugin = '';
- if ($context->contextlevel == CONTEXT_MODULE) {
- list($course, $cm) = get_course_and_cm_from_cmid($context->instanceid);
- $forplugin = $cm->modname;
+ if (!empty($forcedvalue) && context_instance::NOTSET === $forcedvalue) {
+ $checkcontextlevel = true;
}
- // Use the default context level value.
- list($purposeid, $categoryid) = self::get_effective_default_contextlevel_purpose_and_category(
- $context->contextlevel, false, false, $forplugin
- );
- return self::get_element_instance($element, $$fieldname);
- }
+ if ($checkcontextlevel) {
+ // Check for a value at the contextlevel
+ $forplugin = empty($record->modname) ? '' : $record->modname;
+ list($purposeid, $categoryid) = self::get_effective_default_contextlevel_purpose_and_category(
+ $parent->contextlevel, false, false, $forplugin);
- // Specific value for this context instance.
- if ($instancevalue != context_instance::INHERIT) {
- return self::get_element_instance($element, $instancevalue);
- }
+ $instancevalue = $$fieldname;
- // This context is using inherited so let's return the parent effective value.
- $parentcontext = $context->get_parent_context();
- if (!$parentcontext) {
- return false;
+ if (context_instance::NOTSET !== $instancevalue && context_instance::INHERIT !== $instancevalue) {
+ // There is an actual value. Return it.
+ return self::get_element_instance($element, $instancevalue);
+ }
+ } else {
+ $elementclass = "\\tool_dataprivacy\\{$element}";
+ $instance = new $elementclass(null, $elementclass::extract_record($record, 'ele'));
+ $instance->validate();
+
+ return $instance;
+ }
}
- // The forced value should not be transmitted to parent contexts.
- return self::get_effective_context_value($parentcontext, $element);
+ throw new coding_exception('Something went wrong, system defaults should be set and we should already have a value.');
}
/**
*
* @param int $contextlevel
* @param string $element 'category' or 'purpose'
- * @param int $forcedvalue Use this value as if this was this context level purpose.
* @return \tool_dataprivacy\purpose|false
*/
- public static function get_effective_contextlevel_value($contextlevel, $element, $forcedvalue = false) {
-
+ public static function get_effective_contextlevel_value($contextlevel, $element) {
if ($element !== 'purpose' && $element !== 'category') {
throw new coding_exception('Only \'purpose\' and \'category\' are supported.');
}
'have a purpose or a category.');
}
- if ($forcedvalue === false) {
- $instance = contextlevel::get_record_by_contextlevel($contextlevel, false);
- if (!$instance) {
- // If the context level does not have a value defaults to not set, so we grab the context level default as
- // its value.
- $instancevalue = context_instance::NOTSET;
- } else {
- $instancevalue = $instance->get($fieldname);
- }
- } else {
- $instancevalue = $forcedvalue;
- }
+ list($purposeid, $categoryid) = self::get_effective_default_contextlevel_purpose_and_category($contextlevel);
- // Not set -> Use the default context level value.
- if ($instancevalue == context_instance::NOTSET) {
- list($purposeid, $categoryid) = self::get_effective_default_contextlevel_purpose_and_category($contextlevel);
+ // Note: The $$fieldname points to either $purposeid, or $categoryid.
+ if (context_instance::NOTSET !== $$fieldname && context_instance::INHERIT !== $$fieldname) {
+ // There is a specific value set.
return self::get_element_instance($element, $$fieldname);
}
- // Specific value for this context instance.
- if ($instancevalue != context_instance::INHERIT) {
- return self::get_element_instance($element, $instancevalue);
- }
-
- if ($contextlevel == CONTEXT_SYSTEM) {
- throw new coding_exception('Something went wrong, system defaults should be set and we should already have a value.');
- }
-
- // If we reach this point is that we are inheriting so get the parent context level and repeat.
- $parentcontextlevel = reset(self::$contextlevelinheritance[$contextlevel]);
-
- // Forced value are intentionally not passed as the force value should only affect the immediate context level.
- return self::get_effective_contextlevel_value($parentcontextlevel, $element);
+ throw new coding_exception('Something went wrong, system defaults should be set and we should already have a value.');
}
/**
* @param int $contextlevel
* @param int|bool $forcedpurposevalue Use this value as if this was this context level purpose.
* @param int|bool $forcedcategoryvalue Use this value as if this was this context level category.
- * @param string $activity The plugin name of the activity.
+ * @param string $component The name of the component to check.
* @return int[]
*/
public static function get_effective_default_contextlevel_purpose_and_category($contextlevel, $forcedpurposevalue = false,
- $forcedcategoryvalue = false, $activity = '') {
-
- list($purposeid, $categoryid) = self::get_defaults($contextlevel, $activity);
+ $forcedcategoryvalue = false, $component = '') {
+ // Get the defaults for this context level.
+ list($purposeid, $categoryid) = self::get_defaults($contextlevel, $component);
// Honour forced values.
if ($forcedpurposevalue) {
$categoryid = $forcedcategoryvalue;
}
- // Not set == INHERIT for defaults.
- if ($purposeid == context_instance::INHERIT || $purposeid == context_instance::NOTSET) {
- $purposeid = false;
- }
- if ($categoryid == context_instance::INHERIT || $categoryid == context_instance::NOTSET) {
- $categoryid = false;
- }
-
- if ($contextlevel != CONTEXT_SYSTEM && ($purposeid === false || $categoryid === false)) {
- foreach (self::$contextlevelinheritance[$contextlevel] as $parent) {
+ if ($contextlevel == CONTEXT_USER) {
+ // Only user context levels inherit from a parent context level.
+ list($parentpurposeid, $parentcategoryid) = self::get_defaults(CONTEXT_SYSTEM);
- list($parentpurposeid, $parentcategoryid) = self::get_defaults($parent);
- // Not set == INHERIT for defaults.
- if ($parentpurposeid == context_instance::INHERIT || $parentpurposeid == context_instance::NOTSET) {
- $parentpurposeid = false;
- }
- if ($parentcategoryid == context_instance::INHERIT || $parentcategoryid == context_instance::NOTSET) {
- $parentcategoryid = false;
- }
-
- if ($purposeid === false && $parentpurposeid) {
- $purposeid = $parentpurposeid;
- }
+ if (context_instance::INHERIT == $purposeid || context_instance::NOTSET == $purposeid) {
+ $purposeid = $parentpurposeid;
+ }
- if ($categoryid === false && $parentcategoryid) {
- $categoryid = $parentcategoryid;
- }
+ if (context_instance::INHERIT == $categoryid || context_instance::NOTSET == $categoryid) {
+ $categoryid = $parentcategoryid;
}
}
- // They may still be false, but we return anyway.
return [$purposeid, $categoryid];
}
* @return \core\persistent
*/
private static function get_element_instance($element, $id) {
-
if ($element !== 'purpose' && $element !== 'category') {
throw new coding_exception('No other elements than purpose and category are allowed');
}
$persistent->set('contextid', $context->id);
}
- $purposeoptions = \tool_dataprivacy\output\data_registry_page::purpose_options(
- api::get_purposes()
- );
- $categoryoptions = \tool_dataprivacy\output\data_registry_page::category_options(
- api::get_categories()
- );
+ $purposes = [];
+ foreach (api::get_purposes() as $purpose) {
+ $purposes[$purpose->get('id')] = $purpose;
+ }
+ $purposeoptions = \tool_dataprivacy\output\data_registry_page::purpose_options($purposes);
+ $categoryoptions = \tool_dataprivacy\output\data_registry_page::category_options(api::get_categories());
$customdata = [
'context' => $context,
$context);
$customdata['purposeretentionperiods'] = [];
- foreach ($purposeoptions as $optionvalue => $unused) {
- // Get the effective purpose if $optionvalue would be the selected value.
- $purpose = api::get_effective_context_purpose($context, $optionvalue);
+ foreach (array_keys($purposeoptions) as $optionvalue) {
+
+ if (isset($purposes[$optionvalue])) {
+ $purpose = $purposes[$optionvalue];
+ } else {
+ // Get the effective purpose if $optionvalue would be the selected value.
+ $purpose = api::get_effective_context_purpose($context, $optionvalue);
+ }
$retentionperiod = self::get_retention_display_text(
$purpose,
require_once('createdatarequest_form.php');
$manage = optional_param('manage', 0, PARAM_INT);
+$requesttype = optional_param('type', \tool_dataprivacy\api::DATAREQUEST_TYPE_EXPORT, PARAM_INT);
-$url = new moodle_url('/admin/tool/dataprivacy/createdatarequest.php', ['manage' => $manage]);
+$url = new moodle_url('/admin/tool/dataprivacy/createdatarequest.php', ['manage' => $manage, 'type' => $requesttype]);
$PAGE->set_url($url);
}
$mform = new tool_dataprivacy_data_request_form($url->out(false), ['manage' => !empty($manage)]);
+$mform->set_data(['type' => $requesttype]);
// Data request cancelled.
if ($mform->is_cancelled()) {
$string['deletedefaultsconfirmation'] = 'Are you sure you want to delete the default category and purpose for {$a} modules?';
$string['deleteexpiredcontextstask'] = 'Delete expired contexts';
$string['deleteexpireddatarequeststask'] = 'Delete files from completed data requests that have expired';
+$string['deletemyaccount'] = 'Delete my account';
$string['deletepurpose'] = 'Delete purpose';
$string['deletepurposetext'] = 'Are you sure you want to delete the purpose \'{$a}\'?';
$string['defaultssaved'] = 'Defaults saved';
$node = new core_user\output\myprofile\node('privacyandpolicies', 'datarequests',
get_string('datarequests', 'tool_dataprivacy'), null, $url);
$category->add_node($node);
+
+ // Check if the user has an ongoing data export request.
+ $hasexportrequest = \tool_dataprivacy\api::has_ongoing_request($user->id, \tool_dataprivacy\api::DATAREQUEST_TYPE_EXPORT);
+ // Show data export link only if the user doesn't have an ongoing data export request.
+ if (!$hasexportrequest) {
+ $exportparams = ['type' => \tool_dataprivacy\api::DATAREQUEST_TYPE_EXPORT];
+ $exporturl = new moodle_url('/admin/tool/dataprivacy/createdatarequest.php', $exportparams);
+ $exportnode = new core_user\output\myprofile\node('privacyandpolicies', 'requestdataexport',
+ get_string('requesttypeexport', 'tool_dataprivacy'), null, $exporturl);
+ $category->add_node($exportnode);
+ }
+
+ // 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) {
+ $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',
+ get_string('deletemyaccount', 'tool_dataprivacy'), null, $deleteurl);
+ $category->add_node($deletenode);
+ }
}
$summaryurl = new moodle_url('/admin/tool/dataprivacy/summary.php');
$this->assertEquals($purposes[0]->get('id'), $purposeid);
$this->assertEquals(false, $categoryid);
- // Course inherits from system if not defined.
- list($purposeid, $categoryid) = data_registry::get_effective_default_contextlevel_purpose_and_category(CONTEXT_COURSE);
- $this->assertEquals($purposes[0]->get('id'), $purposeid);
- $this->assertEquals(false, $categoryid);
-
// Course defined values should have preference.
list($purposevar, $categoryvar) = data_registry::var_names_from_context(
\context_helper::get_class_for_level(CONTEXT_COURSE)
// Context level defaults are also allowed to be set to 'inherit'.
set_config($purposevar, context_instance::INHERIT, 'tool_dataprivacy');
+ }
- list($purposeid, $categoryid) = data_registry::get_effective_default_contextlevel_purpose_and_category(CONTEXT_COURSE);
- $this->assertEquals($purposes[0]->get('id'), $purposeid);
- $this->assertEquals($categories[0]->get('id'), $categoryid);
+ /**
+ * Ensure that when nothing is configured, all values return false.
+ */
+ public function test_get_effective_contextlevel_unset() {
+ // Before setup, get_effective_contextlevel_purpose will return false.
+ $this->assertFalse(api::get_effective_contextlevel_category(CONTEXT_SYSTEM));
+ $this->assertFalse(api::get_effective_contextlevel_purpose(CONTEXT_SYSTEM));
- list($purposeid, $categoryid) = data_registry::get_effective_default_contextlevel_purpose_and_category(CONTEXT_MODULE);
- $this->assertEquals($purposes[0]->get('id'), $purposeid);
- $this->assertEquals($categories[0]->get('id'), $categoryid);
+ $this->assertFalse(api::get_effective_contextlevel_category(CONTEXT_USER));
+ $this->assertFalse(api::get_effective_contextlevel_purpose(CONTEXT_USER));
}
- public function test_get_effective_contextlevel_category() {
+ /**
+ * Ensure that when nothing is configured, all values return false.
+ */
+ public function test_get_effective_context_unset() {
// Before setup, get_effective_contextlevel_purpose will return false.
- $this->assertFalse(api::get_effective_contextlevel_category(CONTEXT_SYSTEM));
+ $this->assertFalse(api::get_effective_context_category(\context_system::instance()));
+ $this->assertFalse(api::get_effective_context_purpose(\context_system::instance()));
+ }
+
+ /**
+ * Ensure that fetching the effective value for context levels is only available to system, and user context levels.
+ *
+ * @dataProvider invalid_effective_contextlevel_provider
+ * @param int $contextlevel
+ */
+ public function test_set_contextlevel_invalid_contextlevels($contextlevel) {
+
+ $this->expectException(coding_exception::class);
+ api::set_contextlevel((object) [
+ 'contextlevel' => $contextlevel,
+ ]);
+
}
/**
* Test effective contextlevel return.
*/
public function test_effective_contextlevel() {
- $this->setAdminUser();
-
$this->resetAfterTest();
- // Before setup, get_effective_contextlevel_purpose will return false.
- $this->assertFalse(api::get_effective_contextlevel_purpose(CONTEXT_SYSTEM));
+ // Set the initial purpose and category.
+ $purpose1 = api::create_purpose((object)['name' => 'p1', 'retentionperiod' => 'PT1H', 'lawfulbases' => 'gdpr_art_6_1_a']);
+ $category1 = api::create_category((object)['name' => 'a']);
+ api::set_contextlevel((object)[
+ 'contextlevel' => CONTEXT_SYSTEM,
+ 'purposeid' => $purpose1->get('id'),
+ 'categoryid' => $category1->get('id'),
+ ]);
- list($purposes, $categories, $courses, $modules) = $this->add_purposes_and_categories();
+ $this->assertEquals($purpose1, api::get_effective_contextlevel_purpose(CONTEXT_SYSTEM));
+ $this->assertEquals($category1, api::get_effective_contextlevel_category(CONTEXT_SYSTEM));
- // Set the system context level to purpose 1.
- $record = (object)[
- 'contextlevel' => CONTEXT_SYSTEM,
- 'purposeid' => $purposes[1]->get('id'),
- 'categoryid' => $categories[1]->get('id'),
- ];
- api::set_contextlevel($record);
+ // The user context inherits from the system context when not set.
+ $this->assertEquals($purpose1, api::get_effective_contextlevel_purpose(CONTEXT_USER));
+ $this->assertEquals($category1, api::get_effective_contextlevel_category(CONTEXT_USER));
- $purpose = api::get_effective_contextlevel_purpose(CONTEXT_SYSTEM);
- $this->assertEquals($purposes[1]->get('id'), $purpose->get('id'));
+ // Forcing the behaviour to inherit will have the same result.
+ api::set_contextlevel((object) [
+ 'contextlevel' => CONTEXT_USER,
+ 'purposeid' => context_instance::INHERIT,
+ 'categoryid' => context_instance::INHERIT,
+ ]);
+ $this->assertEquals($purpose1, api::get_effective_contextlevel_purpose(CONTEXT_USER));
+ $this->assertEquals($category1, api::get_effective_contextlevel_category(CONTEXT_USER));
- // Value 'not set' will get the default value for the context level. For context level defaults
- // both 'not set' and 'inherit' result in inherit, so the parent context (system) default
- // will be retrieved.
- $purpose = api::get_effective_contextlevel_purpose(CONTEXT_USER);
- $this->assertEquals($purposes[1]->get('id'), $purpose->get('id'));
+ // Setting specific values will override the inheritance behaviour.
+ $purpose2 = api::create_purpose((object)['name' => 'p2', 'retentionperiod' => 'PT2H', 'lawfulbases' => 'gdpr_art_6_1_a']);
+ $category2 = api::create_category((object)['name' => 'b']);
+ // Set the system context level to purpose 1.
+ api::set_contextlevel((object) [
+ 'contextlevel' => CONTEXT_USER,
+ 'purposeid' => $purpose2->get('id'),
+ 'categoryid' => $category2->get('id'),
+ ]);
- // The behaviour forcing an inherit from context system should result in the same effective
- // purpose.
- $record->purposeid = context_instance::INHERIT;
- $record->contextlevel = CONTEXT_USER;
- api::set_contextlevel($record);
- $purpose = api::get_effective_contextlevel_purpose(CONTEXT_USER);
- $this->assertEquals($purposes[1]->get('id'), $purpose->get('id'));
+ $this->assertEquals($purpose2, api::get_effective_contextlevel_purpose(CONTEXT_USER));
+ $this->assertEquals($category2, api::get_effective_contextlevel_category(CONTEXT_USER));
+ }
- $record->purposeid = $purposes[2]->get('id');
- $record->contextlevel = CONTEXT_USER;
- api::set_contextlevel($record);
+ /**
+ * Ensure that fetching the effective value for context levels is only available to system, and user context levels.
+ *
+ * @dataProvider invalid_effective_contextlevel_provider
+ * @param int $contextlevel
+ */
+ public function test_effective_contextlevel_invalid_contextlevels($contextlevel) {
+ $this->resetAfterTest();
- $purpose = api::get_effective_contextlevel_purpose(CONTEXT_USER);
- $this->assertEquals($purposes[2]->get('id'), $purpose->get('id'));
+ $purpose1 = api::create_purpose((object)['name' => 'p1', 'retentionperiod' => 'PT1H', 'lawfulbases' => 'gdpr_art_6_1_a']);
+ $category1 = api::create_category((object)['name' => 'a']);
+ api::set_contextlevel((object)[
+ 'contextlevel' => CONTEXT_SYSTEM,
+ 'purposeid' => $purpose1->get('id'),
+ 'categoryid' => $category1->get('id'),
+ ]);
- // Only system and user allowed.
$this->expectException(coding_exception::class);
- $record->contextlevel = CONTEXT_COURSE;
- $record->purposeid = $purposes[1]->get('id');
- api::set_contextlevel($record);
+ api::get_effective_contextlevel_purpose($contextlevel);
}
/**
- * Test effective context purposes and categories.
- *
- * @return null
+ * Data provider for invalid contextlevel fetchers.
*/
- public function test_effective_context() {
+ public function invalid_effective_contextlevel_provider() {
+ return [
+ [CONTEXT_COURSECAT],
+ [CONTEXT_COURSE],
+ [CONTEXT_MODULE],
+ [CONTEXT_BLOCK],
+ ];
+ }
+
+ /**
+ * Ensure that context inheritance works up the context tree.
+ */
+ public function test_effective_context_inheritance() {
$this->resetAfterTest();
- $this->setAdminUser();
+ $systemdata = $this->create_and_set_purpose_for_contextlevel('PT1S', CONTEXT_SYSTEM);
+
+ /*
+ * System
+ * - Cat
+ * - Subcat
+ * - Course
+ * - Forum
+ * - User
+ * - User block
+ */
+ $cat = $this->getDataGenerator()->create_category();
+ $subcat = $this->getDataGenerator()->create_category(['parent' => $cat->id]);
+ $course = $this->getDataGenerator()->create_course(['category' => $subcat->id]);
+ $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+ list(, $forumcm) = get_course_and_cm_from_instance($forum->id, 'forum');
- list($purposes, $categories, $courses, $modules) = $this->add_purposes_and_categories();
+ $user = $this->getDataGenerator()->create_user();
- // Define system defaults (all context levels below will inherit).
- list($purposevar, $categoryvar) = data_registry::var_names_from_context(
- \context_helper::get_class_for_level(CONTEXT_SYSTEM)
- );
- set_config($purposevar, $purposes[0]->get('id'), 'tool_dataprivacy');
- set_config($categoryvar, $categories[0]->get('id'), 'tool_dataprivacy');
+ $contextsystem = \context_system::instance();
+ $contextcat = \context_coursecat::instance($cat->id);
+ $contextsubcat = \context_coursecat::instance($subcat->id);
+ $contextcourse = \context_course::instance($course->id);
+ $contextforum = \context_module::instance($forumcm->id);
+ $contextuser = \context_user::instance($user->id);
+
+ // Initially everything is set to Inherit.
+ $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsystem));
+ $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextcat));
+ $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsubcat));
+ $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextcourse));
+ $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextforum));
+ $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextuser));
+
+ $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsystem));
+ $this->assertEquals($systemdata->category, api::get_effective_context_category($contextcat));
+ $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsubcat));
+ $this->assertEquals($systemdata->category, api::get_effective_context_category($contextcourse));
+ $this->assertEquals($systemdata->category, api::get_effective_context_category($contextforum));
+ $this->assertEquals($systemdata->category, api::get_effective_context_category($contextuser));
+
+ // When actively set, user will use the specified value.
+ $userdata = $this->create_and_set_purpose_for_contextlevel('PT1S', CONTEXT_USER);
+
+ $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsystem));
+ $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextcat));
+ $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsubcat));
+ $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextcourse));
+ $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextforum));
+ $this->assertEquals($userdata->purpose, api::get_effective_context_purpose($contextuser));
+
+ $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsystem));
+ $this->assertEquals($systemdata->category, api::get_effective_context_category($contextcat));
+ $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsubcat));
+ $this->assertEquals($systemdata->category, api::get_effective_context_category($contextcourse));
+ $this->assertEquals($systemdata->category, api::get_effective_context_category($contextforum));
+ $this->assertEquals($userdata->category, api::get_effective_context_category($contextuser));
+
+ // Set a context for the top category.
+ $catpurpose = new purpose(0, (object) [
+ 'name' => 'Purpose',
+ 'retentionperiod' => 'P1D',
+ 'lawfulbases' => 'gdpr_art_6_1_a',
+ ]);
+ $catpurpose->save();
+ $catcategory = new category(0, (object) ['name' => 'Category']);
+ $catcategory->save();
+ api::set_context_instance((object) [
+ 'contextid' => $contextcat->id,
+ 'purposeid' => $catpurpose->get('id'),
+ 'categoryid' => $catcategory->get('id'),
+ ]);
- // Define course defaults.
- list($purposevar, $categoryvar) = data_registry::var_names_from_context(
- \context_helper::get_class_for_level(CONTEXT_COURSE)
- );
- set_config($purposevar, $purposes[1]->get('id'), 'tool_dataprivacy');
- set_config($categoryvar, $categories[1]->get('id'), 'tool_dataprivacy');
+ $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsystem));
+ $this->assertEquals($catpurpose, api::get_effective_context_purpose($contextcat));
+ $this->assertEquals($catpurpose, api::get_effective_context_purpose($contextsubcat));
+ $this->assertEquals($catpurpose, api::get_effective_context_purpose($contextcourse));
+ $this->assertEquals($catpurpose, api::get_effective_context_purpose($contextforum));
+
+ $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsystem));
+ $this->assertEquals($catcategory, api::get_effective_context_category($contextcat));
+ $this->assertEquals($catcategory, api::get_effective_context_category($contextsubcat));
+ $this->assertEquals($catcategory, api::get_effective_context_category($contextcourse));
+ $this->assertEquals($catcategory, api::get_effective_context_category($contextforum));
+
+ // Set a context for the sub category.
+ $subcatpurpose = new purpose(0, (object) [
+ 'name' => 'Purpose',
+ 'retentionperiod' => 'P1D',
+ 'lawfulbases' => 'gdpr_art_6_1_a',
+ ]);
+ $subcatpurpose->save();
+ $subcatcategory = new category(0, (object) ['name' => 'Category']);
+ $subcatcategory->save();
+ api::set_context_instance((object) [
+ 'contextid' => $contextsubcat->id,
+ 'purposeid' => $subcatpurpose->get('id'),
+ 'categoryid' => $subcatcategory->get('id'),
+ ]);
- $course0context = \context_course::instance($courses[0]->id);
- $course1context = \context_course::instance($courses[1]->id);
- $mod0context = \context_module::instance($modules[0]->cmid);
- $mod1context = \context_module::instance($modules[1]->cmid);
+ $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsystem));
+ $this->assertEquals($catpurpose, api::get_effective_context_purpose($contextcat));
+ $this->assertEquals($subcatpurpose, api::get_effective_context_purpose($contextsubcat));
+ $this->assertEquals($subcatpurpose, api::get_effective_context_purpose($contextcourse));
+ $this->assertEquals($subcatpurpose, api::get_effective_context_purpose($contextforum));
+
+ $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsystem));
+ $this->assertEquals($catcategory, api::get_effective_context_category($contextcat));
+ $this->assertEquals($subcatcategory, api::get_effective_context_category($contextsubcat));
+ $this->assertEquals($subcatcategory, api::get_effective_context_category($contextcourse));
+ $this->assertEquals($subcatcategory, api::get_effective_context_category($contextforum));
+
+ // Set a context for the course.
+ $coursepurpose = new purpose(0, (object) [
+ 'name' => 'Purpose',
+ 'retentionperiod' => 'P1D',
+ 'lawfulbases' => 'gdpr_art_6_1_a',
+ ]);
+ $coursepurpose->save();
+ $coursecategory = new category(0, (object) ['name' => 'Category']);
+ $coursecategory->save();
+ api::set_context_instance((object) [
+ 'contextid' => $contextcourse->id,
+ 'purposeid' => $coursepurpose->get('id'),
+ 'categoryid' => $coursecategory->get('id'),
+ ]);
- // Set course instance values.
- $record = (object)[
- 'contextid' => $course0context->id,
- 'purposeid' => $purposes[1]->get('id'),
- 'categoryid' => $categories[2]->get('id'),
- ];
- api::set_context_instance($record);
- $category = api::get_effective_context_category($course0context);
- $this->assertEquals($record->categoryid, $category->get('id'));
-
- // Module instances get the context level default if nothing specified.
- $category = api::get_effective_context_category($mod0context);
- $this->assertEquals($categories[1]->get('id'), $category->get('id'));
-
- // Module instances get the parent context category if they inherit.
- $record->contextid = $mod0context->id;
- $record->categoryid = context_instance::INHERIT;
- api::set_context_instance($record);
- $category = api::get_effective_context_category($mod0context);
- $this->assertEquals($categories[2]->get('id'), $category->get('id'));
-
- // The $forcedvalue param allows us to override the actual value (method php-docs for more info).
- $category = api::get_effective_context_category($mod0context, $categories[1]->get('id'));
- $this->assertEquals($categories[1]->get('id'), $category->get('id'));
- $category = api::get_effective_context_category($mod0context, $categories[0]->get('id'));
- $this->assertEquals($categories[0]->get('id'), $category->get('id'));
-
- // Module instances get the parent context category if they inherit; in
- // this case the parent context category is not set so it should use the
- // context level default (see 'Define course defaults' above).
- $record->contextid = $mod1context->id;
- $record->categoryid = context_instance::INHERIT;
- api::set_context_instance($record);
- $category = api::get_effective_context_category($mod1context);
- $this->assertEquals($categories[1]->get('id'), $category->get('id'));
-
- // User instances use the value set at user context level instead of the user default.
-
- // User defaults to cat 0 and user context level to 1.
- list($purposevar, $categoryvar) = data_registry::var_names_from_context(
- \context_helper::get_class_for_level(CONTEXT_USER)
- );
- set_config($purposevar, $purposes[0]->get('id'), 'tool_dataprivacy');
- set_config($categoryvar, $categories[0]->get('id'), 'tool_dataprivacy');
- $usercontextlevel = (object)[
- 'contextlevel' => CONTEXT_USER,
- 'purposeid' => $purposes[1]->get('id'),
- 'categoryid' => $categories[1]->get('id'),
- ];
- api::set_contextlevel($usercontextlevel);
+ $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsystem));
+ $this->assertEquals($catpurpose, api::get_effective_context_purpose($contextcat));
+ $this->assertEquals($subcatpurpose, api::get_effective_context_purpose($contextsubcat));
+ $this->assertEquals($coursepurpose, api::get_effective_context_purpose($contextcourse));
+ $this->assertEquals($coursepurpose, api::get_effective_context_purpose($contextforum));
+
+ $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsystem));
+ $this->assertEquals($catcategory, api::get_effective_context_category($contextcat));
+ $this->assertEquals($subcatcategory, api::get_effective_context_category($contextsubcat));
+ $this->assertEquals($coursecategory, api::get_effective_context_category($contextcourse));
+ $this->assertEquals($coursecategory, api::get_effective_context_category($contextforum));
+
+ // Set a context for the forum.
+ $forumpurpose = new purpose(0, (object) [
+ 'name' => 'Purpose',
+ 'retentionperiod' => 'P1D',
+ 'lawfulbases' => 'gdpr_art_6_1_a',
+ ]);
+ $forumpurpose->save();
+ $forumcategory = new category(0, (object) ['name' => 'Category']);
+ $forumcategory->save();
+ api::set_context_instance((object) [
+ 'contextid' => $contextforum->id,
+ 'purposeid' => $forumpurpose->get('id'),
+ 'categoryid' => $forumcategory->get('id'),
+ ]);
- $newuser = $this->getDataGenerator()->create_user();
- $usercontext = \context_user::instance($newuser->id);
- $category = api::get_effective_context_category($usercontext);
- $this->assertEquals($categories[1]->get('id'), $category->get('id'));
+ $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsystem));
+ $this->assertEquals($catpurpose, api::get_effective_context_purpose($contextcat));
+ $this->assertEquals($subcatpurpose, api::get_effective_context_purpose($contextsubcat));
+ $this->assertEquals($coursepurpose, api::get_effective_context_purpose($contextcourse));
+ $this->assertEquals($forumpurpose, api::get_effective_context_purpose($contextforum));
+
+ $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsystem));
+ $this->assertEquals($catcategory, api::get_effective_context_category($contextcat));
+ $this->assertEquals($subcatcategory, api::get_effective_context_category($contextsubcat));
+ $this->assertEquals($coursecategory, api::get_effective_context_category($contextcourse));
+ $this->assertEquals($forumcategory, api::get_effective_context_category($contextforum));
}
/**
$this->resetAfterTest();
$purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H');
- $purposes->course->set('protected', 1)->save();
+ $purposes->course->purpose->set('protected', 1)->save();
$user = $this->getDataGenerator()->create_user();
$course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - YEARSECS]);
$request = api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE);
- $purposes->course->set('protected', 1)->save();
+ $purposes->course->purpose->set('protected', 1)->save();
api::add_request_contexts_with_status($collection, $request->get('id'), contextlist_context::STATUS_APPROVED);
$requests = contextlist_context::get_records();
$this->resetAfterTest();
$purposes = $this->setup_basics('PT1H', 'PT1H', 'P1Y');
- $purposes->course->set('protected', 1)->save();
+ $purposes->course->purpose->set('protected', 1)->save();
$user = $this->getDataGenerator()->create_user();
$course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time()]);
$request = api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE);
- $purposes->course->set('protected', 1)->save();
+ $purposes->course->purpose->set('protected', 1)->save();
api::add_request_contexts_with_status($collection, $request->get('id'), contextlist_context::STATUS_APPROVED);
$requests = contextlist_context::get_records();
$this->resetAfterTest();
$purposes = $this->setup_basics('PT1H', 'PT1H', 'P1Y');
- $purposes->course->set('protected', 1)->save();
+ $purposes->course->purpose->set('protected', 1)->save();
$user = $this->getDataGenerator()->create_user();
$course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time()]);
$request = api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE);
- $purposes->course->set('protected', 0)->save();
+ $purposes->course->purpose->set('protected', 0)->save();
api::add_request_contexts_with_status($collection, $request->get('id'), contextlist_context::STATUS_APPROVED);
$requests = contextlist_context::get_records();
$this->resetAfterTest();
$purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H');
- $purposes->course->set('protected', 1)->save();
+ $purposes->course->purpose->set('protected', 1)->save();
$user = $this->getDataGenerator()->create_user();
$course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - YEARSECS]);
]);
$rcl->save();
- $purposes->course->set('protected', 1)->save();
+ $purposes->course->purpose->set('protected', 1)->save();
$collection = api::get_approved_contextlist_collection_for_request($request);
$this->assertCount(1, $collection);
$this->resetAfterTest();
$purposes = $this->setup_basics('PT1H', 'PT1H', 'P1Y');
- $purposes->course->set('protected', 1)->save();
+ $purposes->course->purpose->set('protected', 1)->save();
$user = $this->getDataGenerator()->create_user();
$course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time()]);
]);
$rcl->save();
- $purposes->course->set('protected', 1)->save();
+ $purposes->course->purpose->set('protected', 1)->save();
$collection = api::get_approved_contextlist_collection_for_request($request);
$this->assertCount(0, $collection);
$this->resetAfterTest();
$purposes = $this->setup_basics('PT1H', 'PT1H', 'P1Y');
- $purposes->course->set('protected', 1)->save();
+ $purposes->course->purpose->set('protected', 1)->save();
$user = $this->getDataGenerator()->create_user();
$course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time()]);
]);
$rcl->save();
- $purposes->course->set('protected', 0)->save();
+ $purposes->course->purpose->set('protected', 0)->save();
$collection = api::get_approved_contextlist_collection_for_request($request);
$this->assertCount(1, $collection);
*
* @param string $retention
* @param int $contextlevel
- * @return purpose
*/
- protected function create_and_set_purpose_for_contextlevel(string $retention, int $contextlevel) : purpose {
+ protected function create_and_set_purpose_for_contextlevel(string $retention, int $contextlevel) {
$purpose = new purpose(0, (object) [
'name' => 'Test purpose ' . rand(1, 1000),
'retentionperiod' => $retention,
set_config($purposevar, $purpose->get('id'), 'tool_dataprivacy');
}
- return $purpose;
+ return (object) [
+ 'purpose' => $purpose,
+ 'category' => $cat,
+ ];
}
}
And I run all adhoc tasks
And I reload the page
And I should see "Awaiting approval" in the "Victim User 1" "table_row"
- And I follow "Actions"
+ And I open the action menu in "Victim User 1" "table_row"
And I follow "Approve request"
And I press "Approve request"
And I should see "Approved" in the "Victim User 1" "table_row"
And I log out
And I log in as "admin"
And I navigate to "Users > Privacy and policies > Data requests" in site administration
- And I follow "Actions"
+ And I open the action menu in "Victim User 1" "table_row"
And I follow "Approve request"
And I press "Approve request"
And I log out
And I log in as "admin"
And I navigate to "Users > Privacy and policies > Data requests" in site administration
- And I follow "Actions"
+ And I open the action menu in "Victim User 1" "table_row"
And I follow "Approve request"
And I press "Approve request"
And I run all adhoc tasks
And I reload the page
And I should see "Awaiting approval" in the "Victim User 1" "table_row"
- And I follow "Actions"
+ And I open the action menu in "Victim User 1" "table_row"
And I follow "Approve request"
And I press "Approve request"
And I should see "Approved" in the "Victim User 1" "table_row"
And I run all adhoc tasks
And I reload the page
And I should see "Download ready" in the "Victim User 1" "table_row"
- And I follow "Actions"
+ And I open the action menu in "Victim User 1" "table_row"
And following "Download" should download between "1" and "100000" bytes
And the following config values are set as admin:
| privacyrequestexpiry | 1 | tool_dataprivacy |
And I wait "1" seconds
And I navigate to "Users > Privacy and policies > Data requests" in site administration
And I should see "Expired" in the "Victim User 1" "table_row"
- And I follow "Actions"
+ And I open the action menu in "Victim User 1" "table_row"
And I should not see "Download"
@javascript
And I log out
And I log in as "admin"
And I navigate to "Users > Privacy and policies > Data requests" in site administration
- And I follow "Actions"
+ And I open the action menu in "Victim User 1" "table_row"
And I follow "Approve request"
And I press "Approve request"
And I run all adhoc tasks
And I reload the page
And I should see "Download ready" in the "Export all of my personal data" "table_row"
- And I follow "Actions"
+ And I open the action menu in "Victim User 1" "table_row"
And following "Download" should download between "1" and "100000" bytes
And the following config values are set as admin:
And I log out
And I log in as "admin"
And I navigate to "Users > Privacy and policies > Data requests" in site administration
- And I follow "Actions"
+ And I open the action menu in "Victim User 1" "table_row"
And I follow "Approve request"
And I press "Approve request"
And I run all adhoc tasks
And I reload the page
And I should see "Download ready" in the "Victim User 1" "table_row"
- And I follow "Actions"
+ And I open the action menu in "Victim User 1" "table_row"
And following "Download" should download between "1" and "100000" bytes
And the following config values are set as admin:
Background:
Given I log in as "admin"
And I navigate to "Users > Privacy and policies > Data registry" in site administration
- And I click on "Edit" "link"
+ And I open the action menu in "region-main" "region"
And I choose "Categories" in the open action menu
And I press "Add category"
And I set the field "Name" to "Category 1"
And I should see "Category 1 description" in the "Category 1" "table_row"
Scenario: Update a data category
- Given I click on "Actions" "link" in the "Category 1" "table_row"
+ Given I open the action menu in "Category 1" "table_row"
And I choose "Edit" in the open action menu
And I set the field "Name" to "Category 1 edited"
And I set the field "Description" to "Category 1 description edited"
And I should see "Category 1 description edited" in the "List of data categories" "table"
Scenario: Delete a data category
- Given I click on "Actions" "link" in the "Category 1" "table_row"
+ Given I open the action menu in "Category 1" "table_row"
And I choose "Delete" in the open action menu
And I should see "Delete category"
And I should see "Are you sure you want to delete the category 'Category 1'?"
And I navigate to "Users > Privacy and policies > Data requests" in site administration
Then I should see "Hi PO!" in the "John Doe" "table_row"
And I should see "Dear Mr. Privacy Officer" in the "Jane Doe" "table_row"
- And I click on "Actions" "link" in the "John Doe" "table_row"
+ And I open the action menu in "John Doe" "table_row"
And I should see "View the request"
And I should see "Mark as complete"
And I choose "View the request" in the open action menu
And I press "Mark as complete"
And I wait until the page is ready
And I should see "Complete" in the "John Doe" "table_row"
- And I click on "Actions" "link" in the "John Doe" "table_row"
+ And I open the action menu in "John Doe" "table_row"
And I should see "View the request"
But I should not see "Mark as complete"
And I press key "27" in ".moodle-actionmenu" "css_element"
- And I click on "Actions" "link" in the "Jane Doe" "table_row"
+ And I open the action menu in "Jane Doe" "table_row"
And I choose "Mark as complete" in the open action menu
And I should see "Do you really want to mark this user enquiry as complete?"
And I press "Mark as complete"
And I wait until the page is ready
And I should see "Complete" in the "Jane Doe" "table_row"
- And I click on "Actions" "link" in the "Jane Doe" "table_row"
+ And I open the action menu in "Jane Doe" "table_row"
And I should see "View the request"
But I should not see "Mark as complete"
| Purpose 2 | P5Y |
And I set the site category and purpose to "Site category" and "Site purpose"
+ # Setting a default for course categories should apply to everything beneath that category.
Scenario: Set course category data registry defaults
- Given I set the category and purpose for the course category "scitech" to "Category 2" and "Purpose 2"
- And I navigate to "Users > Privacy and policies > Data registry" in site administration
+ Given I navigate to "Users > Privacy and policies > Data registry" in site administration
And I click on "Set defaults" "link"
And I should see "Inherit"
- And I should not see "Add a new module default"
And I press "Edit"
And I set the field "Category" to "Category 1"
And I set the field "Purpose" to "Purpose 1"
And I navigate to "Users > Privacy and policies > Data registry" in site administration
And I click on "Science and technology" "link"
And I wait until the page is ready
- And the field "categoryid" matches value "Category 2"
+ And the field "categoryid" matches value "Not set (use the default value)"
+ And the field "purposeid" matches value "Not set (use the default value)"
+ And I should see "3 years"
+ And I click on "Courses" "link"
+ And I wait until the page is ready
+ And I click on "Physics 101" "link"
+ And I wait until the page is ready
+ And I should see "3 years"
+ And I click on "Activities and resources" "link"
+ And I wait until the page is ready
+ And I should see "3 years"
+ And I click on "Assignment 1 (Assignment)" "link"
+ And I wait until the page is ready
+ And I should see "3 years"
+
+ # When Setting a default for course categories, and overriding a specific category, only that category and its
+ # children will be overridden.
+ # If any child is a course category, it will get the default.
+ Scenario: Set course category data registry defaults with override
+ Given I navigate to "Users > Privacy and policies > Data registry" in site administration
+ And I click on "Set defaults" "link"
+ And I press "Edit"
+ And I set the field "Category" to "Category 1"
+ And I set the field "Purpose" to "Purpose 1"
+ And I press "Save changes"
+ And I should see "Category 1"
+ And I should see "Purpose 1"
+ And I set the category and purpose for the course category "scitech" to "Category 2" and "Purpose 2"
+ When I navigate to "Users > Privacy and policies > Data registry" in site administration
+ And I click on "Science and technology" "link"
+ And I wait until the page is ready
+ Then the field "categoryid" matches value "Category 2"
+ And the field "purposeid" matches value "Purpose 2"
+ And I should see "5 years"
+ And I click on "Courses" "link"
+ And I wait until the page is ready
+ # Physics 101 is also a category, so it will get the category default.
+ And I click on "Physics 101" "link"
+ And I wait until the page is ready
+ And I should see "3 years"
+ And I click on "Activities and resources" "link"
+ And I wait until the page is ready
+ And I should see "3 years"
+ And I click on "Assignment 1 (Assignment)" "link"
+ And I wait until the page is ready
+ And I should see "3 years"
+
+ # When overriding a specific category, only that category and its children will be overridden.
+ Scenario: Set course category data registry defaults with override
+ Given I set the category and purpose for the course category "scitech" to "Category 2" and "Purpose 2"
+ When I navigate to "Users > Privacy and policies > Data registry" in site administration
+ And I click on "Science and technology" "link"
+ And I wait until the page is ready
+ Then the field "categoryid" matches value "Category 2"
And the field "purposeid" matches value "Purpose 2"
And I should see "5 years"
+ And I click on "Courses" "link"
+ And I wait until the page is ready
+ # Physics 101 is also a category, so it will get the category default.
+ And I click on "Physics 101" "link"
+ And I wait until the page is ready
+ And I should see "5 years"
+ And I click on "Activities and resources" "link"
+ And I wait until the page is ready
+ And I should see "5 years"
+ And I click on "Assignment 1 (Assignment)" "link"
+ And I wait until the page is ready
+ And I should see "5 years"
+ # Resetting instances removes custom values.
Scenario: Set course category data registry defaults with override
Given I set the category and purpose for the course category "scitech" to "Category 2" and "Purpose 2"
And I navigate to "Users > Privacy and policies > Data registry" in site administration
And I click on "Set defaults" "link"
- And I should see "Inherit"
- And I should not see "Add a new module default"
And I press "Edit"
And I set the field "Category" to "Category 1"
And I set the field "Purpose" to "Purpose 1"
- And I click on "Reset instances with custom values" "checkbox"
- When I press "Save changes"
- Then I should see "Category 1"
+ When I click on "Reset instances with custom values" "checkbox"
+ And I press "Save changes"
+ And I should see "Category 1"
And I should see "Purpose 1"
And I navigate to "Users > Privacy and policies > Data registry" in site administration
And I click on "Science and technology" "link"
And I wait until the page is ready
- And the field "categoryid" matches value "Not set (use the default value)"
+ Then the field "categoryid" matches value "Not set (use the default value)"
And the field "purposeid" matches value "Not set (use the default value)"
And I should see "3 years"
And the field "categoryid" matches value "Category 2"
And the field "purposeid" matches value "Purpose 2"
And I should see "5 years (after the course end date)"
+ And I click on "Activities and resources" "link"
+ And I wait until the page is ready
+ And I should see "5 years"
+ And I click on "Assignment 1 (Assignment)" "link"
+ And I wait until the page is ready
+ And I should see "5 years"
Scenario: Set course data registry defaults with override
Given I set the category and purpose for the course "Physics 101" to "Category 2" and "Purpose 2"
And the field "categoryid" matches value "Not set (use the default value)"
And the field "purposeid" matches value "Not set (use the default value)"
And I should see "3 years (after the course end date)"
+ And I click on "Activities and resources" "link"
+ And I wait until the page is ready
+ And I should see "3 years"
+ And I click on "Assignment 1 (Assignment)" "link"
+ And I wait until the page is ready
+ And I should see "3 years"
Scenario: Set module level data registry defaults
Given I set the category and purpose for the "assign1" "assign" in course "Physics 101" to "Category 2" and "Purpose 2"
Background:
Given I log in as "admin"
And I navigate to "Users > Privacy and policies > Data registry" in site administration
- And I click on "Edit" "link"
+ And I open the action menu in "region-main" "region"
And I choose "Purposes" in the open action menu
And I press "Add purpose"
And I set the field "Name" to "Purpose 1"
And "Purpose 1 Purpose 1 description" row "5" column of "List of data purposes" table should contain "No"
Scenario: Update a data storage purpose
- Given I click on "Actions" "link" in the "Purpose 1" "table_row"
+ Given I open the action menu in "Purpose 1" "table_row"
And I choose "Edit" in the open action menu
And I set the field "Name" to "Purpose 1 edited"
And I set the field "Description" to "Purpose 1 description edited"
And "Purpose 1 edited Purpose 1 description edited" row "5" column of "List of data purposes" table should not contain "No"
Scenario: Delete a data storage purpose
- Given I click on "Actions" "link" in the "Purpose 1" "table_row"
+ Given I open the action menu in "Purpose 1" "table_row"
And I choose "Delete" in the open action menu
And I should see "Delete purpose"
And I should see "Are you sure you want to delete the purpose 'Purpose 1'?"
--- /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/>.
+
+/**
+ * Unit tests for the data_registry class.
+ *
+ * @package tool_dataprivacy
+ * @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+use \tool_dataprivacy\data_registry;
+
+/**
+ * Unit tests for the data_registry class.
+ *
+ * @package tool_dataprivacy
+ * @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_dataprivacy_dataregistry_testcase extends advanced_testcase {
+
+ /**
+ * Ensure that the get_effective_context_value only errors if provided an inappropriate element.
+ *
+ * This test is not great because we only test a limited set of values. This is a fault of the underlying API.
+ */
+ public function test_get_effective_context_value_invalid_element() {
+ $this->expectException(coding_exception::class);
+ data_registry::get_effective_context_value(\context_system::instance(), 'invalid');
+ }
+
+ /**
+ * Ensure that the get_effective_contextlevel_value only errors if provided an inappropriate element.
+ *
+ * This test is not great because we only test a limited set of values. This is a fault of the underlying API.
+ */
+ public function test_get_effective_contextlevel_value_invalid_element() {
+ $this->expectException(coding_exception::class);
+ data_registry::get_effective_contextlevel_value(\context_system::instance(), 'invalid');
+ }
+}
$temp->add(new admin_setting_configtext('mobilecssurl', new lang_string('mobilecssurl', 'tool_mobile'),
new lang_string('configmobilecssurl', 'tool_mobile'), '', PARAM_URL));
+ // Reference to Branded Mobile App.
+ if (empty($CFG->disableserviceads_branded)) {
+ $temp->add(new admin_setting_description('moodlebrandedappreference',
+ new lang_string('moodlebrandedapp', 'admin'),
+ new lang_string('moodlebrandedappreference', 'admin')
+ ));
+ }
+
$temp->add(new admin_setting_heading('tool_mobile/smartappbanners',
new lang_string('smartappbanners', 'tool_mobile'), ''));
Background:
Given the following config values are set as admin:
| sitepolicyhandler | tool_policy |
+ # This is required for now to prevent the overflow region affecting the action menus.
+ And I change window size to "large"
And the following policies exist:
| Name | Revision | Content | Summary | Status |
| This site policy | | full text2 | short text2 | active |
Scenario: View acceptances made by users on their own, multiple policies
Given I log in as "admin"
And I navigate to "Users > Privacy and policies > Manage policies" in site administration
- And I open the action menu in "This privacy policy" "table_row"
And I click on "Set status to \"Active\"" "link" in the "This privacy policy" "table_row"
And I press "Continue"
And I log out
Scenario: Agree on behalf of another user as a manager, multiple policies, javascript off
Given I log in as "admin"
And I navigate to "Users > Privacy and policies > Manage policies" in site administration
- And I open the action menu in "This privacy policy" "table_row"
And I click on "Set status to \"Active\"" "link" in the "This privacy policy" "table_row"
And I press "Continue"
And I set the following system permissions of "Manager" role:
Scenario: Agree on behalf of another user as a manager, multiple policies, javascript on
Given I log in as "admin"
And I navigate to "Users > Privacy and policies > Manage policies" in site administration
- And I open the action menu in "This privacy policy" "table_row"
+ And I click on "Actions" "link_or_button" in the "This privacy policy" "table_row"
And I click on "Set status to \"Active\"" "link" in the "This privacy policy" "table_row"
And I press "Activate"
And I set the following system permissions of "Manager" role:
Scenario: Bulk agree on behalf of another users as a manager, multiple policies, javascript on
Given I log in as "admin"
And I navigate to "Users > Privacy and policies > Manage policies" in site administration
- And I open the action menu in "This privacy policy" "table_row"
+ And I click on "Actions" "link_or_button" in the "This privacy policy" "table_row"
And I click on "Set status to \"Active\"" "link" in the "This privacy policy" "table_row"
And I press "Activate"
And I set the following system permissions of "Manager" role:
And I set the field "I agree to the Policy1" to "1"
And I press "Next"
And I navigate to "Users > Privacy and policies > Manage policies" in site administration
- And I open the action menu in "Policy1" "table_row"
And I click on "Edit" "link" in the "Policy1" "table_row"
And "Draft" "field" should not exist
And "Active" "field" should not exist
And I set the field "I agree to the Policy1" to "1"
And I press "Next"
And I navigate to "Users > Privacy and policies > Manage policies" in site administration
- And I open the action menu in "Policy1" "table_row"
And I click on "Edit" "link" in the "Policy1" "table_row"
And I set the field "Version" to "v2"
And I press "Save as draft"
And I set the field "I agree to the Policy1" to "1"
And I press "Next"
And I navigate to "Users > Privacy and policies > Manage policies" in site administration
- And I open the action menu in "Policy1" "table_row"
And I click on "Edit" "link" in the "Policy1" "table_row"
And I set the field "Name" to "Policy2"
And I set the field "Version" to "v2"
| Policy2 Site policy, All users, Compulsory | Active | v2 | 0 of 4 (0%) |
And I should not see "Policy1"
And I should not see "v1"
- And I open the action menu in "Policy2" "table_row"
And I click on "View previous versions" "link" in the "Policy2" "table_row"
And I should see "Policy2 previous versions"
And I should not see "v2"
| Policy1 | v1 | full text2 | short text2 | draft |
And I log in as "manager"
And I navigate to "Users > Privacy and policies > Manage policies" in site administration
- And I open the action menu in "Policy1" "table_row"
And I click on "Edit" "link" in the "Policy1" "table_row"
And I set the field "Version" to "v2"
And "Draft" "field" should exist
| Name | Policy status | Version | Agreements |
| Policy1 Site policy, All users, Compulsory | Draft | v2 | N/A |
And I should not see "v1"
- And I open the action menu in "Policy1" "table_row"
And "View previous versions" "link" should not exist
And I log out
| Policy1 | v1 | full text2 | short text2 | draft |
And I log in as "manager"
And I navigate to "Users > Privacy and policies > Manage policies" in site administration
- And I open the action menu in "Policy1" "table_row"
And I click on "Edit" "link" in the "Policy1" "table_row"
And I set the field "Version" to "v2"
And I set the field "Active" to "1"
| Name | Policy status | Version | Agreements |
| Policy1 Site policy, All users, Compulsory | Active | v2 | 0 of 4 (0%) |
And I should not see "v1"
- And I open the action menu in "Policy1" "table_row"
And "View previous versions" "link" should not exist
And I log out
| Policy1 | v1 | full text2 | short text2 | draft |
And I log in as "manager"
And I navigate to "Users > Privacy and policies > Manage policies" in site administration
- And I open the action menu in "Policy1" "table_row"
And I click on "Set status to \"Active\"" "link" in the "Policy1" "table_row"
Then I should see "All users will be required to agree to this new policy version to be able to use the site."
And I press "Continue"
And the following should exist in the "tool-policy-managedocs-wrapper" table:
| Name | Policy status | Version | Agreements |
| Policy1 Site policy, All users, Compulsory | Active | v1 | 0 of 4 (0%) |
- And I open the action menu in "Policy1" "table_row"
And "View previous versions" "link" should not exist
And I log out
And I set the field "I agree to the Policy1" to "1"
And I press "Next"
And I navigate to "Users > Privacy and policies > Manage policies" in site administration
- And I open the action menu in "Policy1" "table_row"
And I click on "Set status to \"Inactive\"" "link" in the "Policy1" "table_row"
Then I should see "You are about to inactivate policy"
And I press "Continue"
And the following should exist in the "tool-policy-managedocs-wrapper" table:
| Name | Policy status | Version | Agreements |
| Policy1 Site policy, All users, Compulsory | Inactive | v1 | 1 of 4 (25%) |
- And I open the action menu in "Policy1" "table_row"
And I click on "Create a new draft" "link" in the "Policy1" "table_row"
And I set the field "Version" to "v2"
And I set the field "Name" to "Policy2"
| Policy2 Site policy, All users, Compulsory | Draft | v2 | N/A |
And I should not see "v1"
And I should not see "Policy1"
- And I open the action menu in "Policy2" "table_row"
And I click on "View previous versions" "link" in the "Policy2" "table_row"
And I should see "Policy2 previous versions"
And the following should exist in the "tool-policy-managedocs-wrapper" table:
And I set the field "I agree to the Policy1" to "1"
And I press "Next"
And I navigate to "Users > Privacy and policies > Manage policies" in site administration
- And I open the action menu in "Policy1" "table_row"
And I click on "Set status to \"Inactive\"" "link" in the "Policy1" "table_row"
And I press "Continue"
- And I open the action menu in "Policy1" "table_row"
And I click on "Create a new draft" "link" in the "Policy1" "table_row"
And I set the field "Version" to "v2"
And I set the field "Name" to "Policy2"
| Policy2 Site policy, All users, Compulsory | Active | v2 | 0 of 4 (0%) |
And I should not see "v1"
And I should not see "Policy1"
- And I open the action menu in "Policy2" "table_row"
And I click on "View previous versions" "link" in the "Policy2" "table_row"
And I should see "Policy2 previous versions"
And the following should exist in the "tool-policy-managedocs-wrapper" table:
--- /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/>.
+
+/**
+ * Form for scheduled tasks admin pages.
+ *
+ * @package tool_task
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_task;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Running tasks from CLI.
+ *
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class run_from_cli {
+
+ /**
+ * Find the path of PHP CLI binary.
+ *
+ * @return string|false The PHP CLI executable PATH
+ */
+ protected static function find_php_cli_path() {
+ global $CFG;
+
+ if (!empty($CFG->pathtophp) && is_executable(trim($CFG->pathtophp))) {
+ return $CFG->pathtophp;
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns if Moodle have access to PHP CLI binary or not.
+ *
+ * @return bool
+ */
+ public static function is_runnable():bool {
+ return self::find_php_cli_path() !== false;
+ }
+
+ /**
+ * Executes a cron from web invocation using PHP CLI.
+ *
+ * @param \core\task\task_base $task Task that be executed via CLI.
+ * @return bool
+ * @throws \moodle_exception
+ */
+ public static function execute(\core\task\task_base $task):bool {
+ global $CFG;
+
+ if (!self::is_runnable()) {
+ $redirecturl = new \moodle_url('/admin/settings.php', ['section' => 'systempaths']);
+ throw new \moodle_exception('cannotfindthepathtothecli', 'tool_task', $redirecturl->out());
+ } else {
+ // Shell-escaped path to the PHP binary.
+ $phpbinary = escapeshellarg(self::find_php_cli_path());
+
+ // Shell-escaped path CLI script.
+ $pathcomponents = [$CFG->dirroot, $CFG->admin, 'tool', 'task', 'cli', 'schedule_task.php'];
+ $scriptpath = escapeshellarg(implode(DIRECTORY_SEPARATOR, $pathcomponents));
+
+ // Shell-escaped task name.
+ $classname = get_class($task);
+ $taskarg = escapeshellarg("--execute={$classname}");
+
+ // Build the CLI command.
+ $command = "{$phpbinary} {$scriptpath} {$taskarg}";
+
+ // Execute it.
+ passthru($command);
+ }
+
+ return true;
+ }
+}
$string['asap'] = 'ASAP';
$string['backtoscheduledtasks'] = 'Back to scheduled tasks';
$string['blocking'] = 'Blocking';
+$string['cannotfindthepathtothecli'] = 'Cannot find the path to the PHP CLI executable so task execution aborted. Set the "Path to PHP CLI" setting in "Site administration / Server / System paths"';
$string['clearfaildelay_confirm'] = 'Are you sure you want to clear the fail delay for task \'{$a}\'? After clearing the delay, the task will run according to its normal schedule.';
$string['component'] = 'Component';
$string['corecomponent'] = 'Core';
$string['taskschedulemonth'] = 'Month';
$string['taskschedulemonth_help'] = 'Month field for task schedule. The field uses the same format as unix cron. Some examples are:<br/><ul><li><strong>*</strong> Every month</li><li><strong>*/2</strong> Every second month</li><li><strong>1</strong> Every January</li><li><strong>1,5</strong> Every January and May</li></ul>';
$string['privacy:metadata'] = 'The Scheduled task configuration plugin does not store any personal data.';
+
$asap = get_string('asap', 'tool_task');
$disabledstr = get_string('taskdisabled', 'tool_task');
$plugindisabledstr = get_string('plugindisabled', 'tool_task');
+ $runnabletasks = tool_task\run_from_cli::is_runnable();
foreach ($tasks as $task) {
$customised = $task->is_customised() ? $no : $yes;
if (empty($CFG->preventscheduledtaskchanges)) {
}
$runnow = '';
- if (!$disabled && get_config('tool_task', 'enablerunnow')) {
+ if ( ! $disabled && get_config('tool_task', 'enablerunnow') && $runnabletasks ) {
$runnow = html_writer::div(html_writer::link(
new moodle_url('/admin/tool/task/schedule_task.php',
array('task' => get_class($task))),
$CFG->mtrace_wrapper = 'tool_task_mtrace_wrapper';
// Run the specified task (this will output an error if it doesn't exist).
-cron_run_single_task($task);
+\tool_task\run_from_cli::execute($task);
+
echo html_writer::end_tag('pre');
$output = $PAGE->get_renderer('tool_task');
+++ /dev/null
-@tool @tool_task
-Feature: Run tasks from web interface
- In order to run scheduled tasks immediately
- As an admin
- I need to be able to run a task from the web interface
-
- Scenario: Run a task
- Given I log in as "admin"
- When I navigate to "Server > Scheduled tasks" in site administration
- Then I should see "Never" in the "Log table cleanup" "table_row"
-
- And I click on "Run now" "text" in the "Log table cleanup" "table_row"
- And I should see "Are you sure you want to run this task"
- And I press "Run now"
-
- And I should see "Log table cleanup" in the "h2" "css_element"
- And I should see "Scheduled task complete: Log table cleanup"
-
- And I follow "Back to scheduled tasks"
- And I should not see "Never" in the "Log table cleanup" "table_row"
-
- Scenario: Cancel running a task
- Given I log in as "admin"
- When I navigate to "Server > Scheduled tasks" in site administration
- And I click on "Run now" "text" in the "Log table cleanup" "table_row"
- And I press "Cancel"
- # Confirm we're back on the scheduled tasks page by looking for the table.
- Then "Log table cleanup" "table_row" should exist
-
- Scenario: Cannot run a task when the option is disabled
- Given the following config values are set as admin:
- | enablerunnow | 0 | tool_task |
- When I log in as "admin"
- And I navigate to "Server > Scheduled tasks" in site administration
- Then I should not see "Run now"
When I press tab
Then the focused element is ".usermenu" "css_element"
When I press tab
- Then the focused element is "Admin User" "link" in the ".usermenu" "css_element"
+ Then the focused element is "Admin User" "link_or_button" in the ".usermenu" "css_element"
When I press tab
And I press tab
Then the focused element is ".close" "css_element" in the "User menu" "dialogue"
# Press shift-tab twice should lead us back to "Admin user" link.
When I press shift tab
And I press shift tab
- Then the focused element is "Admin User" "link" in the ".usermenu" "css_element"
+ Then the focused element is "Admin User" "link_or_button" in the ".usermenu" "css_element"
@javascript
Scenario: Aria tags should not exist
*/
class manager {
+ /**
+ * Default mlbackend
+ */
+ const DEFAULT_MLBACKEND = '\mlbackend_php\processor';
+
/**
* @var \core_analytics\predictor[]
*/
}
/**
- * Returns the site selected predictions processor.
+ * Returns the provided predictions processor class.
*
- * @param string $predictionclass
+ * @param false|string $predictionclass Returns the system default processor if false
* @param bool $checkisready
* @return \core_analytics\predictor
*/
// We want 0 or 1 so we can use it as an array key for caching.
$checkisready = intval($checkisready);
- if ($predictionclass === false) {
+ if (!$predictionclass) {
$predictionclass = get_config('analytics', 'predictionsprocessor');
}
if (empty($predictionclass)) {
// Use the default one if nothing set.
- $predictionclass = '\mlbackend_php\processor';
+ $predictionclass = self::default_mlbackend();
}
if (!class_exists($predictionclass)) {
return $predictionprocessors;
}
+ /**
+ * Returns the name of the provided predictions processor.
+ *
+ * @param \core_analytics\predictor $predictionsprocessor
+ * @return string
+ */
+ public static function get_predictions_processor_name(\core_analytics\predictor $predictionsprocessor) {
+ $component = substr(get_class($predictionsprocessor), 0, strpos(get_class($predictionsprocessor), '\\', 1));
+ return get_string('pluginname', $component);
+ }
+
+ /**
+ * Whether the provided plugin is used by any model.
+ *
+ * @param string $plugin
+ * @return bool
+ */
+ public static function is_mlbackend_used($plugin) {
+ $models = self::get_all_models();
+ foreach ($models as $model) {
+ $processor = $model->get_predictions_processor();
+ $noprefixnamespace = ltrim(get_class($processor), '\\');
+ $processorplugin = substr($noprefixnamespace, 0, strpos($noprefixnamespace, '\\'));
+ if ($processorplugin == $plugin) {
+ return true;
+ }
+ }
+
+ // Default predictions processor.
+ $defaultprocessorclass = get_config('analytics', 'predictionsprocessor');
+ $pluginclass = '\\' . $plugin . '\\processor';
+ if ($pluginclass === $defaultprocessorclass) {
+ return true;
+ }
+
+ return false;
+ }
+
/**
* Get all available time splitting methods.
*
}
}
+ /**
+ * Default system backend.
+ *
+ * @return string
+ */
+ public static function default_mlbackend() {
+ return self::DEFAULT_MLBACKEND;
+ }
+
/**
* Returns the provided element classes in the site.
*
*/
protected $target = null;
+ /**
+ * @var \core_analytics\predictor
+ */
+ protected $predictionsprocessor = null;
+
/**
* @var \core_analytics\local\indicator\base[]
*/
* @param string $timesplittingid The time splitting method id (its fully qualified class name)
* @return \core_analytics\model
*/
- public static function create(\core_analytics\local\target\base $target, array $indicators, $timesplittingid = false) {
+ public static function create(\core_analytics\local\target\base $target, array $indicators,
+ $timesplittingid = false, $processor = false) {
global $USER, $DB;
\core_analytics\manager::check_can_manage_models();
$modelobj->timemodified = $now;
$modelobj->usermodified = $USER->id;
+ if ($processor &&
+ !self::is_valid($processor, '\core_analytics\classifier') &&
+ !self::is_valid($processor, '\core_analytics\regressor')) {
+ throw new \coding_exception('The provided predictions processor \\' . $processor . '\processor is not valid');
+ } else {
+ $modelobj->predictionsprocessor = $processor;
+ }
+
$id = $DB->insert_record('analytics_models', $modelobj);
// Get db defaults.
* @param int|bool $enabled
* @param \core_analytics\local\indicator\base[]|false $indicators False to respect current indicators
* @param string|false $timesplittingid False to respect current time splitting method
+ * @param string|false $predictionsprocessor False to respect current predictors processor value
* @return void
*/
- public function update($enabled, $indicators = false, $timesplittingid = '') {
+ public function update($enabled, $indicators = false, $timesplittingid = '', $predictionsprocessor = false) {
global $USER, $DB;
\core_analytics\manager::check_can_manage_models();
$timesplittingid = $this->model->timesplitting;
}
+ if ($predictionsprocessor === false) {
+ // Respect current value.
+ $predictionsprocessor = $this->model->predictionsprocessor;
+ }
+
if ($this->model->timesplitting !== $timesplittingid ||
- $this->model->indicators !== $indicatorsstr) {
+ $this->model->indicators !== $indicatorsstr ||
+ $this->model->predictionsprocessor !== $predictionsprocessor) {
// Delete generated predictions before changing the model version.
$this->clear();
$this->model->enabled = intval($enabled);
$this->model->indicators = $indicatorsstr;
$this->model->timesplitting = $timesplittingid;
+ $this->model->predictionsprocessor = $predictionsprocessor;
$this->model->timemodified = $now;
$this->model->usermodified = $USER->id;
$this->clear();
// Method self::clear is already clearing the current model version.
- $predictor = \core_analytics\manager::get_predictions_processor();
- $predictor->delete_output_dir($this->get_output_dir(array(), true));
+ $predictor = $this->get_predictions_processor(false);
+ if ($predictor->is_ready() !== true) {
+ $predictorname = \core_analytics\manager::get_predictions_processor_name($predictor);
+ debugging('Prediction processor ' . $predictorname . ' is not ready to be used. Model ' .
+ $this->model->id . ' could not be deleted.');
+ } else {
+ $predictor->delete_output_dir($this->get_output_dir(array(), true));
+ }
$DB->delete_records('analytics_models', array('id' => $this->model->id));
$DB->delete_records('analytics_models_log', array('modelid' => $this->model->id));
$this->heavy_duty_mode();
// Before get_labelled_data call so we get an early exception if it is not ready.
- $predictor = \core_analytics\manager::get_predictions_processor();
+ $predictor = $this->get_predictions_processor();
$datasets = $this->get_analyser()->get_labelled_data();
$outputdir = $this->get_output_dir(array('execution'));
// Before get_labelled_data call so we get an early exception if it is not ready.
- $predictor = \core_analytics\manager::get_predictions_processor();
+ $predictor = $this->get_predictions_processor();
$datasets = $this->get_analyser()->get_labelled_data();
// Before get_unlabelled_data call so we get an early exception if it is not ready.
if (!$this->is_static()) {
- $predictor = \core_analytics\manager::get_predictions_processor();
+ $predictor = $this->get_predictions_processor();
}
$samplesdata = $this->get_analyser()->get_unlabelled_data();
return $result;
}
+ /**
+ * Returns the model predictions processor.
+ *
+ * @param bool $checkisready
+ * @return \core_analytics\predictor
+ */
+ public function get_predictions_processor($checkisready = true) {
+ return manager::get_predictions_processor($this->model->predictionsprocessor, $checkisready);
+ }
+
/**
* Formats the predictor results.
*
\core_analytics\manager::check_can_manage_models();
// Delete current model version stored stuff.
- $predictor = \core_analytics\manager::get_predictions_processor();
- $predictor->clear_model($this->get_unique_id(), $this->get_output_dir());
+ $predictor = $this->get_predictions_processor(false);
+ if ($predictor->is_ready() !== true) {
+ $predictorname = \core_analytics\manager::get_predictions_processor_name($predictor);
+ debugging('Prediction processor ' . $predictorname . ' is not ready to be used. Model ' .
+ $this->model->id . ' could not be cleared.');
+ } else {
+ $predictor->clear_model($this->get_unique_id(), $this->get_output_dir());
+ }
$predictionids = $DB->get_fieldset_select('analytics_predictions', 'id', 'modelid = :modelid',
array('modelid' => $this->get_id()));
$this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.');
}
- set_config('predictionsprocessor', $predictionsprocessorclass, 'analytics');
-
$model = $this->add_perfect_model();
- $model->enable($timesplittingid);
+ $model->update(true, false, $timesplittingid, get_class($predictionsprocessor));
// No samples trained yet.
$this->assertEquals(0, $DB->count_records('analytics_train_samples', array('modelid' => $model->get_id())));
$this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.');
}
- set_config('predictionsprocessor', $predictionsprocessorclass, 'analytics');
-
+ $model->update(false, false, false, get_class($predictionsprocessor));
$results = $model->evaluate();
// We check that the returned status includes at least $expectedcode code.
'dockable' => new external_value(PARAM_BOOL, 'Whether the block is dockable.'),
'weight' => new external_value(PARAM_INT, 'Used to order blocks within a region.', VALUE_OPTIONAL),
'visible' => new external_value(PARAM_BOOL, 'Whether the block is visible.', VALUE_OPTIONAL),
+ 'contents' => new external_single_structure(
+ array(
+ 'title' => new external_value(PARAM_TEXT, 'Block title.'),
+ 'content' => new external_value(PARAM_RAW, 'Block contents.'),
+ 'contentformat' => new external_format_value('content'),
+ 'footer' => new external_value(PARAM_RAW, 'Block footer.'),
+ 'files' => new external_files('Block files.'),
+ ),
+ 'Block contents (if required).', VALUE_OPTIONAL
+ ),
), 'Block information.'
);
}
* Convenience function for getting all the blocks of the current $PAGE.
*
* @param bool $includeinvisible Whether to include not visible blocks or not
+ * @param bool $returncontents Whether to return the block contents
* @return array Block information
* @since Moodle 3.6
*/
- private static function get_all_current_page_blocks($includeinvisible = false) {
+ private static function get_all_current_page_blocks($includeinvisible = false, $returncontents = false) {
global $PAGE, $OUTPUT;
// Load the block instances for all the regions.
// Index block instances to retrieve required info.
$blockinstances = array();
foreach ($regioninstances as $ri) {
- $blockinstances[$ri->instance->id] = $ri->instance;
+ $blockinstances[$ri->instance->id] = $ri;
}
foreach ($regionblocks as $bc) {
- $allblocks[] = [
+ $block = [
'instanceid' => $bc->blockinstanceid,
- 'name' => $blockinstances[$bc->blockinstanceid]->blockname,
+ 'name' => $blockinstances[$bc->blockinstanceid]->instance->blockname,
'region' => $region,
'positionid' => $bc->blockpositionid,
'collapsible' => (bool) $bc->collapsible,
'dockable' => (bool) $bc->dockable,
- 'weight' => $blockinstances[$bc->blockinstanceid]->weight,
- 'visible' => $blockinstances[$bc->blockinstanceid]->visible,
+ 'weight' => $blockinstances[$bc->blockinstanceid]->instance->weight,
+ 'visible' => $blockinstances[$bc->blockinstanceid]->instance->visible,
];
+ if ($returncontents) {
+ $block['contents'] = (array) $blockinstances[$bc->blockinstanceid]->get_content_for_external($OUTPUT);
+ }
+ $allblocks[] = $block;
}
}
return $allblocks;
public static function get_course_blocks_parameters() {
return new external_function_parameters(
array(
- 'courseid' => new external_value(PARAM_INT, 'course id')
+ 'courseid' => new external_value(PARAM_INT, 'course id'),
+ 'returncontents' => new external_value(PARAM_BOOL, 'Whether to return the block contents.', VALUE_DEFAULT, false),
)
);
}
* Returns blocks information for a course.
*
* @param int $courseid The course id
+ * @param bool $returncontents Whether to return the block contents
* @return array Blocks list and possible warnings
* @throws moodle_exception
* @since Moodle 3.3
*/
- public static function get_course_blocks($courseid) {
+ public static function get_course_blocks($courseid, $returncontents = false) {
global $PAGE;
$warnings = array();
- $params = self::validate_parameters(self::get_course_blocks_parameters(), ['courseid' => $courseid]);
+ $params = self::validate_parameters(self::get_course_blocks_parameters(),
+ ['courseid' => $courseid, 'returncontents' => $returncontents]);
$course = get_course($params['courseid']);
$context = context_course::instance($course->id);
$PAGE->set_pagetype('course-view-' . $course->format);
}
- $allblocks = self::get_all_current_page_blocks();
+ $allblocks = self::get_all_current_page_blocks(false, $params['returncontents']);
return array(
'blocks' => $allblocks,
public static function get_dashboard_blocks_parameters() {
return new external_function_parameters(
array(
- 'userid' => new external_value(PARAM_INT, 'User id (optional), default is current user.', VALUE_DEFAULT, 0)
+ 'userid' => new external_value(PARAM_INT, 'User id (optional), default is current user.', VALUE_DEFAULT, 0),
+ 'returncontents' => new external_value(PARAM_BOOL, 'Whether to return the block contents.', VALUE_DEFAULT, false),
)
);
}
* Returns blocks information for the given user dashboard.
*
* @param int $userid The user id to retrive the blocks from, optional, default is to current user.
+ * @param bool $returncontents Whether to return the block contents
* @return array Blocks list and possible warnings
* @throws moodle_exception
* @since Moodle 3.6
*/
- public static function get_dashboard_blocks($userid = 0) {
+ public static function get_dashboard_blocks($userid = 0, $returncontents = false) {
global $CFG, $USER, $PAGE;
require_once($CFG->dirroot . '/my/lib.php');
$warnings = array();
- $params = self::validate_parameters(self::get_dashboard_blocks_parameters(), ['userid' => $userid]);
+ $params = self::validate_parameters(self::get_dashboard_blocks_parameters(),
+ ['userid' => $userid, 'returncontents' => $returncontents]);
$userid = $params['userid'];
if (empty($userid)) {
// Load the block instances in the current $PAGE for all the regions.
$returninvisible = has_capability('moodle/my:manageblocks', $context) ? true : false;
- $allblocks = self::get_all_current_page_blocks($returninvisible);
+ $allblocks = self::get_all_current_page_blocks($returninvisible, $params['returncontents']);
return array(
'blocks' => $allblocks,
return $this->content;
}
+ public function get_content_for_external($output) {
+ global $CFG;
+ require_once($CFG->libdir . '/externallib.php');
+
+ $bc = new stdClass;
+ $bc->title = null;
+ $bc->content = '';
+ $bc->contenformat = FORMAT_MOODLE;
+ $bc->footer = '';
+ $bc->files = [];
+
+ if (!$this->hide_header()) {
+ $bc->title = $this->title;
+ }
+
+ if (isset($this->config->text)) {
+ $filteropt = new stdClass;
+ if ($this->content_is_trusted()) {
+ // Fancy html allowed only on course, category and system blocks.
+ $filteropt->noclean = true;
+ }
+
+ $format = FORMAT_HTML;
+ // Check to see if the format has been properly set on the config.
+ if (isset($this->config->format)) {
+ $format = $this->config->format;
+ }
+ list($bc->content, $bc->contentformat) =
+ external_format_text($this->config->text, $format, $this->context, 'block_html', 'content', null, $filteropt);
+ $bc->files = external_util::get_area_files($this->context->id, 'block_html', 'content', false, false);
+
+ }
+ return $bc;
+ }
+
/**
* Serialize and store config data
return $bc;
}
+
+ /**
+ * Return an object containing all the block content to be returned by external functions.
+ *
+ * If your block is returning formatted content or provide files for download, you should override this method to use the
+ * external_format_text, external_format_string functions for formatting or external_util::get_area_files for files.
+ *
+ * @param core_renderer $output the rendered used for output
+ * @return stdClass object containing the block title, central content, footer and linked files (if any).
+ * @since Moodle 3.6
+ */
+ public function get_content_for_external($output) {
+ $bc = new stdClass;
+ $bc->title = null;
+ $bc->content = null;
+ $bc->contentformat = FORMAT_HTML;
+ $bc->footer = null;
+ $bc->files = [];
+
+ if ($this->instance->visible) {
+ $bc->content = $this->formatted_contents($output);
+ if (!empty($this->content->footer)) {
+ $bc->footer = $this->content->footer;
+ }
+ }
+
+ if (!$this->hide_header()) {
+ $bc->title = $this->title;
+ }
+
+ return $bc;
+ }
+
/**
* Convert the contents of the block to HTML.
*
* @copyright 2018 Bas Brands <base@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-define(['core/ajax'], function(Ajax) {
+define(['core/ajax', 'core/notification'], function(Ajax, Notification) {
/**
* Retrieve a list of enrolled courses.
return promise;
};
+ /**
+ * Set the favourite state on a list of courses.
+ *
+ * Valid args are:
+ * Array courses list of course id numbers.
+ *
+ * @param {Object} args Arguments send to the webservice.
+ * @return {Promise} Resolve with warnings.
+ */
+ var setFavouriteCourses = function(args) {
+
+ var request = {
+ methodname: 'core_course_set_favourite_courses',
+ args: args
+ };
+
+ var promise = Ajax.call([request])[0];
+
+ return promise;
+ };
+
+ /**
+ * Update the user preferences.
+ *
+ * @param {Object} args Arguments send to the webservice.
+ *
+ * Sample args:
+ * {
+ * preferences: [
+ * {
+ * type: 'block_example_user_sort_preference'
+ * value: 'title'
+ * }
+ * ]
+ * }
+ */
+ var updateUserPreferences = function(args) {
+ var request = {
+ methodname: 'core_user_update_user_preferences',
+ args: args
+ };
+
+ Ajax.call([request])[0]
+ .fail(Notification.exception);
+ };
+
return {
- getEnrolledCoursesByTimeline: getEnrolledCoursesByTimeline
+ getEnrolledCoursesByTimeline: getEnrolledCoursesByTimeline,
+ setFavouriteCourses: setFavouriteCourses,
+ updateUserPreferences: updateUserPreferences
};
});
define(
[
'jquery',
- 'core/notification',
'block_myoverview/repository',
'core/paged_content_factory',
+ 'core/custom_interaction_events',
+ 'core/notification',
'core/templates',
],
function(
$,
- Notification,
Repository,
PagedContentFactory,
+ CustomEvents,
+ Notification,
Templates
) {
+ var SELECTORS = {
+ ACTION_ADD_FAVOURITE: '[data-action="add-favourite"]',
+ ACTION_REMOVE_FAVOURITE: '[data-action="remove-favourite"]',
+ FAVOURITE_ICON: '[data-region="favourite-icon"]',
+ ICON_IS_FAVOURITE: '[data-region="is-favourite"]',
+ ICON_NOT_FAVOURITE: '[data-region="not-favourite"]',
+ PAGED_CONTENT_CONTAINER: '[data-region="page-container"]'
+
+ };
+
var TEMPLATES = {
COURSES_CARDS: 'block_myoverview/view-cards',
COURSES_LIST: 'block_myoverview/view-list',
NOCOURSES: 'block_myoverview/no-courses'
};
- var NUMCOURSES_PERPAGE = [12, 24];
+ var NUMCOURSES_PERPAGE = [12, 24, 48];
- var currentCourseList = [];
+ var loadedPages = [];
/**
* Get filter values from DOM.
return filters;
};
- // We want the paged content controls below the paged content area
+ // We want the paged content controls below the paged content area.
// and the controls should be ignored while data is loading.
var DEFAULT_PAGED_CONTENT_CONFIG = {
ignoreControlWhileLoading: true,
* @return {promise} Resolved with an array of courses.
*/
var getMyCourses = function(filters, limit, pageNumber) {
+
return Repository.getEnrolledCoursesByTimeline({
offset: pageNumber * limit,
limit: limit,
});
};
+ /**
+ * Get the container element for the favourite icon.
+ *
+ * @param {Object} root The course overview container
+ * @param {Number} courseId Course id number
+ * @return {Object} The favourite icon container
+ */
+ var getFavouriteIconContainer = function(root, courseId) {
+ return root.find(SELECTORS.FAVOURITE_ICON + '[data-course-id="' + courseId + '"]');
+ };
+
+ /**
+ * Get the paged content container element.
+ *
+ * @param {Object} root The course overview container
+ * @param {Number} index Rendered page index.
+ * @return {Object} The rendered paged container.
+ */
+ var getPagedContentContainer = function(root, index) {
+ return root.find('[data-region="paged-content-page"][data-page="' + index + '"]');
+ };
+
+ /**
+ * Get the course id from a favourite element.
+ *
+ * @param {Object} root The favourite icon container element.
+ * @return {Number} Course id.
+ */
+ var getFavouriteCourseId = function(root) {
+ return root.attr('data-course-id');
+ };
+
+ /**
+ * Hide the favourite icon.
+ *
+ * @param {Object} root The favourite icon container element.
+ * @param {Number} courseId Course id number.
+ */
+ var hideFavouriteIcon = function(root, courseId) {
+ var iconContainer = getFavouriteIconContainer(root, courseId);
+ var isFavouriteIcon = iconContainer.find(SELECTORS.ICON_IS_FAVOURITE);
+ isFavouriteIcon.addClass('hidden');
+ isFavouriteIcon.attr('aria-hidden', true);
+ var notFavourteIcon = iconContainer.find(SELECTORS.ICON_NOT_FAVOURITE);
+ notFavourteIcon.removeClass('hidden');
+ notFavourteIcon.attr('aria-hidden', false);
+ };
+
+ /**
+ * Show the favourite icon.
+ *
+ * @param {Object} root The course overview container.
+ * @param {Number} courseId Course id number.
+ */
+ var showFavouriteIcon = function(root, courseId) {
+ var iconContainer = getFavouriteIconContainer(root, courseId);
+ var isFavouriteIcon = iconContainer.find(SELECTORS.ICON_IS_FAVOURITE);
+ isFavouriteIcon.removeClass('hidden');
+ isFavouriteIcon.attr('aria-hidden', false);
+ var notFavourteIcon = iconContainer.find(SELECTORS.ICON_NOT_FAVOURITE);
+ notFavourteIcon.addClass('hidden');
+ notFavourteIcon.attr('aria-hidden', true);
+ };
+
+ /**
+ * Get the action menu item
+ *
+ * @param {Object} root root The course overview container
+ * @param {Number} courseId Course id.
+ * @return {Object} The add to favourite menu item.
+ */
+ var getAddFavouriteMenuItem = function(root, courseId) {
+ return root.find('[data-action="add-favourite"][data-course-id="' + courseId + '"]');
+ };
+
+ /**
+ * Get the action menu item
+ *
+ * @param {Object} root root The course overview container
+ * @param {Number} courseId Course id.
+ * @return {Object} The remove from favourites menu item.
+ */
+ var getRemoveFavouriteMenuItem = function(root, courseId) {
+ return root.find('[data-action="remove-favourite"][data-course-id="' + courseId + '"]');
+ };
+
+ /**
+ * Add course to favourites
+ *
+ * @param {Object} root The course overview container
+ * @param {Number} courseId Course id number
+ */
+ var addToFavourites = function(root, courseId) {
+ var removeAction = getRemoveFavouriteMenuItem(root, courseId);
+ var addAction = getAddFavouriteMenuItem(root, courseId);
+
+ setCourseFavouriteState(courseId, true).then(function(success) {
+ if (success) {
+ removeAction.removeClass('hidden');
+ addAction.addClass('hidden');
+ showFavouriteIcon(root, courseId);
+ } else {
+ Notification.alert('Starring course failed', 'Could not change favourite state');
+ }
+ return;
+ }).catch(Notification.exception);
+ };
+
+ /**
+ * Remove course from favourites
+ *
+ * @param {Object} root The course overview container
+ * @param {Number} courseId Course id number
+ */
+ var removeFromFavourites = function(root, courseId) {
+ var removeAction = getRemoveFavouriteMenuItem(root, courseId);
+ var addAction = getAddFavouriteMenuItem(root, courseId);
+
+ setCourseFavouriteState(courseId, false).then(function(success) {
+ if (success) {
+ removeAction.addClass('hidden');
+ addAction.removeClass('hidden');
+ hideFavouriteIcon(root, courseId);
+ } else {
+ Notification.alert('Starring course failed', 'Could not change favourite state');
+ }
+ return;
+ }).catch(Notification.exception);
+ };
+
+ /**
+ * Set the courses favourite status and push to repository
+ *
+ * @param {Number} courseId Course id to favourite.
+ * @param {Bool} status new favourite status.
+ * @return {Promise} Repository promise.
+ */
+ var setCourseFavouriteState = function(courseId, status) {
+
+ return Repository.setFavouriteCourses({
+ courses: [
+ {
+ 'id': courseId,
+ 'favourite': status
+ }
+ ]
+ }).then(function(result) {
+ if (result.warnings.length == 0) {
+ loadedPages.forEach(function(courseList) {
+ courseList.courses.forEach(function(course, index) {
+ if (course.id == courseId) {
+ courseList.courses[index].isfavourite = status;
+ }
+ });
+ });
+ return true;
+ } else {
+ return false;
+ }
+ }).catch(Notification.exception);
+ };
+
/**
* Render the dashboard courses.
*
* @param {object} root The root element for the courses view.
* @param {array} coursesData containing array of returned courses.
- * @param {object} filters The filters for this view.
* @return {promise} jQuery promise resolved after rendering is complete.
*/
- var renderCourses = function(root, coursesData, filters) {
+ var renderCourses = function(root, coursesData) {
+
+ var filters = getFilterValues(root);
var currentTemplate = '';
if (filters.display == 'cards') {
root = $(root);
+ if (!root.attr('data-init')) {
+ registerEventListeners(root);
+ root.attr('data-init', true);
+ }
+
var filters = getFilterValues(root);
var pagedContentPromise = PagedContentFactory.createWithLimit(
var promises = [];
pagesData.forEach(function(pageData) {
+ var currentPage = pageData.pageNumber;
var pageNumber = pageData.pageNumber - 1;
var pagePromise = getMyCourses(
if (coursesData.courses.length < pageData.limit) {
actions.allItemsLoaded(pageData.pageNumber);
}
- currentCourseList = coursesData;
- return renderCourses(root, coursesData, filters);
+ loadedPages[currentPage] = coursesData;
+ return renderCourses(root, coursesData);
})
.catch(Notification.exception);
}).catch(Notification.exception);
};
+ /**
+ * Listen to, and handle events for the myoverview block.
+ *
+ * @param {Object} root The myoverview block container element.
+ */
+ var registerEventListeners = function(root) {
+ CustomEvents.define(root, [
+ CustomEvents.events.activate
+ ]);
+
+ root.on(CustomEvents.events.activate, SELECTORS.ACTION_ADD_FAVOURITE, function(e, data) {
+ var favourite = $(e.target).closest(SELECTORS.ACTION_ADD_FAVOURITE);
+ var courseId = getFavouriteCourseId(favourite);
+ addToFavourites(root, courseId);
+ data.originalEvent.preventDefault();
+ });
+
+ root.on(CustomEvents.events.activate, SELECTORS.ACTION_REMOVE_FAVOURITE, function(e, data) {
+ var favourite = $(e.target).closest(SELECTORS.ACTION_REMOVE_FAVOURITE);
+ var courseId = getFavouriteCourseId(favourite);
+ removeFromFavourites(root, courseId);
+ data.originalEvent.preventDefault();
+ });
+
+ root.on(CustomEvents.events.activate, SELECTORS.FAVOURITE_ICON, function(e, data) {
+ data.originalEvent.preventDefault();
+ });
+ };
+
/**
* Reset the courses views to their original
* state on first page load.
* This is called when configuration has changed for the event lists
* to cause them to reload their data.
*
- * @param {object} root The root element for the timeline view.
- * @param {object} content The content element for the timeline view.
+ * @param {Object} root The root element for the timeline view.
+ * @param {Object} content The content element for the timeline view.
*/
var reset = function(root, content) {
- var filters = getFilterValues(root);
- renderCourses(root, currentCourseList, filters)
- .then(function(html, js) {
- return Templates.replaceNodeContents(content, html, js);
- }).catch(Notification.exception);
+
+ if (loadedPages.length > 0) {
+ loadedPages.forEach(function(courseList, index) {
+ var pagedContentPage = getPagedContentContainer(root, index);
+ renderCourses(root, courseList).then(function(html, js) {
+ return Templates.replaceNodeContents(pagedContentPage, html, js);
+ }).catch(Notification.exception);
+ });
+ } else {
+ init(root, content);
+ }
};
return {
[
'jquery',
'core/custom_interaction_events',
+ 'block_myoverview/repository',
'block_myoverview/view'
],
function(
$,
CustomEvents,
+ Repository,
View
) {
DISPLAY_OPTION: '[data-display-option]'
};
+ /**
+ * Update the user preference for the block.
+ *
+ * @param {String} filter The type of filter: display/sort/grouping.
+ * @param {String} value The current preferred value.
+ */
+ var updatePreferences = function(filter, value) {
+ var type = null;
+ if (filter == 'display') {
+ type = 'block_myoverview_user_view_preference';
+ } else if (filter == 'sort') {
+ type = 'block_myoverview_user_sort_preference';
+ } else {
+ type = 'block_myoverview_user_grouping_preference';
+ }
+
+ Repository.updateUserPreferences({
+ preferences: [
+ {
+ type: type,
+ value: value
+ }
+ ]
+ });
+ };
+
/**
* Event listener for the Display filter (cards, list).
*
return;
}
- var attributename = 'data-' + option.attr('data-filter');
- viewRoot.attr(attributename, option.attr('data-value'));
+ var filter = option.attr('data-filter');
+ var attributename = 'data-' + filter;
+ var value = option.attr('data-value');
+ var pref = option.attr('data-pref');
+
+ viewRoot.attr(attributename, value);
+
+ updatePreferences(filter, pref);
// Reset the views.
View.init(viewRoot, viewContent);
return;
}
- viewRoot.attr('data-display', option.attr('data-value'));
+ var filter = option.attr('data-display-option');
+ var value = option.attr('data-value');
+ var pref = option.attr('data-pref');
+
+ updatePreferences(filter, pref);
+ viewRoot.attr('data-display', value);
View.reset(viewRoot, viewContent);
data.originalEvent.preventDefault();
}
if (isset($this->content)) {
return $this->content;
}
+ $group = get_user_preferences('block_myoverview_user_grouping_preference');
+ $sort = get_user_preferences('block_myoverview_user_sort_preference');
+ $view = get_user_preferences('block_myoverview_user_view_preference');
- $renderable = new \block_myoverview\output\main();
+ $renderable = new \block_myoverview\output\main($group, $sort, $view);
$renderer = $this->page->get_renderer('block_myoverview');
$this->content = new stdClass();
use renderer_base;
use templatable;
-require_once($CFG->libdir . '/completionlib.php');
+require_once($CFG->dirroot . '/blocks/myoverview/lib.php');
/**
* Class containing data for my overview block.
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class main implements renderable, templatable {
+
+ /**
+ * Store the grouping preference
+ *
+ * @var string String matching the grouping constants defined in myoverview/lib.php
+ */
+ private $grouping;
+
+ /**
+ * Store the sort preference
+ *
+ * @var string String matching the sort constants defined in myoverview/lib.php
+ */
+ private $sort;
+
+ /**
+ * Store the view preference
+ *
+ * @var string String matching the view/display constants defined in myoverview/lib.php
+ */
+ private $view;
+
+ /**
+ * main constructor.
+ * Initialize the user preferences
+ *
+ * @param string $grouping Grouping user preference
+ * @param string $sort Sort user preference
+ * @param string $view Display user preference
+ */
+ public function __construct($grouping, $sort, $view) {
+ $this->grouping = $grouping ? $grouping : BLOCK_MYOVERVIEW_GROUPING_ALL;
+ $this->sort = $sort ? $sort : BLOCK_MYOVERVIEW_SORTING_TITLE;
+ $this->view = $view ? $view : BLOCK_MYOVERVIEW_VIEW_CARD;
+ }
+
+ /**
+ * Get the user preferences as an array to figure out what has been selected
+ *
+ * @return array $preferences Array with the pref as key and value set to true
+ */
+ public function get_preferences_as_booleans() {
+ $preferences = [];
+ $preferences[$this->view] = true;
+ $preferences[$this->sort] = true;
+ $preferences[$this->grouping] = true;
+
+ return $preferences;
+ }
+
/**
* Export this data so it can be used as the context for a mustache template.
*
* @param \renderer_base $output
- * @return stdClass
+ * @return array Context variables for the template
*/
public function export_for_template(renderer_base $output) {
$nocoursesurl = $output->image_url('courses', 'block_myoverview')->out();
- return (object) [
- 'nocoursesimg' => $nocoursesurl
+ $defaultvariables = [
+ 'nocoursesimg' => $nocoursesurl,
+ 'grouping' => $this->grouping,
+ 'sort' => $this->sort == BLOCK_MYOVERVIEW_SORTING_TITLE ? 'fullname' : 'ul.timeaccess desc',
+ 'view' => $this->view
];
+
+ $preferences = $this->get_preferences_as_booleans();
+ return array_merge($defaultvariables, $preferences);
+
}
-}
+}
\ No newline at end of file
namespace block_myoverview\privacy;
+use core_privacy\local\request\user_preference_provider;
+use core_privacy\local\metadata\collection;
+
defined('MOODLE_INTERNAL') || die();
/**
* @copyright 2018 Zig Tan <zig@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-class provider implements \core_privacy\local\metadata\null_provider {
+class provider implements \core_privacy\local\metadata\provider, user_preference_provider {
/**
- * Get the language string identifier with the component's language
- * file to explain why this plugin stores no data.
+ * Returns meta-data information about the myoverview block.
*
- * @return string
+ * @param \core_privacy\local\metadata\collection $collection A collection of meta-data.
+ * @return \core_privacy\local\metadata\collection Return the collection of meta-data.
*/
- public static function get_reason() : string {
- return 'privacy:metadata';
+ public static function get_metadata(collection $collection) : collection {
+ $collection->add_user_preference('block_myoverview_user_sort_preference', 'privacy:metadata:overviewsortpreference');
+ $collection->add_user_preference('block_myoverview_user_view_preference', 'privacy:metadata:overviewviewpreference');
+ $collection->add_user_preference('block_myoverview_user_grouping_preference',
+ 'privacy:metadata:overviewgroupingpreference');
+ return $collection;
+ }
+ /**
+ * Export all user preferences for the myoverview block
+ *
+ * @param int $userid The userid of the user whose data is to be exported.
+ */
+ public static function export_user_preferences(int $userid) {
+ $preference = get_user_preferences('block_myoverview_user_sort_preference', null, $userid);
+ if (isset($preference)) {
+ \core_privacy\local\request\writer::export_user_preference('block_myoverview',
+ 'block_myoverview_user_sort_preference', get_string($preference, 'block_myoverview'),
+ get_string('privacy:metadata:overviewsortpreference', 'block_myoverview'));
+ }
+
+ $preference = get_user_preferences('block_myoverview_user_view_preference', null, $userid);
+ if (isset($preference)) {
+ \core_privacy\local\request\writer::export_user_preference('block_myoverview',
+ 'block_myoverview_user_view_preference',
+ get_string($preference, 'block_myoverview'),
+ get_string('privacy:metadata:overviewviewpreference', 'block_myoverview'));
+ }
+
+ $preference = get_user_preferences('block_myoverview_user_grouping_preference', null, $userid);
+ if (isset($preference)) {
+ \core_privacy\local\request\writer::export_user_preference('block_myoverview',
+ 'block_myoverview_user_grouping_preference',
+ get_string($preference, 'block_myoverview'),
+ get_string('privacy:metadata:overviewgroupingpreference', 'block_myoverview'));
+ }
}
-}
+}
\ No newline at end of file
*/
$string['all'] = 'All';
+$string['addtofavourites'] = 'Star this course';
+$string['aria:addtofavourites'] = 'Star for';
$string['aria:allcourses'] = 'All courses';
$string['aria:card'] = 'Switch to card view';
$string['aria:controls'] = 'Course overview controls';
+$string['aria:courseactions'] = 'Actions for current course';
$string['aria:courseimage'] = 'Course image:';
$string['aria:coursename'] = 'Course name:';
$string['aria:coursesummary'] = 'Course summary text:';
$string['aria:courseprogress'] = 'Course progress:';
$string['aria:displaydropdown'] = 'Display dropdown';
+$string['aria:favourite'] = 'Course is starred';
+$string['aria:favourites'] = 'Show starred courses';
$string['aria:future'] = 'Show future courses';
$string['aria:groupingdropdown'] = 'Grouping dropdown';
$string['aria:inprogress'] = 'Show in courses in progress';
$string['aria:list'] = 'Switch to list view';
$string['aria:title'] = 'Sort courses by title';
$string['aria:past'] = 'Show past courses';
+$string['aria:removefromfavourites'] = 'Remove star for';
$string['aria:summary'] = 'Switch to summary view';
$string['aria:sortingdropdown'] = 'Sorting dropdown';
$string['card'] = 'Card';
-$string['courseprogress'] = 'Course progress:';
+$string['cards'] = 'Cards';
$string['complete'] = 'Complete';
-$string['favorite'] = 'Favorite';
+$string['favourite'] = 'Starred course';
+$string['favourites'] = 'Starred';
$string['future'] = 'Future';
-$string['future:aria'] = 'View future courses';
-$string['hidden'] = 'Hidden';
$string['inprogress'] = 'In progress';
-$string['inprogress:aria'] = 'View in progress courses';
+$string['notfavourite'] = 'Not starred';
$string['lastaccessed'] = 'Last accessed';
-$string['lastaccessed:aria'] = 'Sort course by lastaccessed';
$string['list'] = 'List';
-$string['morecourses'] = 'More courses';
$string['myoverview:addinstance'] = 'Add a new course overview block';
$string['myoverview:myaddinstance'] = 'Add a new course overview block to Dashboard';
-$string['nocoursesfuture'] = 'No future courses';
-$string['nocoursesinprogress'] = 'No in progress courses';
$string['nocourses'] = 'No courses';
-$string['nocoursespast'] = 'No past courses';
$string['past'] = 'Past';
$string['pluginname'] = 'Course overview';
-$string['privacy:metadata'] = 'The myoverview block does not store any personal data.';
+$string['privacy:metadata:overviewsortpreference'] = 'The myoverview block sort preference.';
+$string['privacy:metadata:overviewviewpreference'] = 'The myoverview block view preference.';
+$string['privacy:metadata:overviewgroupingpreference'] = 'The myoverview block grouping preference.';
+$string['removefromfavourites'] = 'Unstar this course';
$string['summary'] = 'Summary';
$string['title'] = 'Title';
+
+// Deprecated since Moodle 3.6.
+$string['defaulttab'] = 'Default tab';
+$string['defaulttab_desc'] = 'The tab that will be displayed when a user first views their course overview. When returning to their course overview, the user\'s active tab is remembered.';
+$string['morecourses'] = 'More courses';
+$string['nocoursesinprogress'] = 'No in progress courses';
+$string['nocoursesfuture'] = 'No future courses';
+$string['nocoursespast'] = 'No past courses';
+$string['noevents'] = 'No upcoming activities due';
+$string['next30days'] = 'Next 30 days';
+$string['next7days'] = 'Next 7 days';
+$string['recentlyoverdue'] = 'Recently overdue';
+$string['sortbycourses'] = 'Sort by courses';
+$string['sortbydates'] = 'Sort by dates';
+$string['timeline'] = 'Timeline';
$string['viewcoursename'] = 'View course {$a}';
-$string['viewcourse'] = 'View course';
+$string['privacy:metadata:overviewlasttab'] = 'This stores the last tab selected by the user on the overview block.';
--- /dev/null
+defaulttab,block_myoverview
+defaulttab_desc,block_myoverview
+morecourses,block_myoverview
+nocoursesinprogress,block_myoverview
+nocoursesfuture,block_myoverview
+nocoursespast,block_myoverview
+noevents,block_myoverview
+next30days,block_myoverview
+next7days,block_myoverview
+recentlyoverdue,block_myoverview
+sortbycourses,block_myoverview
+sortbydates,block_myoverview
+timeline,block_myoverview
+viewcoursename,block_myoverview
+privacy:metadata:overviewlasttab,block_myoverview
\ 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/>.
+
+/**
+ * Library functions for overview.
+ *
+ * @package block_myoverview
+ * @copyright 2018 Peter Dias
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Constants for the user preferences grouping options
+ */
+define('BLOCK_MYOVERVIEW_GROUPING_ALL', 'all');
+define('BLOCK_MYOVERVIEW_GROUPING_INPROGRESS', 'inprogress');
+define('BLOCK_MYOVERVIEW_GROUPING_FUTURE', 'future');
+define('BLOCK_MYOVERVIEW_GROUPING_PAST', 'past');
+define('BLOCK_MYOVERVIEW_GROUPING_FAVOURITES', 'favourites');
+
+/**
+ * Constants for the user preferences sorting options
+ * timeline
+ */
+define('BLOCK_MYOVERVIEW_SORTING_TITLE', 'title');
+define('BLOCK_MYOVERVIEW_SORTING_LASTACCESSED', 'lastaccessed');
+
+/**
+ * Constants for the user preferences view options
+ */
+define('BLOCK_MYOVERVIEW_VIEW_CARD', 'cards');
+define('BLOCK_MYOVERVIEW_VIEW_LIST', 'list');
+define('BLOCK_MYOVERVIEW_VIEW_SUMMARY', 'summary');
+
+/**
+ * Get the current user preferences that are available
+ *
+ * @return mixed Array representing current options along with defaults
+ */
+function block_myoverview_user_preferences() {
+ $preferences['block_myoverview_user_grouping_preference'] = array(
+ 'null' => NULL_NOT_ALLOWED,
+ 'default' => BLOCK_MYOVERVIEW_GROUPING_ALL,
+ 'type' => PARAM_ALPHA,
+ 'choices' => array(
+ BLOCK_MYOVERVIEW_GROUPING_ALL,
+ BLOCK_MYOVERVIEW_GROUPING_INPROGRESS,
+ BLOCK_MYOVERVIEW_GROUPING_FUTURE,
+ BLOCK_MYOVERVIEW_GROUPING_PAST,
+ BLOCK_MYOVERVIEW_GROUPING_FAVOURITES
+ )
+ );
+ $preferences['block_myoverview_user_sort_preference'] = array(
+ 'null' => NULL_NOT_ALLOWED,
+ 'default' => BLOCK_MYOVERVIEW_SORTING_TITLE,
+ 'type' => PARAM_ALPHA,
+ 'choices' => array(
+ BLOCK_MYOVERVIEW_SORTING_TITLE,
+ BLOCK_MYOVERVIEW_SORTING_LASTACCESSED
+ )
+ );
+ $preferences['block_myoverview_user_view_preference'] = array(
+ 'null' => NULL_NOT_ALLOWED,
+ 'default' => BLOCK_MYOVERVIEW_VIEW_CARD,
+ 'type' => PARAM_ALPHA,
+ 'choices' => array(
+ BLOCK_MYOVERVIEW_VIEW_CARD,
+ BLOCK_MYOVERVIEW_VIEW_LIST,
+ BLOCK_MYOVERVIEW_VIEW_SUMMARY
+ )
+ );
+ return $preferences;
+}
\ 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 block_myoverview/course-action-menu
+
+ This template renders action menu for each course.
+
+ Example context (json):
+ {
+ "isfavourite": true
+ }
+}}
+<div class="ml-auto dropdown">
+ <button class="btn btn-link btn-icon icon-size-3 coursemenubtn"
+ type="button"
+ data-toggle="dropdown"
+ aria-haspopup="true"
+ aria-expanded="false">
+ {{#pix}} i/moremenu, core {{/pix}}
+ <span class="sr-only">
+ {{#str}} aria:courseactions, block_myoverview {{/str}} {{{fullname}}}
+ </span>
+ </button>
+ <div class="dropdown-menu dropdown-menu-right">
+ <a class="dropdown-item {{#isfavourite}}hidden{{/isfavourite}}" href="#"
+ data-action="add-favourite"
+ data-course-id="{{id}}"
+ aria-controls="favorite-icon-{{ id }}"
+ >
+ {{#pix}} i/star, core, {{#str}} favourite, block_myoverview {{/str}} {{/pix}}
+ {{#str}} addtofavourites, block_myoverview {{/str}}
+ <div class="sr-only">
+ {{#str}} aria:addtofavourites, block_myoverview {{/str}} {{{fullname}}}
+ </div>
+ </a>
+ <a class="dropdown-item {{^isfavourite}}hidden{{/isfavourite}}" href="#"
+ data-action="remove-favourite"
+ data-course-id="{{id}}"
+ aria-controls="favorite-icon-{{ id }}"
+ >
+ {{#str}} removefromfavourites, block_myoverview {{/str}}
+ <div class="sr-only">
+ {{#str}} aria:removefromfavourites, block_myoverview {{/str}} {{{fullname}}}
+ </div>
+ </a>
+ </div>
+</div>
\ No newline at end of file
Example context (json):
{
- "nocoursesimg": "https://moodlesite/theme/image.php/boost/block_myoverview/1535727318/courses"
+ "nocoursesimg": "https://moodlesite/theme/image.php/boost/block_myoverview/1535727318/courses",
+ "grouping": "all",
+ "sort": "fullname",
+ "view": "card"
}
}}
<div id="courses-view-{{uniqid}}"
data-region="courses-view"
- data-display="cards"
- data-grouping="all"
- data-sort="fullname"
+ data-display="{{view}}"
+ data-grouping="{{grouping}}"
+ data-sort="{{sort}}"
data-nocoursesimg="{{nocoursesimg}}">
<div data-region="course-view-content">
<div data-region="courses-loading-placeholder">
- <div class="row card-deck">
+ <div class="card-deck dashboard-card-deck">
{{> block_myoverview/placeholder-course }}
{{> block_myoverview/placeholder-course }}
{{> block_myoverview/placeholder-course }}
</div>
</div>
</div>
-</div>
+</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 block_myoverview/favourite-icon
+
+ This template renders the favourite icon for a course.
+
+ Example context (json):
+ {
+ "id": 3,
+ "isfavourite": true
+ }
+}}
+<div id="favorite-icon-{{ id }}"
+ data-region="favourite-icon"
+ data-course-id="{{id}}"
+ >
+ <div class="btn btn-link favouritebtn p-2">
+ <span
+ {{^isfavourite}}class="hidden"{{/isfavourite}}
+ data-region="is-favourite"
+ aria-hidden="{{^isfavourite}}true{{/isfavourite}}{{#isfavourite}}false{{/isfavourite}}"
+ >
+ {{#pix}} i/star, core, {{#str}} favourite, block_myoverview {{/str}} {{/pix}}
+ <span class="sr-only">{{#str}} aria:favourite, block_myoverview {{/str}}</span>
+ </span>
+ <span
+ {{#isfavourite}}class="hidden"{{/isfavourite}}
+ data-region="not-favourite"
+ aria-hidden="{{^isfavourite}}false{{/isfavourite}}{{#isfavourite}}true{{/isfavourite}}"
+ >
+ {{#pix}} i/empty, core, {{#str}} notfavourite, block_myoverview {{/str}} {{/pix}}
+ </span>
+ </div>
+</div>
<div id="block-myoverview-{{uniqid}}" class="block-myoverview" data-region="myoverview" role="navigation">
- <div data-region="filter" class="d-flex m-b-1" aria-label="{{#str}} aria:controls, block_myoverview {{/str}}">
+ <div data-region="filter" class="d-flex align-items-center flex-wrap" aria-label="{{#str}} aria:controls, block_myoverview {{/str}}">
{{> block_myoverview/nav-grouping-selector }}
{{> block_myoverview/nav-sort-selector }}
This template renders display dropdown.
Example context (json):
- {}
+ {
+ "cards": true,
+ "list": false,
+ "summary": false
+ }
}}
-<div class="dropdown">
+<div class="dropdown m-b-1">
<button id="displaydropdown" type="button" class="btn btn-outline-secondary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
aria-label="{{#str}} aria:displaydropdown, block_myoverview {{/str}}">
- <span class="d-sm-inline-block">{{#pix}} a/view_icon_active {{/pix}}</span>
+ {{#pix}} a/view_icon_active {{/pix}}
+ <span class="d-sm-inline-block" data-active-item-text>
+ {{#cards}}{{#str}} card, block_myoverview {{/str}}{{/cards}}
+ {{#list}}{{#str}} list, block_myoverview {{/str}}{{/list}}
+ {{#summary}}{{#str}} summary, block_myoverview {{/str}}{{/summary}}
+ </span>
</button>
<ul class="dropdown-menu" data-show-active-item aria-labelledby="displaydropdown">
<li>
- <a class="dropdown-item active" href="#" data-display-option="display" data-value="cards" aria-label="{{#str}} aria:card, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+ <a class="dropdown-item {{#cards}}active{{/cards}}" href="#" data-display-option="display" data-value="cards" data-pref="cards" aria-label="{{#str}} aria:card, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
{{#str}} card, block_myoverview {{/str}}
</a>
</li>
<li>
- <a class="dropdown-item" href="#" data-display-option="display" data-value="list" aria-label="{{#str}} aria:list, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+ <a class="dropdown-item {{#list}}active{{/list}}" href="#" data-display-option="display" data-value="list" data-pref="list" aria-label="{{#str}} aria:list, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
{{#str}} list, block_myoverview {{/str}}
</a>
</li>
<li>
- <a class="dropdown-item" href="#" data-display-option="display" data-value="summary" aria-label="{{#str}} aria:summary, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+ <a class="dropdown-item {{#summary}}active{{/summary}}" href="#" data-display-option="display" data-value="summary" data-pref="summary" aria-label="{{#str}} aria:summary, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
{{#str}} summary, block_myoverview {{/str}}
</a>
</li>
</ul>
-</div>
\ No newline at end of file
+</div>
This template renders grouping dropdown.
Example context (json):
- {}
+ {
+ "all": true,
+ "inprogress": false,
+ "future": false,
+ "past": false
+ }
}}
-<div class="dropdown">
+<div class="dropdown m-b-1 mr-auto">
<button id="groupingdropdown" type="button" class="btn btn-outline-secondary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" aria-label="{{#str}} aria:groupingdropdown, block_myoverview {{/str}}">
- <span class="d-sm-inline-block">{{#str}} all, block_myoverview {{/str}}</span>
+ {{#pix}} i/filter {{/pix}}
+ <span class="d-sm-inline-block" data-active-item-text>
+ {{#all}}{{#str}} all, block_myoverview {{/str}}{{/all}}
+ {{#inprogress}}{{#str}} inprogress, block_myoverview {{/str}}{{/inprogress}}
+ {{#future}}{{#str}} future, block_myoverview {{/str}}{{/future}}
+ {{#past}}{{#str}} past, block_myoverview {{/str}}{{/past}}
+ {{#favourites}}{{#str}} favourites, block_myoverview {{/str}}{{/favourites}}
+ </span>
</button>
- <ul class="dropdown-menu" data-show-active-item aria-labelledby="groupingdropdown">
+ <ul class="dropdown-menu" data-show-active-item data-active-item-text aria-labelledby="groupingdropdown">
<li>
- <a class="dropdown-item active" href="#" data-filter="grouping" data-value="all" aria-label="{{#str}} aria:allcourses, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+ <a class="dropdown-item {{#all}}active{{/all}}" href="#" data-filter="grouping" data-value="all" data-pref="all" aria-label="{{#str}} aria:allcourses, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
{{#str}} all, block_myoverview {{/str}}
</a>
</li>
<li>
- <a class="dropdown-item" href="#" data-filter="grouping" data-value="inprogress" aria-label="{{#str}} aria:inprogress, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+ <a class="dropdown-item {{#inprogress}}active{{/inprogress}}" href="#" data-filter="grouping" data-value="inprogress" data-pref="inprogress" aria-label="{{#str}} aria:inprogress, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
{{#str}} inprogress, block_myoverview {{/str}}
</a>
</li>
<li>
- <a class="dropdown-item" href="#" data-filter="grouping" data-value="future" aria-label="{{#str}} aria:future, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+ <a class="dropdown-item {{#future}}active{{/future}}" href="#" data-filter="grouping" data-value="future" data-pref="future" aria-label="{{#str}} aria:future, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
{{#str}} future, block_myoverview {{/str}}
</a>
</li>
<li>
- <a class="dropdown-item" href="#" data-filter="grouping" data-value="past" aria-label="{{#str}} aria:past, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+ <a class="dropdown-item {{#past}}active{{/past}}" href="#" data-filter="grouping" data-value="past" data-pref="past" aria-label="{{#str}} aria:past, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
{{#str}} past, block_myoverview {{/str}}
</a>
</li>
+ <li>
+ <a class="dropdown-item {{#favourites}}active{{/favourites}}" href="#" data-filter="grouping" data-value="favourites" data-pref="favourites" aria-label="{{#str}} aria:favourites, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+ {{#str}} favourites, block_myoverview {{/str}}
+ </a>
+ </li>
</ul>
</div>
This template renders sorting dropdown.
Example context (json):
- {}
+ {
+ "title": false,
+ "lastaccessed": true
+ }
}}
-<div class="dropdown mr-1 ml-auto">
- {{#str}} sortby, core {{/str}}
- <button id="sortingdropdown" type="button" class="btn btn-outline-secondary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" aria-label="{{#str}} aria:sortingdropdown, block_myoverview {{/str}}">
- <span class="d-sm-inline-block">{{#str}} title, block_myoverview {{/str}}</span>
- </button>
- <ul class="dropdown-menu" data-show-active-item aria-labelledby="sortingdropdown">
- <li>
- <a class="dropdown-item active" href="#" data-filter="sort" data-value="fullname" aria-label="{{#str}} aria:title, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
- {{#str}} title, block_myoverview {{/str}}
- </a>
- </li>
- <li>
- <a class="dropdown-item" href="#" data-filter="sort" data-value="ul.timeaccess desc" aria-label="{{#str}} aria:lastaccessed, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
- {{#str}} lastaccessed, block_myoverview {{/str}}
- </a>
- </li>
- </ul>
+<div class="m-b-1 mr-1 d-flex align-items-center">
+ <div class="d-none d-md-inline-block mr-1">{{#str}} sortby, core {{/str}}</div>
+ <div class="dropdown">
+ <button id="sortingdropdown" type="button" class="btn btn-outline-secondary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" aria-label="{{#str}} aria:sortingdropdown, block_myoverview {{/str}}">
+ <span data-active-item-text>
+ {{#title}}{{#str}} title, block_myoverview {{/str}}{{/title}}
+ {{#lastaccessed}}{{#str}} lastaccessed, block_myoverview {{/str}}{{/lastaccessed}}
+ </span>
+ </button>
+ <ul class="dropdown-menu" data-show-active-item aria-labelledby="sortingdropdown">
+ <li>
+ <a class="dropdown-item {{#title}}active{{/title}}" href="#" data-filter="sort" data-pref="title" data-value="fullname" aria-label="{{#str}} aria:title, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+ {{#str}} title, block_myoverview {{/str}}
+ </a>
+ </li>
+ <li>
+ <a class="dropdown-item {{#lastaccessed}}active{{/lastaccessed}}" href="#" data-filter="sort" data-pref="lastaccessed" data-value="ul.timeaccess desc" aria-label="{{#str}} aria:lastaccessed, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+ {{#str}} lastaccessed, block_myoverview {{/str}}
+ </a>
+ </li>
+ </ul>
+ </div>
</div>
\ No newline at end of file
Example context (json):
{}
}}
-<div class="card course-card border-0">
+<div class="card dashboard-card border-0">
<div class="card-img-top bg-pulse-grey w-100" style="height: 7rem">
</div>
<div class="card-body course-info-container">
}
}}
-<div class="row card-deck" role="list">
+<div class="card-deck dashboard-card-deck" role="list">
{{#courses}}
- <div class="card course-card" role="listitem">
- <a href="{{viewurl}}" tabindex="-1" role="presentation">
- <div class="card-img-top myoverviewimg" style='background-image: url("{{{courseimage}}}");'>
+ <div class="card dashboard-card" role="listitem">
+ <a href="{{viewurl}}" tabindex="-1">
+ <div class="card-img dashboard-card-img" style='background-image: url("{{{courseimage}}}");'>
<span class="sr-only">{{#str}}aria:courseimage, block_myoverview{{/str}}</span>
+ {{> block_myoverview/favourite-icon }}
</div>
</a>
- <div class="card-body course-info-container" id="course-info-container-{{id}}">
- <div class="d-flex">
- <div class="card-title">
+ <div class="card-body pr-1 course-info-container" id="course-info-container-{{id}}">
+ <div class="d-flex align-items-start">
+ <div class="card-title mr-2">
<a href="{{viewurl}}">
<span class="sr-only">{{#str}}aria:coursename, block_myoverview{{/str}}</span>
- {{#shortentext}}140, {{{fullname}}} {{/shortentext}}
+ {{#shortentext}}40, {{{fullname}}} {{/shortentext}}
</a>
</div>
+ {{> block_myoverview/course-action-menu }}
</div>
</div>
{{#hasprogress}}
- <div class="card-footer course-card-footer">
+ <div class="card-footer dashboard-card-footer">
{{> block_myoverview/progress-bar}}
</div>
{{/hasprogress}}
}
}}
-<ul class="list-group" role="list">
+<ul class="list-group">
{{#courses}}
- <li class="list-group-item course-listitem" role="listitem">
+ <li class="list-group-item course-listitem">
<div class="row-fluid">
- <div class="{{#hasprogress}}col-6 span6{{/hasprogress}}{{^hasprogress}}col-12 span12{{/hasprogress}}">
- <a href="{{viewurl}}">
- <span class="sr-only">{{#str}}aria:coursename, block_myoverview{{/str}}</span>
- {{{fullname}}}
- </a>
+ <div class="{{#hasprogress}}col-6 span6{{/hasprogress}}{{^hasprogress}}col-11 span11{{/hasprogress}} p-l-0">
+ <div class="d-flex align-items-center">
+ {{> block_myoverview/favourite-icon }}
+ <a href="{{viewurl}}">
+ <span class="sr-only">{{#str}}aria:coursename, block_myoverview{{/str}}</span>
+ {{{fullname}}}
+ </a>
+ </div>
</div>
{{#hasprogress}}
- <div class="col-6 span6">
+ <div class="col-5 span5">
{{> block_myoverview/progress-bar}}
</div>
{{/hasprogress}}
+ <div class="col-1 span1 p-0 d-flex">
+ {{> block_myoverview/course-action-menu }}
+ </div>
</div>
</li>
{{/courses}}
{{#courses}}
<div class="course-summaryitem m-b-1 p-2" role="listitem">
<div class="row-fluid d-flex">
- <a href="{{viewurl}}" class="col-sm-4 col-xl-3 span4" tabindex="-1" role="presentation">
+ <a href="{{viewurl}}" class="col-sm-4 col-xl-3 span4 position-relative" tabindex="-1">
+ <div class="position-absolute">
+ {{> block_myoverview/favourite-icon }}
+ </div>
<img src="{{{courseimage}}}" class="summaryimage img-fluid" alt="{{#str}}aria:courseimage, block_myoverview{{/str}}">
+
</a>
<div class="col-sm-8 col-xl-9 span8 align-self-stretch d-flex flex-column">
- <a href="{{viewurl}}">
- <span class="sr-only">{{#str}}aria:coursename, block_myoverview{{/str}}</span>
- <h4>{{{fullname}}}</h4>
- </a>
+ <div class="d-flex">
+ <a href="{{viewurl}}">
+ <span class="sr-only">{{#str}}aria:coursename, block_myoverview{{/str}}</span>
+ <h4>{{{fullname}}}</h4>
+ </a>
+ {{> block_myoverview/course-action-menu }}
+ </div>
<div class="summary">
<span class="sr-only">{{#str}}aria:coursesummary, block_myoverview{{/str}}</span>
{{{summary}}}
Then I should see "Course 3" in the "Course overview" "block"
Then I should see "Course 4" in the "Course overview" "block"
Then I should see "Course 5" in the "Course overview" "block"
- And I log out
\ No newline at end of file
+ And I log out
+
+ Scenario: View inprogress courses - test persistence
+ Given I log in as "student1"
+ And I click on "All" "button" in the "Course overview" "block"
+ And I click on "In progress" "link" in the "Course overview" "block"
+ And I reload the page
+ Then I should see "In progress" in the "Course overview" "block"
+ Then I should see "Course 2" in the "Course overview" "block"
+ Then I should see "Course 3" in the "Course overview" "block"
+ Then I should see "Course 4" in the "Course overview" "block"
+ And I should not see "Course 1" in the "Course overview" "block"
+ And I should not see "Course 5" in the "Course overview" "block"
+ And I log out
+
+ Scenario: View all courses - w/ persistence
+ Given I log in as "student1"
+ And I click on "All" "button" in the "Course overview" "block"
+ When I click on "All" "link" in the "Course overview" "block"
+ And I reload the page
+ Then I should see "All" in the "Course overview" "block"
+ Then I should see "Course 1" in the "Course overview" "block"
+ Then I should see "Course 2" in the "Course overview" "block"
+ Then I should see "Course 3" in the "Course overview" "block"
+ Then I should see "Course 4" in the "Course overview" "block"
+ Then I should see "Course 5" in the "Course overview" "block"
+ And I log out
+
+ Scenario: View past courses - w/ persistence
+ Given I log in as "student1"
+ And I click on "All" "button" in the "Course overview" "block"
+ When I click on "Past" "link" in the "Course overview" "block"
+ And I reload the page
+ Then I should see "Past" in the "Course overview" "block"
+ Then I should see "Course 1" in the "Course overview" "block"
+ And I should not see "Course 2" in the "Course overview" "block"
+ And I should not see "Course 3" in the "Course overview" "block"
+ And I should not see "Course 4" in the "Course overview" "block"
+ And I should not see "Course 5" in the "Course overview" "block"
+ And I log out
+
+ Scenario: View future courses - w/ persistence
+ Given I log in as "student1"
+ And I click on "All" "button" in the "Course overview" "block"
+ When I click on "Future" "link" in the "Course overview" "block"
+ And I reload the page
+ Then I should see "Future" in the "Course overview" "block"
+ Then I should see "Course 5" in the "Course overview" "block"
+ And I should not see "Course 1" in the "Course overview" "block"
+ And I should not see "Course 2" in the "Course overview" "block"
+ And I should not see "Course 3" in the "Course overview" "block"
+ And I should not see "Course 4" in the "Course overview" "block"
+ And I log out
+
+ Scenario: List display persistence
+ Given I log in as "student1"
+ And I click on "Display dropdown" "button" in the "Course overview" "block"
+ And I click on "List" "link" in the "Course overview" "block"
+ And I reload the page
+ Then I should see "List" in the "Course overview" "block"
+ And "[data-display='list']" "css_element" in the "Course overview" "block" should be visible
+
+ Scenario: Cards display persistence
+ Given I log in as "student1"
+ And I click on "Display dropdown" "button" in the "Course overview" "block"
+ And I click on "Card" "link" in the "Course overview" "block"
+ And I reload the page
+ Then I should see "Card" in the "Course overview" "block"
+ And "[data-display='cards']" "css_element" in the "Course overview" "block" should be visible
+
+ Scenario: Summary display persistence
+ Given I log in as "student1"
+ And I click on "Display dropdown" "button" in the "Course overview" "block"
+ And I click on "Summary" "link" in the "Course overview" "block"
+ And I reload the page
+ Then I should see "Summary" in the "Course overview" "block"
+ And "[data-display='summary']" "css_element" in the "Course overview" "block" should be visible
+
+ Scenario: Title sort persistence
+ Given I log in as "student1"
+ And I click on "sortingdropdown" "button" in the "Course overview" "block"
+ And I click on "Title" "link" in the "Course overview" "block"
+ And I reload the page
+ Then I should see "Title" in the "Course overview" "block"
+ And "[data-sort='fullname']" "css_element" in the "Course overview" "block" should be visible
+
+ Scenario: Last accessed sort persistence
+ Given I log in as "student1"
+ And I click on "sortingdropdown" "button" in the "Course overview" "block"
+ And I click on "Last accessed" "link" in the "Course overview" "block"
+ And I reload the page
+ Then I should see "Last accessed" in the "Course overview" "block"
+ And "[data-sort='ul.timeaccess desc']" "css_element" in the "Course overview" "block" should be visible
\ No newline at end of file
--- /dev/null
+@block @block_myoverview @javascript
+Feature: The my overview block allows users to favourite their courses
+ In order to enable the my overview block in a course
+ As a student
+ I can add the my overview block to my dashboard
+
+ Background:
+ Given the following "users" exist:
+ | username | firstname | lastname | email | idnumber |
+ | student1 | Student | X | student1@example.com | S1 |
+ And the following "courses" exist:
+ | fullname | shortname | category |
+ | Course 1 | C1 | 0 |
+ | Course 2 | C2 | 0 |
+ | Course 3 | C3 | 0 |
+ | Course 4 | C4 | 0 |
+ | Course 5 | C5 | 0 |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | student1 | C1 | student |
+ | student1 | C2 | student |
+ | student1 | C3 | student |
+ | student1 | C4 | student |
+ | student1 | C5 | student |
+
+ Scenario: Favourite a course on a course card
+ Given I log in as "student1"
+ When I click on ".coursemenubtn" "css_element" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+ And I click on "Star this course" "link" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+ And I reload the page
+ Then "//div[@role='listitem' and contains(.,'Course 2')]//span[@data-region='is-favourite' and @aria-hidden='false']" "xpath_element" should exist
+ And "//div[@role='listitem' and contains(.,'Course 2')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should not exist
+ And "//div[@role='listitem' and contains(.,'Course 2')]//span[@data-region='not-favourite' and @aria-hidden='true']" "xpath_element" should exist
+ And "//div[@role='listitem' and contains(.,'Course 1')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should exist
+ And "//div[@role='listitem' and contains(.,'Course 3')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should exist
+ And I log out
+
+ Scenario: Star a course and switch display to list
+ Given I log in as "student1"
+ When I click on ".coursemenubtn" "css_element" in the "//div[@class='card dashboard-card' and contains(.,'Course 5')]" "xpath_element"
+ And I click on "Star this course" "link" in the "//div[@class='card dashboard-card' and contains(.,'Course 5')]" "xpath_element"
+ And I reload the page
+ And I click on "Display dropdown" "button" in the "Course overview" "block"
+ And I click on "List" "link" in the "Course overview" "block"
+ And I reload the page
+ Then "//li[contains(concat(' ', normalize-space(@class), ' '), 'list-group-item') and contains(.,'Course 5')]//span[@data-region='is-favourite' and @aria-hidden='false']" "xpath_element" should exist
+ And "//li[contains(concat(' ', normalize-space(@class), ' '), 'list-group-item') and contains(.,'Course 5')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should not exist
+ And "//li[contains(concat(' ', normalize-space(@class), ' '), 'list-group-item') and contains(.,'Course 5')]//span[@data-region='not-favourite' and @aria-hidden='true']" "xpath_element" should exist
+ And "//li[contains(concat(' ', normalize-space(@class), ' '), 'list-group-item') and contains(.,'Course 1')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should exist
+ And "//li[contains(concat(' ', normalize-space(@class), ' '), 'list-group-item') and contains(.,'Course 3')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should exist
+ And I log out
+
+ Scenario: Star a course and switch display to summary
+ Given I log in as "student1"
+ When I click on ".coursemenubtn" "css_element" in the "//div[@class='card dashboard-card' and contains(.,'Course 5')]" "xpath_element"
+ And I click on "Star this course" "link" in the "//div[@class='card dashboard-card' and contains(.,'Course 5')]" "xpath_element"
+ And I reload the page
+ And I click on "Display dropdown" "button" in the "Course overview" "block"
+ And I click on "Summary" "link" in the "Course overview" "block"
+ And I reload the page
+ Then "//div[contains(concat(' ', normalize-space(@class), ' '), 'course-summaryitem') and contains(.,'Course 5')]//span[@data-region='is-favourite' and @aria-hidden='false']" "xpath_element" should exist
+ And "//div[contains(concat(' ', normalize-space(@class), ' '), 'course-summaryitem') and contains(.,'Course 5')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should not exist
+ And "//div[contains(concat(' ', normalize-space(@class), ' '), 'course-summaryitem') and contains(.,'Course 5')]//span[@data-region='not-favourite' and @aria-hidden='true']" "xpath_element" should exist
+ And "//div[contains(concat(' ', normalize-space(@class), ' '), 'course-summaryitem') and contains(.,'Course 1')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should exist
+ And "//div[contains(concat(' ', normalize-space(@class), ' '), 'course-summaryitem') and contains(.,'Course 3')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should exist
+ And I log out
--- /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/>.
+/**
+ * Unit tests for the block_myoverview implementation of the privacy API.
+ *
+ * @package block_myoverview
+ * @category test
+ * @copyright 2018 Peter Dias <peter@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+defined('MOODLE_INTERNAL') || die();
+use \core_privacy\local\request\writer;
+use \block_myoverview\privacy\provider;
+/**
+ * Unit tests for the block_myoverview implementation of the privacy API.
+ *
+ * @copyright 2018 Peter Dias <peter@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class block_myoverview_privacy_testcase extends \core_privacy\tests\provider_testcase {
+ /**
+ * Ensure that export_user_preferences returns no data if the user has not visited the myoverview block.
+ */
+ public function test_export_user_preferences_no_pref() {
+ $this->resetAfterTest();
+ $user = $this->getDataGenerator()->create_user();
+ provider::export_user_preferences($user->id);
+ $writer = writer::with_context(\context_system::instance());
+ $this->assertFalse($writer->has_any_data());
+ }
+
+ /**
+ * Test the export_user_preferences given different inputs
+ *
+ * @param string $type The name of the user preference to get/set
+ * @param string $value The value you are storing
+ *
+ * @dataProvider user_preference_provider
+ */
+ public function test_export_user_preferences($type, $value) {
+ $this->resetAfterTest();
+ $user = $this->getDataGenerator()->create_user();
+ set_user_preference($type, $value, $user);
+ provider::export_user_preferences($user->id);
+ $writer = writer::with_context(\context_system::instance());
+ $blockpreferences = $writer->get_user_preferences('block_myoverview');
+ $this->assertEquals(get_string($value, 'block_myoverview'), $blockpreferences->{$type}->value);
+ }
+
+ /**
+ * Create an array of valid user preferences for the myoverview block.
+ *
+ * @return array Array of valid user preferences.
+ */
+ public function user_preference_provider() {
+ return array(
+ array('block_myoverview_user_sort_preference', 'lastaccessed'),
+ array('block_myoverview_user_sort_preference', 'title'),
+ array('block_myoverview_user_grouping_preference', 'all'),
+ array('block_myoverview_user_grouping_preference', 'inprogress'),
+ array('block_myoverview_user_grouping_preference', 'future'),
+ array('block_myoverview_user_grouping_preference', 'past'),
+ array('block_myoverview_user_view_preference', 'card'),
+ array('block_myoverview_user_view_preference', 'list'),
+ array('block_myoverview_user_view_preference', 'summary')
+ );
+ }
+}
\ No newline at end of file
}
+ /**
+ * Test get_course_blocks contents
+ */
+ public function test_get_course_blocks_contents() {
+ global $DB, $FULLME;
+
+ $this->resetAfterTest(true);
+
+ $user = $this->getDataGenerator()->create_user();
+ $course = $this->getDataGenerator()->create_course();
+ $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+ $this->getDataGenerator()->enrol_user($user->id, $course->id, $studentrole->id);
+ $coursecontext = context_course::instance($course->id);
+
+ // Create a HTML block.
+ $title = 'Some course info';
+ $body = 'Some course info<br /><p>Some contents</p>';
+ $bodyformat = FORMAT_MOODLE;
+ $page = new moodle_page();
+ $page->set_context($coursecontext);
+ $page->set_pagelayout('course');
+ $course->format = course_get_format($course)->get_format();
+ $page->set_pagetype('course-view-' . $course->format);
+ $page->blocks->load_blocks();
+ $newblock = 'html';
+ $page->blocks->add_block_at_end_of_default_region($newblock);
+
+ $this->setUser($user);
+ // Re-create the page.
+ $page = new moodle_page();
+ $page->set_context($coursecontext);
+ $page->set_pagelayout('course');
+ $course->format = course_get_format($course)->get_format();
+ $page->set_pagetype('course-view-' . $course->format);
+ $page->blocks->load_blocks();
+ $blocks = $page->blocks->get_blocks_for_region($page->blocks->get_default_region());
+ $block = end($blocks);
+ $block = block_instance('html', $block->instance);
+ $configdata = (object) [
+ 'title' => $title,
+ 'text' => [
+ 'itemid' => 0,
+ 'text' => $body,
+ 'format' => $bodyformat,
+ ],
+ ];
+ $block->instance_config_save((object) $configdata);
+ $filename = 'img.png';
+ $filerecord = array(
+ 'contextid' => context_block::instance($block->instance->id)->id,
+ 'component' => 'block_html',
+ 'filearea' => 'content',
+ 'itemid' => 0,
+ 'filepath' => '/',
+ 'filename' => $filename,
+ );
+ // Create an area to upload the file.
+ $fs = get_file_storage();
+ // Create a file from the string that we made earlier.
+ $file = $fs->create_file_from_string($filerecord, 'some fake content (should be an image).');
+
+ // Check for the new block.
+ $result = core_block_external::get_course_blocks($course->id, true);
+ // We need to execute the return values cleaning process to simulate the web service server.
+ $result = external_api::clean_returnvalue(core_block_external::get_course_blocks_returns(), $result);
+
+ // Expect the new block.
+ $this->assertCount(1, $result['blocks']);
+ $this->assertEquals($title, $result['blocks'][0]['contents']['title']);
+ $this->assertEquals($body, $result['blocks'][0]['contents']['content']);
+ $this->assertEquals(FORMAT_HTML, $result['blocks'][0]['contents']['contentformat']); // Format change for external.
+ $this->assertEquals('', $result['blocks'][0]['contents']['footer']);
+ $this->assertCount(1, $result['blocks'][0]['contents']['files']);
+ $this->assertEquals($newblock, $result['blocks'][0]['name']);
+ }
+
/**
* Test user get default dashboard blocks.
*/
* The timeline view from block_myoverview has been split out into block_timeline.
* External function core_blocks::get_course_blocks now returns the block visible status and weight for ordering.
+* New method added block_base::get_content_for_external(). It will return all the block contents rendered for external functions.
+ If your block is returning formatted content or provide files for download, you should override this method to use the
+ external_format_text, external_format_string functions for formatting or external_util::get_area_files for files.
+ See block_html as example.
+* External functions core_block::get_course_blocks and core_block::get_dashboard_blocks have a new parameter to indicate if
+ you want to receive the block contents.
=== 3.4 ===
// and 'gsdll32.dll' to a new folder without a space in the path)
// $CFG->pathtogs = '/usr/bin/gs';
//
+// Path to PHP CLI.
+// Probably something like /usr/bin/php. If you enter this, cron scripts can be
+// executed from admin web interface.
+// $CFG->pathtophp = '';
+//
// Path to du.
// Probably something like /usr/bin/du. If you enter this, pages that display
// directory contents will run much faster for directories with a lot of files.
*/
class course_summary_exporter extends \core\external\exporter {
+ /**
+ * Constructor - saves the persistent object, and the related objects.
+ *
+ * @param mixed $data - Either an stdClass or an array of values.
+ * @param array $related - An optional list of pre-loaded objects related to this object.
+ */
+ public function __construct($data, $related = array()) {
+ if (!array_key_exists('isfavourite', $related)) {
+ $related['isfavourite'] = false;
+ }
+ parent::__construct($data, $related);
+ }
+
protected static function define_related() {
// We cache the context so it does not need to be retrieved from the course.
- return array('context' => '\\context');
+ return array('context' => '\\context', 'isfavourite' => 'bool?');
}
protected function get_other_values(renderer_base $output) {
+
$courseimage = self::get_course_image($this->data);
if (!$courseimage) {
$courseimage = self::get_course_pattern($this->data);
'viewurl' => (new moodle_url('/course/view.php', array('id' => $this->data->id)))->out(false),
'courseimage' => $courseimage,
'progress' => $progress,
- 'hasprogress' => $hasprogress
+ 'hasprogress' => $hasprogress,
+ 'isfavourite' => $this->related['isfavourite']
);
}
),
'hasprogress' => array(
'type' => PARAM_BOOL
+ ),
+ 'isfavourite' => array(
+ 'type' => PARAM_BOOL
)
);
}
break;
case COURSE_TIMELINE_FUTURE:
break;
+ case COURSE_FAVOURITES:
+ break;
default:
throw new invalid_parameter_exception('Invalid classification');
}
$requiredproperties = course_summary_exporter::define_properties();
$fields = join(',', array_keys($requiredproperties));
$courses = course_get_enrolled_courses_for_logged_in_user(0, $offset, $sort, $fields);
- list($filteredcourses, $processedcount) = course_filter_courses_by_timeline_classification(
- $courses,
- $classification,
- $limit
- );
+
+ $favouritecourseids = [];
+ $ufservice = \core_favourites\service_factory::get_service_for_user_context(\context_user::instance($USER->id));
+ $favourites = $ufservice->find_favourites_by_type('core_course', 'courses');
+
+ if ($favourites) {
+ $favouritecourseids = array_map(
+ function($favourite) {
+ return $favourite->itemid;
+ }, $favourites);
+ }
+
+ if ($classification == COURSE_FAVOURITES) {
+ list($filteredcourses, $processedcount) = course_filter_courses_by_favourites(
+ $courses,
+ $favouritecourseids,
+ $limit
+ );
+
+ } else {
+ list($filteredcourses, $processedcount) = course_filter_courses_by_timeline_classification(
+ $courses,
+ $classification,
+ $limit
+ );
+ }
$renderer = $PAGE->get_renderer('core');
- $formattedcourses = array_map(function($course) use ($renderer) {
+ $formattedcourses = array_map(function($course) use ($renderer, $favouritecourseids) {
context_helper::preload_from_record($course);
$context = context_course::instance($course->id);
- $exporter = new course_summary_exporter($course, ['context' => $context]);
+ $isfavourite = false;
+ if (in_array($course->id, $favouritecourseids)) {
+ $isfavourite = true;
+ }
+ $exporter = new course_summary_exporter($course, ['context' => $context, 'isfavourite' => $isfavourite]);
return $exporter->export($renderer);
}, $filteredcourses);
)
);
}
+
+ /**
+ * Returns description of method parameters
+ *
+ * @return external_function_parameters
+ */
+ public static function set_favourite_courses_parameters() {
+ return new external_function_parameters(
+ array(
+ 'courses' => new external_multiple_structure(
+ new external_single_structure(
+ array(
+ 'id' => new external_value(PARAM_INT, 'course ID'),
+ 'favourite' => new external_value(PARAM_BOOL, 'favourite status')
+ )
+ )
+ )
+ )
+ );
+ }
+
+ /**
+ * Set the course favourite status for an array of courses.
+ *
+ * @param array $courses List with course id's and favourite status.
+ * @return array Array with an array of favourite courses.
+ */
+ public static function set_favourite_courses(
+ array $courses
+ ) {
+ global $USER;
+
+ $params = self::validate_parameters(self::set_favourite_courses_parameters(),
+ array(
+ 'courses' => $courses
+ )
+ );
+
+ $warnings = [];
+
+ $ufservice = \core_favourites\service_factory::get_service_for_user_context(\context_user::instance($USER->id));
+
+ foreach ($params['courses'] as $course) {
+
+ $warning = [];
+
+ $favouriteexists = $ufservice->favourite_exists('core_course', 'courses', $course['id'], \context_system::instance());
+
+ if ($course['favourite']) {
+ if (!$favouriteexists) {
+ try {
+ $ufservice->create_favourite('core_course', 'courses', $course['id'], \context_system::instance());
+ } catch (Exception $e) {
+ $warning['courseid'] = $course['id'];
+ if ($e instanceof moodle_exception) {
+ $warning['warningcode'] = $e->errorcode;
+ } else {
+ $warning['warningcode'] = $e->getCode();
+ }
+ $warning['message'] = $e->getMessage();
+ $warnings[] = $warning;
+ $warnings[] = $warning;
+ }
+ } else {
+ $warning['courseid'] = $course['id'];
+ $warning['warningcode'] = 'coursealreadyfavourited';
+ $warning['message'] = 'Course already favourited';
+ $warnings[] = $warning;
+ }
+ } else {
+ if ($favouriteexists) {
+ try {
+ $ufservice->delete_favourite('core_course', 'courses', $course['id'], \context_system::instance());
+ } catch (Exception $e) {
+ $warning['courseid'] = $course['id'];
+ if ($e instanceof moodle_exception) {
+ $warning['warningcode'] = $e->errorcode;
+ } else {
+ $warning['warningcode'] = $e->getCode();
+ }
+ $warning['message'] = $e->getMessage();
+ $warnings[] = $warning;
+ $warnings[] = $warning;
+ }
+ } else {
+ $warning['courseid'] = $course['id'];
+ $warning['warningcode'] = 'cannotdeletefavourite';
+ $warning['message'] = 'Could not delete favourite status for course';
+ $warnings[] = $warning;
+ }
+ }
+ }
+
+ return [
+ 'warnings' => $warnings
+ ];
+ }
+
+ /**
+ * Returns description of method result value
+ *
+ * @return external_description
+ */
+ public static function set_favourite_courses_returns() {
+ return new external_single_structure(
+ array(
+ 'warnings' => new external_warnings()
+ )
+ );
+ }
}
define('COURSE_TIMELINE_PAST', 'past');
define('COURSE_TIMELINE_INPROGRESS', 'inprogress');
define('COURSE_TIMELINE_FUTURE', 'future');
+define('COURSE_FAVOURITES', 'favourites');
define('COURSE_DB_QUERY_LIMIT', 1000);
function make_log_url($module, $url) {
return [$filteredcourses, $numberofcoursesprocessed];
}
+/**
+ * Search the given $courses for any that match the given $classification up to the specified
+ * $limit.
+ *
+ * This function will return the subset of courses that are favourites as well as the
+ * number of courses it had to process to build that subset.
+ *
+ * It is recommended that for larger sets of courses this function is given a Generator that loads
+ * the courses from the database in chunks.
+ *
+ * @param array|Traversable $courses List of courses to process
+ * @param array $favouritecourseids Array of favourite courses.
+ * @param int $limit Limit the number of results to this amount
+ * @return array First value is the filtered courses, second value is the number of courses processed
+ */
+function course_filter_courses_by_favourites(
+ $courses,
+ $favouritecourseids,
+ int $limit = 0
+) : array {
+
+ $filteredcourses = [];
+ $numberofcoursesprocessed = 0;
+ $filtermatches = 0;
+
+ foreach ($courses as $course) {
+ $numberofcoursesprocessed++;
+
+ if (in_array($course->id, $favouritecourseids)) {
+ $filteredcourses[] = $course;
+ $filtermatches++;
+ }
+
+ if ($limit && $filtermatches >= $limit) {
+ // We've found the number of requested courses. No need to continue searching.
+ break;
+ }
+ }
+
+ // Return the number of filtered courses as well as the number of courses that were searched
+ // in order to find the matching courses. This allows the calling code to do some kind of
+ // pagination.
+ return [$filteredcourses, $numberofcoursesprocessed];
+}
+
/**
* Check module updates since a given time.
* This function checks for updates in the module config, file areas, completion, grades, comments and ratings.
And I should not see "Course categories and courses" in the ".view-mode-selector .menu" "css_element"
And I should not see "Course categories" in the ".view-mode-selector .menu" "css_element"
And I should not see "Courses" in the ".view-mode-selector .menu" "css_element"
- When I click on "Course categories" "link" in the ".view-mode-selector" "css_element"
+ And I open the action menu in ".view-mode-selector" "css_element"
And I start watching to see if a new page loads
Then I should see "Course categories and courses" in the ".view-mode-selector .menu" "css_element"
And I should see "Course categories" in the ".view-mode-selector .menu" "css_element"
And I should see "Cat 1" in the "#course-listing h3" "css_element"
And I should see "Cat 1" in the "#category-listing" "css_element"
And I should see "Course 1" in the "#course-listing" "css_element"
- When I click on "Course categories" "link" in the ".view-mode-selector" "css_element"
+ And I open the action menu in ".view-mode-selector" "css_element"
Then I should see "Courses" in the ".view-mode-selector .menu" "css_element"
And I click on "Courses" "link" in the ".view-mode-selector .menu" "css_element"
And a new page should have loaded since I started watching
And a new page should have loaded since I started watching
And I start watching to see if a new page loads
And I should see the "Course categories and courses" management page
- And I click on "Sort courses" "link"
+ And I open the action menu in ".course-listing-actions" "css_element"
And I should see "Sort by Course full name ascending" in the ".course-listing-actions" "css_element"
And I should see "Sort by Course full name descending" in the ".course-listing-actions" "css_element"
And I should see "Sort by Course short name ascending" in the ".course-listing-actions" "css_element"
And a new page should have loaded since I started watching
And I start watching to see if a new page loads
And I should see the "Course categories and courses" management page
- And I click on "Sort courses" "link"
+ And I open the action menu in ".course-listing-actions" "css_element"
And I click on "Sort by Course ID number ascending" "link" in the ".course-listing-actions" "css_element"
And a new page should have loaded since I started watching
And I start watching to see if a new page loads
And I should see course listing "Course 10" before "Course 11"
And I should see course listing "Course 11" before "Course 12"
And "#course-listing .pagination" "css_element" should not exist
- And I click on "Per page: 20" "link" in the ".course-listing-actions" "css_element"
+ And I open the action menu in ".courses-per-page" "css_element"
And I should see "5" in the ".courses-per-page" "css_element"
And I should see "10" in the ".courses-per-page" "css_element"
And I should see "20" in the ".courses-per-page" "css_element"
And I click on "Cat 1" "link"
# Redirect.
And I should see the "Course categories and courses" management page
- And I click on "Sort courses" "link"
+ And I open the action menu in ".course-listing-actions" "css_element"
And I click on "Sort by Course ID number ascending" "link" in the ".course-listing-actions" "css_element"
# Redirect.
And I should see "Per page: 20" in the ".course-listing-actions" "css_element"
And a new page should have loaded since I started watching
And I start watching to see if a new page loads
And I should see the "Course categories and courses" management page
- And I click on "Sort courses" "link"
+ And I open the action menu in ".course-listing-actions" "css_element"
And I click on "Sort by Course ID number ascending" "link" in the ".course-listing-actions" "css_element"
And a new page should have loaded since I started watching
And I start watching to see if a new page loads
And I should see course listing "Course 19" before "Course 20"
And I should not see "Course 21"
And I should see "Showing courses 1 to 20 of 32 courses"
- And I click on "Per page: 20" "link" in the ".course-listing-actions" "css_element"
+ And I open the action menu in ".courses-per-page" "css_element"
And I click on "100" "link" in the ".courses-per-page" "css_element"
And a new page should have loaded since I started watching
And I start watching to see if a new page loads
And I should see course listing "Course 21" before "Course 22"
And I should see course listing "Course 31" before "Course 32"
And "#course-listing .pagination" "css_element" should not exist
- And I click on "Per page: 100" "link" in the ".course-listing-actions" "css_element"
+ And I open the action menu in ".courses-per-page" "css_element"
And I click on "5" "link" in the ".courses-per-page" "css_element"
And a new page should have loaded since I started watching
And I start watching to see if a new page loads
And course in management listing should be dimmed "C1"
And I toggle visibility of category "CAT1" in management listing
And I toggle visibility of course "C1" in management listing
- And I click on "Course categories and courses" "link" in the ".view-mode-selector" "css_element"
+ And I open the action menu in ".view-mode-selector" "css_element"
And I click on "Courses" "link" in the ".view-mode-selector" "css_element"
And a new page should have loaded since I started watching
And I start watching to see if a new page loads
| id_enddate_year | 2016 |
And I press "Save and return"
Then I should see the "Course categories and courses" management page
- And I click on "Sort courses" "link"
And I click on "Sort by Course time created ascending" "link" in the ".course-listing-actions" "css_element"
And I should see course listing "Course 1" before "Course 2"
And I click on "Course 2" "link" in the "region-main" "region"
And I should not see "Sort by Course ID number descending" in the ".course-listing-actions" "css_element"
And I should not see "Sort by Course time created ascending" in the ".course-listing-actions" "css_element"
And I should not see "Sort by Course time created descending" in the ".course-listing-actions" "css_element"
- And I click on "Sort courses" "link"
+ And I open the action menu in ".course-listing-actions" "css_element"
And I should see "Sort by Course full name ascending" in the ".course-listing-actions" "css_element"
And I should see "Sort by Course full name descending" in the ".course-listing-actions" "css_element"
And I should see "Sort by Course short name ascending" in the ".course-listing-actions" "css_element"
And I should see the "Course categories and courses" management page
And I should see "Course categories" in the "#category-listing h3" "css_element"
And I should see "Cat 1" in the "#category-listing" "css_element"
- And I click on "Sort courses" "link"
+ And I open the action menu in ".course-listing-actions" "css_element"
And I click on "Sort by Course ID number ascending" "link" in the ".course-listing-actions" "css_element"
# Redirect.
And I should see the "Course categories and courses" management page
And I should see the "Course categories and courses" management page
And I should see "Course categories" in the "#category-listing h3" "css_element"
And I should see "Cat 1" in the "#category-listing" "css_element"
- And I click on "Sort courses" "link"
+ And I open the action menu in ".course-listing-actions" "css_element"
And I click on "Sort by Course ID number ascending" "link" in the ".course-listing-actions" "css_element"
And a new page should have loaded since I started watching
And I start watching to see if a new page loads
$this->repo->delete($favourite->id);
}
+
+ /**
+ * Check whether an item has been marked as a favourite in the respective area.
+ *
+ * @param string $component the frankenstyle component name.
+ * @param string $itemtype the type of the favourited item.
+ * @param int $itemid the id of the item which was favourited (not the favourite's id).
+ * @param \context $context the context of the item which was favourited.
+ * @return bool true if the item is favourited, false otherwise.
+ */
+ public function favourite_exists(string $component, string $itemtype, int $itemid, \context $context) : bool {
+ return $this->repo->exists_by(
+ [
+ 'userid' => $this->userid,
+ 'component' => $component,
+ 'itemtype' => $itemtype,
+ 'itemid' => $itemid,
+ 'contextid' => $context->id
+ ]
+ );
+ }
}
return array_key_exists($id, $mockstore);
})
);
+ $mockrepo->expects($this->any())
+ ->method('exists_by')
+ ->will($this->returnCallback(function(array $criteria) use (&$mockstore) {
+ // Check the mockstore for all objects with properties matching the key => val pairs in $criteria.
+ foreach ($mockstore as $index => $mockrow) {
+ $mockrowarr = (array)$mockrow;
+ if (array_diff($criteria, $mockrowarr) == []) {
+ return true;
+ }
+ }
+ return false;
+ })
+ );
$mockrepo->expects($this->any())
->method('delete')
->will($this->returnCallback(function(int $id) use (&$mockstore) {
$this->expectException(\moodle_exception::class);
$service->delete_favourite('core_course', 'course', $course1context->instanceid, $course1context);
}
+
+ /**
+ * Test confirming the behaviour of the favourite_exists() method.
+ */
+ public function test_favourite_exists() {
+ list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+ // Get a user_favourite_service for the user.
+ $repo = $this->get_mock_repository([]);
+ $service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
+
+ // Favourite a course.
+ $fav1 = $service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context);
+
+ // Verify we can check existence of the favourite.
+ $this->assertTrue(
+ $service->favourite_exists(
+ 'core_course',
+ 'course',
+ $course1context->instanceid,
+ $course1context
+ )
+ );
+
+ // And one that we know doesn't exist.
+ $this->assertFalse(
+ $service->favourite_exists(
+ 'core_course',
+ 'someothertype',
+ $course1context->instanceid,
+ $course1context
+ )
+ );
+ }
}
use \core_privacy\local\metadata\collection;
use \core_privacy\local\request\contextlist;
use \core_privacy\local\request\approved_contextlist;
+use \core_privacy\local\request\userlist;
+use \core_privacy\local\request\approved_userlist;
/**
* Privacy class for requesting user data.
* @copyright 2018 Mihail Geshoski <mihail@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\plugin\provider {
+class provider implements
+ \core_privacy\local\metadata\provider,
+ \core_privacy\local\request\core_userlist_provider,
+ \core_privacy\local\request\plugin\provider {
/**
* Returns meta data about this system.
return $contextlist;
}
+ /**
+ * Get the list of users who have data within a context.
+ *
+ * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination.
+ */
+ public static function get_users_in_context(userlist $userlist) {
+ }
+
/**
* Export all user data for the specified user, in the specified contexts.
*
public static function delete_data_for_all_users_in_context(\context $context) {
}
+ /**
+ * Delete multiple users within a single context.
+ *
+ * @param approved_userlist $userlist The approved context and user information to delete information for.
+ */
+ public static function delete_data_for_users(approved_userlist $userlist) {
+ }
+
/**
* Delete all user data for the specified user, in the specified contexts.
*
And I press "Save changes"
Scenario: Being able to change the grade type, scale and maximum grade for a grade category when there are no overridden grades
- And I click on "Edit" "link" in the "EN Cat 1" "table_row"
When I click on "Edit settings" "link" in the "EN Cat 1" "table_row"
Then I should not see "This category has associated grade items which have been overridden. Therefore some grades have already been awarded"
And I set the field "Grade type" to "Scale"
And I set the field "Scale" to "EN ABCDEF"
And I press "Save changes"
And I should not see "You cannot change the type, as grades already exist for this item"
- And I click on "Edit" "link" in the "EN Cat 1" "table_row"
And I click on "Edit settings" "link" in the "EN Cat 1" "table_row"
And I should not see "This category has associated grade items which have been overridden. Therefore some grades have already been awarded"
And I set the field "Scale" to "EN Letter scale"
And I give the grade "20.00" to the user "Student 1" for the grade item "EN Cat 1 total"
And I press "Save changes"
And I navigate to "Setup > Gradebook setup" in the course gradebook
- And I click on "Edit" "link" in the "EN Cat 1" "table_row"
When I click on "Edit settings" "link" in the "EN Cat 1" "table_row"
Then I should see "This category has associated grade items which have been overridden. Therefore some grades have already been awarded, so the grade type cannot be changed. If you wish to change the maximum grade, you must first choose whether or not to rescale existing grades."
And "//div[contains(concat(' ', normalize-space(@class), ' '), 'felement') and contains(text(), 'Value')]" "xpath_element" should exist
Scenario: Attempting to change a category item's scale when overridden grades already exist
- Given I click on "Edit" "link" in the "EN Cat 1" "table_row"
- And I click on "Edit settings" "link" in the "EN Cat 1" "table_row"
+ Given I click on "Edit settings" "link" in the "EN Cat 1" "table_row"
And I set the field "Grade type" to "Scale"
And I set the field "Scale" to "ABCDEF"
And I press "Save changes"
And I give the grade "C" to the user "Student 1" for the grade item "EN Cat 1 total"
And I press "Save changes"
And I navigate to "Setup > Gradebook setup" in the course gradebook
- And I click on "Edit" "link" in the "EN Cat 1" "table_row"
When I click on "Edit settings" "link" in the "EN Cat 1" "table_row"
Then I should see "This category has associated grade items which have been overridden. Therefore some grades have already been awarded, so the grade type and scale cannot be changed."
And "//div[contains(concat(' ', normalize-space(@class), ' '), 'felement') and contains(text(), 'ABCDEF')]" "xpath_element" should exist
And I give the grade "20.00" to the user "Student 1" for the grade item "EN Cat 1 total"
And I press "Save changes"
And I navigate to "Setup > Gradebook setup" in the course gradebook
- And I click on "Edit" "link" in the "EN Cat 1" "table_row"
And I click on "Edit settings" "link" in the "EN Cat 1" "table_row"
And I set the field "Maximum grade" to "50"
When I press "Save changes"
And I give the grade "8.00" to the user "Student 2" for the grade item "Manual item 1"
And I press "Save changes"
When I navigate to "Setup > Gradebook setup" in the course gradebook
- And I click on "Edit" "link" in the "Manual item 1" "table_row"
And I click on "Edit settings" "link" in the "Manual item 1" "table_row"
And I set the following fields to these values:
| Maximum grade | 10 |
| Grade item | Calculated weight | Grade | Contribution to course total |
| Manual item 1 | 100.00 % | 8.00 | 80.00 % |
And I navigate to "Setup > Gradebook setup" in the course gradebook
- And I click on "Edit" "link" in the "Manual item 1" "table_row"
And I click on "Edit settings" "link" in the "Manual item 1" "table_row"
And I set the following fields to these values:
| Maximum grade | 20 |
And I press "Save changes"
Scenario: Being able to change the grade type, scale and maximum grade for a manual grade item when there are no grades
- Given I click on "Edit" "link" in the "EN MI 1" "table_row"
- When I click on "Edit settings" "link" in the "EN MI 1" "table_row"
- Then I should not see "Some grades have already been awarded, so the grade type"
- And I set the field "Grade type" to "Scale"
+ Given I click on "Edit settings" "link" in the "EN MI 1" "table_row"
+ When I should not see "Some grades have already been awarded, so the grade type"
+ Then I set the field "Grade type" to "Scale"
And I press "Save changes"
And I should see "Scale must be selected"
And I set the field "Scale" to "EN ABCDEF"
And I press "Save changes"
And I should not see "You cannot change the type, as grades already exist for this item"
- And I click on "Edit" "link" in the "MI 1" "table_row"
And I click on "Edit settings" "link" in the "EN MI 1" "table_row"
And I should not see "Some grades have already been awarded, so the grade type"
And I set the field "Scale" to "EN Letter scale"
And I give the grade "20.00" to the user "Student 1" for the grade item "EN MI 1"
And I press "Save changes"
And I navigate to "Setup > Gradebook setup" in the course gradebook
- And I click on "Edit" "link" in the "EN MI 1" "table_row"
When I click on "Edit settings" "link" in the "EN MI 1" "table_row"
Then I should see "Some grades have already been awarded, so the grade type cannot be changed. If you wish to change the maximum grade, you must first choose whether or not to rescale existing grades."
And "//div[contains(concat(' ', normalize-space(@class), ' '), 'felement') and contains(text(), 'Value')]" "xpath_element" should exist
Scenario: Attempting to change a manual item's scale when grades already exist
- Given I click on "Edit" "link" in the "EN MI 1" "table_row"
- And I click on "Edit settings" "link" in the "EN MI 1" "table_row"
+ Given I click on "Edit settings" "link" in the "EN MI 1" "table_row"
And I set the field "Grade type" to "Scale"
And I set the field "Scale" to "EN ABCDEF"
And I press "Save changes"
And I give the grade "C" to the user "Student 1" for the grade item "EN MI 1"
And I press "Save changes"
And I navigate to "Setup > Gradebook setup" in the course gradebook
- And I click on "Edit" "link" in the "EN MI 1" "table_row"
When I click on "Edit settings" "link" in the "EN MI 1" "table_row"
Then I should see "Some grades have already been awarded, so the grade type and scale cannot be changed."
And "//div[contains(concat(' ', normalize-space(@class), ' '), 'felement') and contains(text(), 'ABCDEF')]" "xpath_element" should exist
And I give the grade "20.00" to the user "Student 1" for the grade item "EN MI 1"
And I press "Save changes"
And I navigate to "Setup > Gradebook setup" in the course gradebook
- And I click on "Edit" "link" in the "MI 1" "table_row"
And I click on "Edit settings" "link" in the "EN MI 1" "table_row"
And I set the field "Maximum grade" to "50"
When I press "Save changes"
And I navigate to "Setup > Gradebook setup" in the course gradebook
And I set the field "Override weight of Grade me" to "1"
Then the field "Override weight of Grade me" matches value "100.00"
- And I click on "Edit" "link" in the "Scale me" "table_row"
+ And I open the action menu in "Scale me" "table_row"
And I click on "Edit settings" "link" in the "Scale me" "table_row"
And I follow "Show more..."
And I should not see "Weight adjusted"
And the field "Override weight of Grade me" matches value "95.238"
And I set the field "Override weight of Scale me" to "1"
And the field "Override weight of Scale me" matches value "4.8"
- And I click on "Edit" "link" in the "Scale me" "table_row"
+ And I open the action menu in "Scale me" "table_row"
And I click on "Edit settings" "link" in the "Scale me" "table_row"
And I follow "Show more..."
And I should see "Weight adjusted"
$mform->addHelpButton('enrolmentkey', 'enrolmentkey', 'group');
$mform->setType('enrolmentkey', PARAM_RAW);
+ // Group conversation messaging.
+ if (\core_message\api::can_create_group_conversation($USER->id, $coursecontext)) {
+ $mform->addElement('selectyesno', 'enablemessaging', get_string('enablemessaging', 'group'));
+ $mform->addHelpButton('enablemessaging', 'enablemessaging', 'group');
+ }
+
$mform->addElement('static', 'currentpicture', get_string('currentpicture'));
$mform->addElement('checkbox', 'deletepicture', get_string('delete'));
* Extend the form definition after the data has been parsed.
*/
public function definition_after_data() {
- global $COURSE, $DB;
+ global $COURSE, $DB, $USER;
$mform = $this->_form;
$groupid = $mform->getElementValue('id');
+ $coursecontext = context_course::instance($COURSE->id);
if ($group = $DB->get_record('groups', array('id' => $groupid))) {
-
+ // If can create group conversation then get if a conversation area exists and it is enabled.
+ if (\core_message\api::can_create_group_conversation($USER->id, $coursecontext)) {
+ if (\core_message\api::is_conversation_area_enabled('core_group', 'groups', $groupid, $coursecontext->id)) {
+ $mform->getElement('enablemessaging')->setSelected(1);
+ }
+ }
// Print picture.
if (!($pic = print_group_picture($group, $COURSE->id, true, true, false))) {
$pic = get_string('none');
// Invalidate the group and grouping cache for users.
cache_helper::invalidate_by_definition('core', 'user_group_groupings', array(), array($userid));
+ // Group conversation messaging.
+ if ($conversation = \core_message\api::get_conversation_by_area('core_group', 'groups', $groupid, $context->id)) {
+ \core_message\api::add_members_to_conversation([$userid], $conversation->id);
+ }
+
// Trigger group event.
$params = array(
'context' => $context,
// Invalidate the group and grouping cache for users.
cache_helper::invalidate_by_definition('core', 'user_group_groupings', array(), array($userid));
+ // Group conversation messaging.
+ $context = context_course::instance($group->courseid);
+ if ($conversation = \core_message\api::get_conversation_by_area('core_group', 'groups', $groupid, $context->id)) {
+ \core_message\api::remove_members_from_conversation([$userid], $conversation->id);
+ }
+
// Trigger group event.
$params = array(
'context' => context_course::instance($group->courseid),
* @return id of group or false if error
*/
function groups_create_group($data, $editform = false, $editoroptions = false) {
- global $CFG, $DB;
+ global $CFG, $DB, $USER;
//check that courseid exists
$course = $DB->get_record('course', array('id' => $data->courseid), '*', MUST_EXIST);
// Invalidate the grouping cache for the course
cache_helper::invalidate_by_definition('core', 'groupdata', array(), array($course->id));
+ // Group conversation messaging.
+ if (\core_message\api::can_create_group_conversation($USER->id, $context)) {
+ if (!empty($data->enablemessaging)) {
+ \core_message\api::create_conversation(
+ \core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+ [],
+ $group->name,
+ \core_message\api::MESSAGE_CONVERSATION_ENABLED,
+ 'core_group',
+ 'groups',
+ $group->id,
+ $context->id);
+ }
+ }
+
// Trigger group event.
$params = array(
'context' => $context,
* @return bool true or exception
*/
function groups_update_group($data, $editform = false, $editoroptions = false) {
- global $CFG, $DB;
+ global $CFG, $DB, $USER;
$context = context_course::instance($data->courseid);
groups_update_group_icon($group, $data, $editform);
}
+ // Group conversation messaging.
+ if (\core_message\api::can_create_group_conversation($USER->id, $context)) {
+ if ($conversation = \core_message\api::get_conversation_by_area('core_group', 'groups', $group->id, $context->id)) {
+ if ($data->enablemessaging && $data->enablemessaging != $conversation->enabled) {
+ \core_message\api::enable_conversation($conversation->id);
+ }
+ if (!$data->enablemessaging && $data->enablemessaging != $conversation->enabled) {
+ \core_message\api::disable_conversation($conversation->id);
+ }
+ \core_message\api::update_conversation_name($conversation->id, $group->name);
+ } else {
+ if (!empty($data->enablemessaging)) {
+ $conversation = \core_message\api::create_conversation(
+ \core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+ [],
+ $group->name,
+ \core_message\api::MESSAGE_CONVERSATION_ENABLED,
+ 'core_group',
+ 'groups',
+ $group->id,
+ $context->id
+ );
+
+ // Add members to conversation if they exists in the group.
+ if ($groupmemberroles = groups_get_members_by_role($group->id, $group->courseid, 'u.id')) {
+ $users = [];
+ foreach ($groupmemberroles as $roleid => $roledata) {
+ foreach ($roledata->users as $member) {
+ $users[] = $member->id;
+ }
+ }
+ \core_message\api::add_members_to_conversation($users, $conversation->id);
+ }
+ }
+ }
+ }
+
// Trigger group event.
$params = array(
'context' => $context,
}
$this->assertEquals(2, $DB->count_records('groups_members', array('groupid' => $group6->id)));
}
+
+ /**
+ * Test groups_create_group enabling a group conversation.
+ */
+ public function test_groups_create_group_with_conversation() {
+ global $DB;
+
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $course1 = $this->getDataGenerator()->create_course();
+ $coursecontext1 = context_course::instance($course1->id);
+
+ // Create two groups and only one group with enablemessaging = 1.
+ $group1a = $this->getDataGenerator()->create_group(array('courseid' => $course1->id, 'enablemessaging' => 1));
+ $group1b = $this->getDataGenerator()->create_group(array('courseid' => $course1->id, 'enablemessaging' => 0));
+
+ $conversations = $DB->get_records('message_conversations',
+ [
+ 'contextid' => $coursecontext1->id,
+ 'component' => 'core_group',
+ 'itemtype' => 'groups',
+ 'enabled' => \core_message\api::MESSAGE_CONVERSATION_ENABLED
+ ]
+ );
+ $this->assertCount(1, $conversations);
+
+ $conversation = reset($conversations);
+ // Check groupid was stored in itemid on conversation area.
+ $this->assertEquals($group1a->id, $conversation->itemid);
+
+ $conversations = $DB->get_records('message_conversations', ['id' => $conversation->id]);
+ $this->assertCount(1, $conversations);
+
+ $conversation = reset($conversations);
+
+ // Check group name was stored in conversation.
+ $this->assertEquals($group1a->name, $conversation->name);
+ }
+
+ /**
+ * Test groups_update_group enabling and disabling a group conversation.
+ */
+ public function test_groups_update_group_conversation() {
+ global $DB;
+
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $course1 = $this->getDataGenerator()->create_course();
+ $coursecontext1 = context_course::instance($course1->id);
+
+ // Create two groups and only one group with enablemessaging = 1.
+ $group1a = $this->getDataGenerator()->create_group(array('courseid' => $course1->id, 'enablemessaging' => 1));
+ $group1b = $this->getDataGenerator()->create_group(array('courseid' => $course1->id, 'enablemessaging' => 0));
+
+ $conversations = $DB->get_records('message_conversations',
+ [
+ 'contextid' => $coursecontext1->id,
+ 'component' => 'core_group',
+ 'itemtype' => 'groups',
+ 'enabled' => \core_message\api::MESSAGE_CONVERSATION_ENABLED
+ ]
+ );
+ $this->assertCount(1, $conversations);
+
+ // Check that the conversation area is created when group messaging is enabled in the course group.
+ $group1b->enablemessaging = 1;
+ groups_update_group($group1b);
+
+ $conversations = $DB->get_records('message_conversations',
+ [
+ 'contextid' => $coursecontext1->id,
+ 'component' => 'core_group',
+ 'itemtype' => 'groups',
+ 'enabled' => \core_message\api::MESSAGE_CONVERSATION_ENABLED
+ ],
+ 'id ASC');
+ $this->assertCount(2, $conversations);
+
+ $conversation1a = array_shift($conversations);
+ $conversation1b = array_shift($conversations);
+
+ $conversation1b = $DB->get_record('message_conversations', ['id' => $conversation1b->id]);
+
+ // Check for group1b that group name was stored in conversation.
+ $this->assertEquals($group1b->name, $conversation1b->name);
+
+ $group1b->enablemessaging = 0;
+ groups_update_group($group1b);
+ $this->assertEquals(0, $DB->get_field("message_conversations", "enabled", ['id' => $conversation1b->id]));
+
+ // Check that the name of the conversation is changed when the name of the course group is updated.
+ $group1b->name = 'New group name';
+ groups_update_group($group1b);
+ $conversation1b = $DB->get_record('message_conversations', ['id' => $conversation1b->id]);
+ $this->assertEquals($group1b->name, $conversation1b->name);
+ }
+
+ /**
+ * Test groups_add_member to conversation.
+ */
+ public function test_groups_add_member_conversation() {
+ global $DB;
+ $this->resetAfterTest();
+
+ $this->setAdminUser();