# Enable test external resources
sed -i \
-e "/require_once/i \\define('TEST_EXTERNAL_FILES_HTTP_URL', 'http://127.0.0.1:8080');" \
+ -e "/require_once/i \\define('TEST_EXTERNAL_FILES_HTTPS_URL', 'http://127.0.0.1:8080');" \
config.php ;
+
# Redis cache store tests
sed -i \
-e "/require_once/i \\define('TEST_CACHESTORE_REDIS_TESTSERVERS', '127.0.0.1');" \
if [ "$TASK" = 'PHPUNIT' ];
then
vendor/bin/phpunit --fail-on-risky --disallow-test-output --verbose;
- EXTTESTS_HITS=$(docker logs exttests 2>&1 | grep -Fv -e 'AH00558' -e '[pid 1]' | wc -l)
- echo -e "\nTest local resources number of hits: ${EXTTESTS_HITS}.\n"
fi
- >
exit 1 ;
fi
fi
+
+after_script:
+ - >
+ if [ "$TASK" = 'PHPUNIT' ];
+ then
+ EXTTESTS_HITS=$(docker logs exttests 2>&1 | grep -Fv -e 'AH00558' -e '[pid 1]' | wc -l)
+ echo -e "\nTest local resources number of hits: ${EXTTESTS_HITS}.\n"
+ fi
</CUSTOM_CHECK>
</CUSTOM_CHECKS>
</MOODLE>
+ <MOODLE version="3.10" requires="3.5">
+ <UNICODE level="required">
+ <FEEDBACK>
+ <ON_ERROR message="unicoderequired" />
+ </FEEDBACK>
+ </UNICODE>
+ <DATABASE level="required">
+ <VENDOR name="mariadb" version="10.2.29" />
+ <VENDOR name="mysql" version="5.7" />
+ <VENDOR name="postgres" version="9.6" />
+ <VENDOR name="mssql" version="11.0" />
+ <VENDOR name="oracle" version="11.2" />
+ </DATABASE>
+ <PHP version="7.2.0" level="required">
+ </PHP>
+ <PCREUNICODE level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="pcreunicodewarning" />
+ </FEEDBACK>
+ </PCREUNICODE>
+ <PHP_EXTENSIONS>
+ <PHP_EXTENSION name="iconv" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="iconvrequired" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="mbstring" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="mbstringrequired" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="curl" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="curlrequired" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="openssl" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="opensslrequired" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="tokenizer" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="tokenizerrecommended" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="xmlrpc" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="xmlrpcrecommended" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="soap" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="soaprecommended" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="ctype" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="ctyperequired" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="zip" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="ziprequired" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="zlib" level="required">
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="gd" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="gdrequired" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="simplexml" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="simplexmlrequired" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="spl" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="splrequired" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="pcre" level="required">
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="dom" level="required">
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="xml" level="required">
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="xmlreader" level="required">
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="intl" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="intlrequired" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="json" level="required">
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="hash" level="required"/>
+ <PHP_EXTENSION name="fileinfo" level="required"/>
+ </PHP_EXTENSIONS>
+ <PHP_SETTINGS>
+ <PHP_SETTING name="memory_limit" value="96M" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="settingmemorylimit" />
+ </FEEDBACK>
+ </PHP_SETTING>
+ <PHP_SETTING name="file_uploads" value="1" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="settingfileuploads" />
+ </FEEDBACK>
+ </PHP_SETTING>
+ <PHP_SETTING name="opcache.enable" value="1" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="opcacherecommended" />
+ </FEEDBACK>
+ </PHP_SETTING>
+ </PHP_SETTINGS>
+ <CUSTOM_CHECKS>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_database_storage_engine" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="unsupporteddbstorageengine" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="question/engine/upgrade/upgradelib.php" function="quiz_attempts_upgraded" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="quizattemptsupgradedmessage" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_slasharguments" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="slashargumentswarning" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_database_tables_row_format" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="unsupporteddbtablerowformat" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_unoconv_version" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="unoconvwarning" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_libcurl_version" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="libcurlwarning" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_mysql_file_format" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="unsupporteddbfileformat" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_mysql_file_per_table" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="unsupporteddbfilepertable" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_mysql_large_prefix" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="unsupporteddblargeprefix" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_is_https" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="ishttpswarning" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_mysql_incomplete_unicode_support" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="incompleteunicodesupport" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_sixtyfour_bits" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="sixtyfourbitswarning" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ </CUSTOM_CHECKS>
+ </MOODLE>
</COMPATIBILITY_MATRIX>
// Always verify plugin dependencies!
$failed = array();
if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed, $CFG->branch)) {
- echo $output->unsatisfied_dependencies_page($version, $failed, $PAGE->url);
+ echo $output->unsatisfied_dependencies_page($version, $failed, new moodle_url($PAGE->url,
+ array('confirmplugincheck' => 0)));
die();
}
unset($failed);
$failed = array();
if (!$pluginman->all_plugins_ok($version, $failed, $CFG->branch)) {
$output = $PAGE->get_renderer('core', 'admin');
- echo $output->unsatisfied_dependencies_page($version, $failed, $PAGE->url);
+ echo $output->unsatisfied_dependencies_page($version, $failed, new moodle_url($PAGE->url,
+ array('confirmplugincheck' => 0)));
die();
}
unset($failed);
if (!empty($installabortable[$plugin->component])) {
$status .= $this->output->single_button(
- new moodle_url($this->page->url, array('abortinstall' => $plugin->component)),
+ new moodle_url($this->page->url, array('abortinstall' => $plugin->component, 'confirmplugincheck' => 0)),
get_string('cancelinstallone', 'core_plugin'),
'post',
array('class' => 'actionbutton cancelinstallone d-block mt-1')
if ($installabortable) {
$out .= $this->output->single_button(
- new moodle_url($this->page->url, array('abortinstallx' => 1)),
+ new moodle_url($this->page->url, array('abortinstallx' => 1, 'confirmplugincheck' => 0)),
get_string('cancelinstallall', 'core_plugin', count($installabortable)),
'post',
array('class' => 'singlebutton cancelinstallall mr-1')
defined('MOODLE_INTERNAL') || die();
+use lang_string;
use core\persistent;
/**
- * Class for loading/storing competencies from the DB.
+ * Class for loading/storing data requests from the DB.
*
* @copyright 2018 Jun Pataleta
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
],
'comments' => [
'type' => PARAM_TEXT,
+ 'message' => new lang_string('errorinvalidrequestcomments', 'tool_dataprivacy'),
'default' => ''
],
'commentsformat' => [
'default' => FORMAT_PLAIN
],
'userid' => [
- 'default' => 0,
+ 'default' => function() {
+ global $USER;
+ return $USER->id;
+ },
'type' => PARAM_INT
],
'requestedby' => [
redirect($returnurl, get_string('contactdpoviaprivacypolicy', 'tool_dataprivacy'), 0, \core\output\notification::NOTIFY_ERROR);
}
-$mform = new tool_dataprivacy_data_request_form($url->out(false), ['manage' => !empty($manage)]);
-$mform->set_data(['type' => $requesttype]);
+$mform = new tool_dataprivacy_data_request_form($url->out(false), ['manage' => !empty($manage),
+ 'persistent' => new \tool_dataprivacy\data_request(0, (object) ['type' => $requesttype])]);
// Data request cancelled.
if ($mform->is_cancelled()) {
*/
use tool_dataprivacy\api;
+use tool_dataprivacy\data_request;
use tool_dataprivacy\local\helper;
defined('MOODLE_INTERNAL') || die();
* @license http://www.gnu.org/copyleft/gpl.html GNU Public License
* @package tool_dataprivacy
*/
-class tool_dataprivacy_data_request_form extends moodleform {
+class tool_dataprivacy_data_request_form extends \core\form\persistent {
+
+ /** @var string Name of the persistent class. */
+ protected static $persistentclass = data_request::class;
/** @var bool Flag to indicate whether this form is being rendered for managing data requests or for regular requests. */
protected $manage = false;
api::DATAREQUEST_TYPE_EXPORT => get_string('requesttypeexport', 'tool_dataprivacy'),
api::DATAREQUEST_TYPE_DELETE => get_string('requesttypedelete', 'tool_dataprivacy')
];
+
$mform->addElement('select', 'type', get_string('requesttype', 'tool_dataprivacy'), $options);
- $mform->setType('type', PARAM_INT);
$mform->addHelpButton('type', 'requesttype', 'tool_dataprivacy');
// Request comments text area.
$textareaoptions = ['cols' => 60, 'rows' => 10];
$mform->addElement('textarea', 'comments', get_string('requestcomments', 'tool_dataprivacy'), $textareaoptions);
- $mform->setType('type', PARAM_ALPHANUM);
$mform->addHelpButton('comments', 'requestcomments', 'tool_dataprivacy');
// Action buttons.
}
}
+ /**
+ * Get the default data. Unset the default userid if managing data requests
+ *
+ * @return stdClass
+ */
+ protected function get_default_data() {
+ $data = parent::get_default_data();
+ if ($this->manage) {
+ unset($data->userid);
+ }
+
+ return $data;
+ }
+
/**
* Form validation.
*
- * @param array $data
+ * @param stdClass $data
* @param array $files
+ * @param array $errors
* @return array
* @throws coding_exception
* @throws dml_exception
*/
- public function validation($data, $files) {
+ public function extra_validation($data, $files, array &$errors) {
global $USER;
- $errors = [];
$validrequesttypes = [
api::DATAREQUEST_TYPE_EXPORT,
api::DATAREQUEST_TYPE_DELETE
];
- if (!in_array($data['type'], $validrequesttypes)) {
+ if (!in_array($data->type, $validrequesttypes)) {
$errors['type'] = get_string('errorinvalidrequesttype', 'tool_dataprivacy');
}
- if (api::has_ongoing_request($data['userid'], $data['type'])) {
+ $userid = $data->userid;
+
+ if (api::has_ongoing_request($userid, $data->type)) {
$errors['type'] = get_string('errorrequestalreadyexists', 'tool_dataprivacy');
}
// Check if current user can create data deletion request.
- $userid = $data['userid'];
- if ($data['type'] == api::DATAREQUEST_TYPE_DELETE) {
+ if ($data->type == api::DATAREQUEST_TYPE_DELETE) {
if ($userid == $USER->id) {
if (!api::can_create_data_deletion_request_for_self()) {
$errors['type'] = get_string('errorcannotrequestdeleteforself', 'tool_dataprivacy');
$string['emailsalutation'] = 'Dear {$a},';
$string['errorcannotrequestdeleteforself'] = 'You don\'t have permission to create deletion request for yourself.';
$string['errorcannotrequestdeleteforother'] = 'You don\'t have permission to create deletion request for this user.';
+$string['errorinvalidrequestcomments'] = 'The comments field may contain plain text only.';
$string['errorinvalidrequestcreationmethod'] = 'Invalid request creation method!';
$string['errorinvalidrequeststatus'] = 'Invalid request status!';
$string['errorinvalidrequesttype'] = 'Invalid request type!';
$showsummary = true;
}
- if ($showsummary) {
+ if ($showsummary && $iscurrentuser) {
$summaryurl = new moodle_url('/admin/tool/dataprivacy/summary.php');
$summarynode = new core_user\output\myprofile\node('privacyandpolicies', 'retentionsummary',
get_string('dataretentionsummary', 'tool_dataprivacy'), null, $summaryurl);
| Test-Comp1 | ID-FW1 |
| Test-Comp2 | ID-FW1 |
Given the following "courses" exist:
- | shortname | fullname |
- | C1 | Course 1 |
+ | shortname | fullname | enablecompletion |
+ | C1 | Course 1 | 1 |
And the following "users" exist:
| username | firstname | lastname | email |
| student1 | Student | 1 | student1@example.com |
| user | course | role |
| student1 | C1 | student |
And the following "activities" exist:
- | activity | name | intro | course | idnumber |
- | page | PageName1 | PageDesc1 | C1 | PAGE1 |
- | page | PageName2 | PageDesc2 | C1 | PAGE2 |
+ | activity | name | intro | course | idnumber | completion | completionview |
+ | page | PageName1 | PageDesc1 | C1 | PAGE1 | 1 | 1 |
+ | page | PageName2 | PageDesc2 | C1 | PAGE2 | 1 | 1 |
And I log in as "admin"
And I am on site homepage
And I follow "Course 1"
And I should not see "Test-Comp1"
And I should not see "Test-Comp2"
And I should see "No competencies have been linked to this activity or resource."
+
+ @javascript
+ Scenario: None course competencies page.
+ When I log in as "student1"
+ And I am on site homepage
+ And I follow "Course 1"
+ And I follow "PageName1"
+ Then I should see "Test page content"
+ And I am on site homepage
+ And I follow "Course 1"
+ And I follow "PageName1"
+ Then I should see "Test page content"
'type' => 'danger', 'message' => get_string('subscriptionfeaturenotapplied', 'tool_mobile')];
}
break;
+ // Check QR automatic login.
+ case 'qrautomaticlogin':
+ if ($ms->qrcodetype == \tool_mobile\api::QR_CODE_LOGIN) {
+ $feature['message'] = [
+ 'type' => 'danger', 'message' => get_string('subscriptionfeaturenotapplied', 'tool_mobile')];
+ }
+ break;
}
}
}
$string['qrcodeformobileappurlabout'] = 'Scan the QR code with your mobile app to fill in the site URL in your app.';
$string['qrsiteadminsnotallowed'] = 'For security reasons login via QR code is not allowed for site administrators or if you are logged in as another user.';
$string['qrcodetype'] = 'QR code access';
-$string['qrcodetype_desc'] = 'A QR code can be provided for mobile app users to scan and either have the site URL filled in or be automatically logged in without having to enter their credentials.';
+$string['qrcodetype_desc'] = 'A QR code can be provided for mobile app users to scan. This can be used to fill in the site URL, or where the site is secured using HTTPS, to automatically log the user in without having to enter their username and password.';
$string['qrcodetypeurl'] = 'QR code with site URL';
$string['qrcodetypelogin'] = 'QR code with automatic login';
$string['readingthisemailgettheapp'] = 'Reading this in an email? <a href="{$a}">Download the mobile app and receive notifications on your mobile device</a>.';
$options = [
tool_mobile\api::QR_CODE_DISABLED => new lang_string('qrcodedisabled', 'tool_mobile'),
tool_mobile\api::QR_CODE_URL => new lang_string('qrcodetypeurl', 'tool_mobile'),
- tool_mobile\api::QR_CODE_LOGIN => new lang_string('qrcodetypelogin', 'tool_mobile'),
];
+ $qrcodetypedefault = tool_mobile\api::QR_CODE_URL;
+
+ if (is_https()) { // Allow QR login for https sites.
+ $options[tool_mobile\api::QR_CODE_LOGIN] = new lang_string('qrcodetypelogin', 'tool_mobile');
+ $qrcodetypedefault = tool_mobile\api::QR_CODE_LOGIN;
+ }
+
$temp->add(new admin_setting_configselect('tool_mobile/qrcodetype',
new lang_string('qrcodetype', 'tool_mobile'),
- new lang_string('qrcodetype_desc', 'tool_mobile'), tool_mobile\api::QR_CODE_LOGIN, $options));
+ new lang_string('qrcodetype_desc', 'tool_mobile'), $qrcodetypedefault, $options));
$temp->add(new admin_setting_configtext('tool_mobile/forcedurlscheme',
new lang_string('forcedurlscheme_key', 'tool_mobile'),
}
}
if (!empty($update)) {
- $authdb->Execute("UPDATE {$this->config->table}
- SET ".implode(',', $update)."
- WHERE {$this->config->fielduser}='".$this->ext_addslashes($extusername)."'");
+ $sql = "UPDATE {$this->config->table}
+ SET ".implode(',', $update)."
+ WHERE {$this->config->fielduser} = ?";
+ if (!$authdb->Execute($sql, array($this->ext_addslashes($extusername)))) {
+ print_error('auth_dbupdateerror', 'auth_db');
+ }
}
$authdb->Close();
return true;
$string['auth_dbcannotreadtable'] = 'Cannot read external table.';
$string['auth_dbtableempty'] = 'External table is empty.';
$string['auth_dbcolumnlist'] = 'External table contains the following columns:<br />{$a}';
+$string['auth_dbupdateerror'] = 'Error updating external database.';
$string['pluginname'] = 'External database';
$string['privacy:metadata'] = 'The External database authentication plugin does not store any personal data.';
$o['ou'] = 'users';
ldap_add($connection, 'ou='.$o['ou'].','.$topdn, $o);
+ $createdusers = array();
for ($i=1; $i<=5; $i++) {
$this->create_ldap_user($connection, $topdn, $i);
+ $createdusers[] = 'username' . $i;
}
// Set up creators group.
+ $assignedroles = array('username1', 'username2');
$o = array();
$o['objectClass'] = array('posixGroup');
$o['cn'] = 'creators';
$o['gidNumber'] = 1;
- $o['memberUid'] = array('username1', 'username2');
+ $o['memberUid'] = $assignedroles;
ldap_add($connection, 'cn='.$o['cn'].','.$topdn, $o);
$creatorrole = $DB->get_record('role', array('shortname'=>'coursecreator'));
// Check events, 5 users created with 2 users having roles.
$this->assertCount(7, $events);
foreach ($events as $index => $event) {
- $usercreatedindex = array(0, 2, 4, 5, 6);
- $roleassignedindex = array (1, 3);
- if (in_array($index, $usercreatedindex)) {
- $this->assertInstanceOf('\core\event\user_created', $event);
- }
- if (in_array($index, $roleassignedindex)) {
- $this->assertInstanceOf('\core\event\role_assigned', $event);
+ $username = $DB->get_field('user', 'username', array('id' => $event->relateduserid)); // Get username.
+
+ if ($event->eventname === '\core\event\user_created') {
+ $this->assertContains($username, $createdusers);
+ unset($events[$index]); // Remove matching event.
+
+ } else if ($event->eventname === '\core\event\role_assigned') {
+ $this->assertContains($username, $assignedroles);
+ unset($events[$index]); // Remove matching event.
+
+ } else {
+ $this->fail('Unexpected event found: ' . $event->eventname);
}
}
+ // If all the user_created and role_assigned events have matched
+ // then the $events array should be now empty.
+ $this->assertCount(0, $events);
$this->assertEquals(5, $DB->count_records('user', array('auth'=>'ldap')));
$this->assertEquals(2, $DB->count_records('role_assignments'));
noneNode.appendChild(deleteIcon.span);
// Also if it's not the root, none is actually invalid, so add a label.
- noneNode.appendChild(Y.Node.create('<span class="mt-1 label label-warning">' +
+ noneNode.appendChild(Y.Node.create('<span class="mt-1 badge badge-warning">' +
M.util.get_string('invalid', 'availability') + '</span>'));
}
// Add the invalid marker (empty).
this.node.appendChild(document.createTextNode(' '));
- this.node.appendChild(Y.Node.create('<span class="label label-warning"/>'));
+ this.node.appendChild(Y.Node.create('<span class="badge badge-warning"/>'));
};
/**
errors.push('core_availability:item_unknowntype');
}
// If any errors were added, add the marker to this item.
- var errorLabel = this.node.one('> .label-warning');
+ var errorLabel = this.node.one('> .badge-warning');
if (errors.length !== before && !errorLabel.get('firstChild')) {
errorLabel.appendChild(document.createTextNode(M.util.get_string('invalid', 'availability')));
} else if (errors.length === before && errorLabel.get('firstChild')) {
}
// Identify the backup we're dealing with.
- $backuprelease = floatval($this->get_task()->get_info()->backup_release); // The major version: 2.9, 3.0, ...
+ $backuprelease = $this->get_task()->get_info()->backup_release; // The major version: 2.9, 3.0, 3.10...
$backupbuild = 0;
preg_match('/(\d{8})/', $this->get_task()->get_info()->moodle_release, $matches);
if (!empty($matches[1])) {
// On older versions the freeze value has to be converted.
// We do this from here as it is happening right before the file is read.
// This only targets the backup files that can contain the legacy freeze.
- if ($backupbuild > 20150618 && ($backuprelease < 3.0 || $backupbuild < 20160527)) {
+ if ($backupbuild > 20150618 && (version_compare($backuprelease, '3.0', '<') || $backupbuild < 20160527)) {
$this->rewrite_step_backup_file_for_legacy_freeze($fullpath);
}
$gradebookcalculationsfreeze = get_config('core', 'gradebook_calculations_freeze_' . $this->get_courseid());
preg_match('/(\d{8})/', $this->get_task()->get_info()->moodle_release, $matches);
$backupbuild = (int)$matches[1];
- // The function floatval will return a float even if there is text mixed with the release number.
- $backuprelease = floatval($this->get_task()->get_info()->backup_release);
+ $backuprelease = $this->get_task()->get_info()->backup_release; // The major version: 2.9, 3.0, 3.10...
// Extra credits need adjustments only for backups made between 2.8 release (20141110) and the fix release (20150619).
if (!$gradebookcalculationsfreeze && $backupbuild >= 20141110 && $backupbuild < 20150619) {
// Courses from before 3.1 (20160518) may have a letter boundary problem and should be checked for this issue.
// Backups from before and including 2.9 could have a build number that is greater than 20160518 and should
// be checked for this problem.
- if (!$gradebookcalculationsfreeze && ($backupbuild < 20160518 || $backuprelease <= 2.9)) {
+ if (!$gradebookcalculationsfreeze && ($backupbuild < 20160518 || version_compare($backuprelease, '2.9', '<='))) {
require_once($CFG->libdir . '/db/upgradelib.php');
upgrade_course_letter_boundary($this->get_courseid());
}
// Before 3.5, question categories could be created at top level.
// From 3.5 onwards, all question categories should be a child of a special category called the "top" category.
- $backuprelease = floatval($this->get_task()->get_info()->backup_release);
+ $backuprelease = $this->get_task()->get_info()->backup_release; // The major version: 2.9, 3.0, 3.10...
preg_match('/(\d{8})/', $this->get_task()->get_info()->moodle_release, $matches);
$backupbuild = (int)$matches[1];
$before35 = false;
- if ($backuprelease < 3.5 || $backupbuild < 20180205) {
+ if (version_compare($backuprelease, '3.5', '<') || $backupbuild < 20180205) {
$before35 = true;
}
if (empty($mapping->info->parent) && $before35) {
protected function define_execution() {
global $DB;
- $backuprelease = floatval($this->task->get_info()->backup_release);
+ $backuprelease = $this->task->get_info()->backup_release; // The major version: 2.9, 3.0, 3.10...
preg_match('/(\d{8})/', $this->task->get_info()->moodle_release, $matches);
$backupbuild = (int)$matches[1];
$after35 = false;
- if ($backuprelease >= 3.5 && $backupbuild > 20180205) {
+ if (version_compare($backuprelease, '3.5', '>=') && $backupbuild > 20180205) {
$after35 = true;
}
$rc = restore_controller_dbops::load_controller($restoreid);
$restoreinfo = $rc->get_info();
$rc->destroy(); // Always need to destroy.
- $backuprelease = floatval($restoreinfo->backup_release);
+ $backuprelease = $restoreinfo->backup_release; // The major version: 2.9, 3.0, 3.10...
preg_match('/(\d{8})/', $restoreinfo->moodle_release, $matches);
$backupbuild = (int)$matches[1];
$after35 = false;
- if ($backuprelease >= 3.5 && $backupbuild > 20180205) {
+ if (version_compare($backuprelease, '3.5', '>=') && $backupbuild > 20180205) {
$after35 = true;
}
*/
function getAllCopyProgress() {
var copyids = [];
- var progressbars = $('.progress').find('.progress-bar').not('.complete');
+ var progressbars = $('.progress').find('.progress-bar[data-operation][data-backupid][data-restoreid]').not('.complete');
progressbars.each(function() {
let progressvars = {
$mform->addRule('description', null, 'required');
$str = $action == 'new' ? get_string('badgeimage', 'badges') : get_string('newimage', 'badges');
- $imageoptions = array('maxbytes' => 262144, 'accepted_types' => array('web_image'));
+ $imageoptions = array('maxbytes' => 262144, 'accepted_types' => array('optimised_image'));
$mform->addElement('filepicker', 'image', $str, null, $imageoptions);
if ($action == 'new') {
$issuercontact = $CFG->badges_defaultissuercontact;
$mform->addElement('static', 'issuerinfo', get_string('defaultissuercontact', 'core_badges'), $issuercontact);
- if ($backpack && $backpack->apiversion != OPEN_BADGES_V2P1) {
- $mform->addElement('passwordunmask', 'password', get_string('defaultissuerpassword', 'core_badges'));
- $mform->setType('password', PARAM_RAW);
- $mform->addHelpButton('password', 'defaultissuerpassword', 'badges');
- $mform->hideIf('password', 'apiversion', 'eq', 1);
- } else {
- $oauth2options = badges_get_oauth2_service_options();
- $mform->addElement('select', 'oauth2_issuerid', get_string('oauth2issuer', 'core_badges'), $oauth2options);
- $mform->setType('oauth2_issuerid', PARAM_INT);
- }
+ $mform->addElement('passwordunmask', 'password', get_string('defaultissuerpassword', 'core_badges'));
+ $mform->setType('password', PARAM_RAW);
+ $mform->addHelpButton('password', 'defaultissuerpassword', 'badges');
+ $mform->hideIf('password', 'apiversion', 'neq', 2);
+
+ $oauth2options = badges_get_oauth2_service_options();
+ $mform->addElement('select', 'oauth2_issuerid', get_string('oauth2issuer', 'core_badges'), $oauth2options);
+ $mform->setType('oauth2_issuerid', PARAM_INT);
+ $mform->hideIf('oauth2_issuerid', 'apiversion', 'neq', '2.1');
+
if ($backpack) {
$this->set_data($backpack);
}
foreach ($this->backpacks as $backpack) {
$exporter = new backpack_exporter($backpack);
$backpack = $exporter->export($output);
- if ($backpack->apiversion == OPEN_BADGES_V2 || $backpack->apiversion == OPEN_BADGES_V2P1) {
- $backpack->canedit = true;
- } else {
- $backpack->canedit = false;
- }
$backpack->cantest = ($backpack->apiversion == OPEN_BADGES_V2);
$backpack->iscurrent = ($backpack->id == $CFG->badges_site_backpack);
Example context (json):
{
"backpacks": [
- {"backpackweburl": "http://localhost/", "sitebackpack": true, "canedit": false, "cantest": true}
+ {"backpackweburl": "http://localhost/", "sitebackpack": true, "cantest": true}
]
}
}}
<td> {{{backpackweburl}}} </td>
<td> {{#sitebackpack}}Yes{{/sitebackpack}} </td>
<td>
- {{#canedit}}
<a href="{{baseurl}}?id={{id}}&action=edit">{{#pix}}t/edit, core,{{#str}}editsettings{{/str}}{{/pix}}</a>
- {{/canedit}}
{{^iscurrent}}
<a href="{{baseurl}}?id={{id}}&action=delete" role="button" data-action="deletebackpack">
{{#pix}}t/delete, core,{{#str}}delete{{/str}}{{/pix}}
And I set the field "backpackweburl" to "http://backpackweburl.cat"
And I press "Save changes"
Then I should see "http://backpackweburl.cat"
- And "Delete" "button" should exist
+ And "Delete" "icon" should exist in the "http://backpackweburl.cat" "table_row"
+ And "Edit settings" "icon" should exist in the "http://backpackweburl.cat" "table_row"
@javascript
Scenario: Remove a site backpack
start = start >= 0 ? start : 0;
}
+ // At least show one card.
+ if (availableVisibleCards === 0) {
+ availableVisibleCards = 1;
+ }
+
var coursesToShow = allCourses.slice(start, start + availableVisibleCards);
// Create an id for the list of courses we expect to be displayed.
var newVisibleCoursesId = coursesToShow.reduce(function(carry, course) {
* @return array Additional properties with values
*/
protected function get_other_values(renderer_base $output) {
- global $OUTPUT;
+ global $CFG;
+ require_once($CFG->libdir.'/modinfolib.php');
return array(
- 'viewurl' => (new moodle_url('/mod/'.$this->data->modname.'/view.php',
- array('id' => $this->data->cmid)))->out(false),
- 'courseviewurl' => (new moodle_url('/course/view.php', array('id' => $this->data->courseid)))->out(false),
- 'icon' => $OUTPUT->image_icon('icon', get_string('pluginname', $this->data->modname), $this->data->modname)
+ 'viewurl' => (new moodle_url('/mod/'.$this->data->modname.'/view.php',
+ array('id' => $this->data->cmid)))->out(false),
+ 'courseviewurl' => (new moodle_url('/course/view.php', array('id' => $this->data->courseid)))->out(false),
+ 'icon' => \html_writer::img(
+ get_fast_modinfo($this->data->courseid)->cms[$this->data->cmid]->get_icon_url(),
+ get_string('pluginname', $this->data->modname),
+ ['title' => get_string('pluginname', $this->data->modname), 'class' => 'icon']
+ )
);
}
)
);
}
-}
\ No newline at end of file
+}
data-midnight="{{midnight}}"
data-limit="2"
data-offset="0"
- data-days-limit="30"
+ data-days-limit="{{dayslimit}}"
data-days-offset="0"
data-no-events-url="{{urls.noevents}}"
id="view_courses_{{uniqid}}"
'type' => PARAM_RAW,
'default' => '',
],
+ 'daytitle' => [
+ 'type' => PARAM_RAW,
+ ]
]);
return $return;
$return['popovertitle'] = $popovertitle;
}
+ $return['daytitle'] = $this->get_day_title();
+
return $return;
}
return $title;
}
+
+ /**
+ * Get the title for this day.
+ *
+ * @return string
+ */
+ protected function get_day_title(): string {
+ $userdate = userdate($this->data[0], get_string('strftimedayshort'));
+
+ $numevents = count($this->related['events']);
+ if ($numevents == 1) {
+ $title = get_string('dayeventsone', 'calendar', $userdate);
+ } else if ($numevents) {
+ $title = get_string('dayeventsmany', 'calendar', ['num' => $numevents, 'day' => $userdate]);
+ } else {
+ $title = get_string('dayeventsnone', 'calendar', $userdate);
+ }
+
+ return $title;
+ }
}
<thead>
<tr>
{{# daynames }}
- <th class="header text-xs-center" aria-label="{{fullname}}">
- {{shortname}}
+ <th class="header text-xs-center">
+ <span class="sr-only">{{fullname}}</span>
+ <span aria-hidden="true">{{shortname}}</span>
</th>
{{/ daynames }}
</tr>
data-region="day"
data-new-event-timestamp="{{neweventtimestamp}}">
<div class="d-none d-md-block hidden-phone text-xs-center">
+ <span class="sr-only">{{daytitle}}</span>
{{#hasevents}}
<a data-action="view-day-link" href="#" class="aalink day" aria-label="{{viewdaylinktitle}}"
data-year="{{date.year}}" data-month="{{date.mon}}" data-day="{{mday}}"
data-timestamp="{{timestamp}}">{{mday}}</a>
{{/hasevents}}
{{^hasevents}}
- {{mday}}
+ <span aria-hidden="true">{{mday}}</span>
{{/hasevents}}
{{#hasevents}}
<div data-region="day-content">
{{/hasevents}}
</div>
<div class="d-md-none hidden-desktop hidden-tablet">
+ <span class="sr-only">{{daytitle}}</span>
{{#hasevents}}
<a data-action="view-day-link" href="#" class="day aalink" aria-label="{{viewdaylinktitle}}"
data-year="{{date.year}}" data-month="{{date.mon}}" data-day="{{mday}}"
data-timestamp="{{timestamp}}">{{mday}}</a>
{{/hasevents}}
{{^hasevents}}
- {{mday}}
+ <span aria-hidden="true">{{mday}}</span>
{{/hasevents}}
</div>
</td>
<thead>
<tr>
{{# daynames }}
- <th class="header text-xs-center" scope="col" aria-label="{{fullname}}">
- {{shortname}}
+ <th class="header text-xs-center">
+ <span class="sr-only">{{fullname}}</span>
+ <span aria-hidden="true">{{shortname}}</span>
</th>
{{/ daynames }}
</tr>
This is the timestamp for this month.
}} data-day-timestamp="{{timestamp}}"{{!
}}>{{!
- }}{{#popovertitle}}
+ }}<span class="sr-only">{{daytitle}}</span>
+ {{#popovertitle}}
{{< core_calendar/minicalendar_day_link }}
{{$day}}{{mday}}{{/day}}
{{$url}}{{viewdaylink}}{{/url}}
{{/ core_calendar/minicalendar_day_link }}
{{/popovertitle}}{{!
}}{{^popovertitle}}
- {{mday}}
+ <span aria-hidden="true">{{mday}}</span>
{{/popovertitle}}{{!
}}</td>
{{/days}}
$cohortmanager = has_capability('moodle/cohort:manage', $cohortcontext);
$cohortcanassign = has_capability('moodle/cohort:assign', $cohortcontext);
- $urlparams = array('id' => $cohort->id, 'returnurl' => $baseurl->out_as_local_url());
+ $urlparams = array('id' => $cohort->id, 'returnurl' => $baseurl->out_as_local_url(false));
$showhideurl = new moodle_url('/cohort/edit.php', $urlparams + array('sesskey' => sesskey()));
if ($cohortmanager) {
if ($cohort->visible) {
$recommend = false;
$strdesc = 'evidence_coursemodulecompleted';
+ if ($outcome == course_module_competency::OUTCOME_NONE) {
+ continue;
+ }
if ($outcome == course_module_competency::OUTCOME_EVIDENCE) {
$action = evidence::ACTION_LOG;
$recommend = false;
$strdesc = 'evidence_coursecompleted';
+ if ($outcome == course_module_competency::OUTCOME_NONE) {
+ continue;
+ }
if ($outcome == course_competency::OUTCOME_EVIDENCE) {
$action = evidence::ACTION_LOG;
});
}).then(function(modal) {
modal.setSaveButtonText(saveButtonText);
- modal.getRoot().on(ModalEvents.save, function() {
+ modal.getRoot().on(ModalEvents.save, function(e) {
// The action is now confirmed, sending an action for it.
- var newname = $("#newname").val();
- return renameContent(contentid, newname);
+ var newname = $("#newname").val().trim();
+ if (newname) {
+ renameContent(contentid, newname);
+ } else {
+ var errorStrings = [
+ {
+ key: 'error',
+ },
+ {
+ key: 'emptynamenotallowed',
+ component: 'core_contentbank',
+ },
+ ];
+ Str.get_strings(errorStrings).then(function(langStrings) {
+ Notification.alert(langStrings[0], langStrings[1]);
+ }).catch(Notification.exception);
+ e.preventDefault();
+ }
});
// Handle hidden event.
};
var requestType = 'success';
Ajax.call([request])[0].then(function(data) {
- if (data) {
+ if (data.result) {
return 'contentrenamed';
}
requestType = 'error';
- return 'contentnotrenamed';
+ return data.warnings[0].message;
}).then(function(message) {
var params = null;
* @throws \coding_exception if not loaded.
*/
public function set_name(string $name): bool {
+ $name = trim($name);
if (empty($name)) {
return false;
}
return $this->content->configdata;
}
+ /**
+ * Import a file as a valid content.
+ *
+ * By default, all content has a public file area to interact with the content bank
+ * repository. This method should be overridden by contentypes which does not simply
+ * upload to the public file area.
+ *
+ * If any, the method will return the final stored_file. This way it can be invoked
+ * as parent::import_file in case any plugin want to store the file in the public area
+ * and also parse it.
+ *
+ * @throws file_exception If file operations fail
+ * @param stored_file $file File to store in the content file area.
+ * @return stored_file|null the stored content file or null if the file is discarted.
+ */
+ public function import_file(stored_file $file): ?stored_file {
+ $originalfile = $this->get_file();
+ if ($originalfile) {
+ $originalfile->replace_file_with($file);
+ return $originalfile;
+ } else {
+ $itemid = $this->get_id();
+ $fs = get_file_storage();
+ $filerecord = [
+ 'contextid' => $this->get_contextid(),
+ 'component' => 'contentbank',
+ 'filearea' => 'public',
+ 'itemid' => $this->get_id(),
+ 'filepath' => '/',
+ 'filename' => $file->get_filename(),
+ 'timecreated' => time(),
+ ];
+ return $fs->create_file_from_storedfile($filerecord, $file);
+ }
+ }
+
/**
* Returns the $file related to this content.
*
/**
* Create content from a file information.
*
+ * @throws file_exception If file operations fail
+ * @throws dml_exception if the content creation fails
* @param \context $context Context where to upload the file and content.
* @param int $userid Id of the user uploading the file.
* @param stored_file $file The file to get information from
$record->name = $filename;
$record->usercreated = $userid;
$contentype = new $classname($context);
- $content = $contentype->create_content($record);
+ $content = $contentype->upload_content($file, $record);
$event = \core\event\contentbank_content_uploaded::create_from_record($content->get_content());
$event->trigger();
return $content;
use core\event\contentbank_content_created;
use core\event\contentbank_content_deleted;
use core\event\contentbank_content_viewed;
+use stored_file;
+use Exception;
use moodle_url;
/**
/**
* Fills content_bank table with appropiate information.
*
+ * @throws dml_exception A DML specific exception is thrown for any creation error.
* @param \stdClass $record An optional content record compatible object (default null)
* @return content Object with content bank information.
*/
- public function create_content(\stdClass $record = null): ?content {
+ public function create_content(\stdClass $record = null): content {
global $USER, $DB;
$entry = new \stdClass();
$entry->configdata = $record->configdata ?? '';
$entry->instanceid = $record->instanceid ?? 0;
$entry->id = $DB->insert_record('contentbank_content', $entry);
- if ($entry->id) {
- $classname = '\\'.$entry->contenttype.'\\content';
- $content = new $classname($entry);
- // Trigger an event for creating the content.
- $event = contentbank_content_created::create_from_record($content->get_content());
- $event->trigger();
- return $content;
+ $classname = '\\'.$entry->contenttype.'\\content';
+ $content = new $classname($entry);
+ // Trigger an event for creating the content.
+ $event = contentbank_content_created::create_from_record($content->get_content());
+ $event->trigger();
+ return $content;
+ }
+
+ /**
+ * Create a new content from an uploaded file.
+ *
+ * @throws file_exception If file operations fail
+ * @throws dml_exception if the content creation fails
+ * @param stored_file $file the uploaded file
+ * @param \stdClass|null $record an optional content record
+ * @return content Object with content bank information.
+ */
+ public function upload_content(stored_file $file, \stdClass $record = null): content {
+ if (empty($record)) {
+ $record = new \stdClass();
+ $record->name = $file->get_filename();
}
- return null;
+ $content = $this->create_content($record);
+ try {
+ $content->import_file($file);
+ } catch (Exception $e) {
+ $this->delete_content($content);
+ throw $e;
+ }
+
+ return $content;
}
/**
$content = new $contentclass($record);
// Check capability.
if ($contenttype->can_manage($content)) {
- // This content can be renamed.
- if ($contenttype->rename_content($content, $params['name'])) {
- $result = true;
- } else {
+ if (empty(trim($name))) {
+ // If name is empty don't try to rename and return a more detailed message.
$warnings[] = [
'item' => $contentid,
- 'warningcode' => 'contentnotrenamed',
- 'message' => get_string('contentnotrenamed', 'core_contentbank')
+ 'warningcode' => 'emptynamenotallowed',
+ 'message' => get_string('emptynamenotallowed', 'core_contentbank')
];
+ } else {
+ // This content can be renamed.
+ if ($contenttype->rename_content($content, $params['name'])) {
+ $result = true;
+ } else {
+ $warnings[] = [
+ 'item' => $contentid,
+ 'warningcode' => 'contentnotrenamed',
+ 'message' => get_string('contentnotrenamed', 'core_contentbank')
+ ];
+ }
}
} else {
// The user has no permission to manage this content.
<div class="cb-type cb-column d-flex last">
<div class="title">{{#str}} type, contentbank {{/str}}</div>
<button class="btn btn-sm cb-btnsort dir-none ml-auto" data-string="type" data-action="sorttype"
- title="{{#str}} sortbyx, core, {{#str}} size, contentbank {{/str}} {{/str}}">
+ title="{{#str}} sortbyx, core, {{#str}} type, contentbank {{/str}} {{/str}}">
<span class="default">{{#pix}} t/sort, core, {{#str}}sort, core {{/str}} {{/pix}}</span>
<span class="desc">{{#pix}} t/sort_desc, core, {{#str}}desc, core{{/str}} {{/pix}}</span>
<span class="asc">{{#pix}} t/sort_asc, core, {{#str}}asc, core{{/str}} {{/pix}}</span>
'Name with symbols' => ['Follow us: @moodle', 'Follow us: @moodle'],
'Name with tags' => ['This is <b>bold</b>', 'This is bold'],
'Long name' => [str_repeat('a', 100), str_repeat('a', 100)],
- 'Too long name' => [str_repeat('a', 300), str_repeat('a', 255)]
+ 'Too long name' => [str_repeat('a', 300), str_repeat('a', 255)],
+ 'Empty name' => ['', 'Old name'],
+ 'Blanks only' => [' ', 'Old name'],
];
}
$this->assertEquals($newcontext->id, $content->get_contextid());
$this->assertEquals($newcontext->id, $file->get_contextid());
}
+
+ /**
+ * Tests for 'import_file' behaviour when replacing a file.
+ *
+ * @covers ::import_file
+ */
+ public function test_import_file_replace(): void {
+ global $USER;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $context = context_system::instance();
+
+ // Add some content to the content bank.
+ $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+ $contents = $generator->generate_contentbank_data('contenttype_testable', 3, 0, $context);
+ $content = reset($contents);
+
+ $originalfile = $content->get_file();
+
+ // Create a dummy file.
+ $filerecord = array(
+ 'contextid' => $context->id,
+ 'component' => 'contentbank',
+ 'filearea' => 'draft',
+ 'itemid' => $content->get_id(),
+ 'filepath' => '/',
+ 'filename' => 'example.txt'
+ );
+ $fs = get_file_storage();
+ $file = $fs->create_file_from_string($filerecord, 'Dummy content ');
+
+ $importedfile = $content->import_file($file);
+
+ $this->assertEquals($originalfile->get_filename(), $importedfile->get_filename());
+ $this->assertEquals($originalfile->get_filearea(), $importedfile->get_filearea());
+ $this->assertEquals($originalfile->get_filepath(), $importedfile->get_filepath());
+ $this->assertEquals($originalfile->get_mimetype(), $importedfile->get_mimetype());
+
+ $this->assertEquals($file->get_userid(), $importedfile->get_userid());
+ $this->assertEquals($file->get_contenthash(), $importedfile->get_contenthash());
+ }
+
+ /**
+ * Tests for 'import_file' behaviour when uploading a new file.
+ *
+ * @covers ::import_file
+ */
+ public function test_import_file_upload(): void {
+ global $USER;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $context = context_system::instance();
+
+ $type = new contenttype($context);
+ $record = (object)[
+ 'name' => 'content name',
+ 'usercreated' => $USER->id,
+ ];
+ $content = $type->create_content($record);
+
+ // Create a dummy file.
+ $filerecord = array(
+ 'contextid' => $context->id,
+ 'component' => 'contentbank',
+ 'filearea' => 'draft',
+ 'itemid' => $content->get_id(),
+ 'filepath' => '/',
+ 'filename' => 'example.txt'
+ );
+ $fs = get_file_storage();
+ $file = $fs->create_file_from_string($filerecord, 'Dummy content ');
+
+ $importedfile = $content->import_file($file);
+
+ $this->assertEquals($file->get_filename(), $importedfile->get_filename());
+ $this->assertEquals($file->get_userid(), $importedfile->get_userid());
+ $this->assertEquals($file->get_mimetype(), $importedfile->get_mimetype());
+ $this->assertEquals($file->get_contenthash(), $importedfile->get_contenthash());
+ $this->assertEquals('public', $importedfile->get_filearea());
+ $this->assertEquals('/', $importedfile->get_filepath());
+
+ $contentfile = $content->get_file($file);
+ $this->assertEquals($importedfile->get_id(), $contentfile->get_id());
+ }
}
$this->resetAfterTest();
$cb = new contentbank();
- $expectedsupporters = [$extension => $expected];
$systemcontext = context_system::instance();
// All contexts allowed for admins.
$this->setAdminUser();
$contextsupporters = $cb->load_context_supported_extensions($systemcontext);
- $this->assertEquals($expectedsupporters, $contextsupporters);
+ $this->assertArrayHasKey($extension, $contextsupporters);
+ $this->assertEquals($expected, $contextsupporters[$extension]);
}
/**
$this->resetAfterTest();
$cb = new contentbank();
- $expectedsupporters = [$extension => $expected];
$course = $this->getDataGenerator()->create_course();
$teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
// Teachers has permission in their context to upload supported by H5P content type.
$contextsupporters = $cb->load_context_supported_extensions($coursecontext);
- $this->assertEquals($expectedsupporters, $contextsupporters);
+ $this->assertArrayHasKey($extension, $contextsupporters);
+ $this->assertEquals($expected, $contextsupporters[$extension]);
}
/**
use stdClass;
use context_system;
+use context_user;
+use Exception;
use contenttype_testable\contenttype as contenttype;
/**
* Test for content bank contenttype class.
$this->assertInstanceOf('\\contenttype_testable\\content', $content);
}
+ /**
+ * Tests for behaviour of upload_content() with a file and a record.
+ *
+ * @dataProvider upload_content_provider
+ * @param bool $userecord if a predefined record has to be used.
+ *
+ * @covers ::upload_content
+ */
+ public function test_upload_content(bool $userecord): void {
+ global $USER;
+
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $dummy = [
+ 'contextid' => context_user::instance($USER->id)->id,
+ 'component' => 'user',
+ 'filearea' => 'draft',
+ 'itemid' => 1,
+ 'filepath' => '/',
+ 'filename' => 'file.h5p',
+ 'userid' => $USER->id,
+ ];
+ $fs = get_file_storage();
+ $dummyfile = $fs->create_file_from_string($dummy, 'Dummy content');
+
+ // Create content.
+ if ($userecord) {
+ $record = new stdClass();
+ $record->name = 'Test content';
+ $record->configdata = '';
+ $record->contenttype = '';
+ $checkname = $record->name;
+ } else {
+ $record = null;
+ $checkname = $dummyfile->get_filename();
+ }
+
+ $contenttype = new contenttype(context_system::instance());
+ $content = $contenttype->upload_content($dummyfile, $record);
+
+ $this->assertEquals('contenttype_testable', $content->get_content_type());
+ $this->assertEquals($checkname, $content->get_name());
+ $this->assertInstanceOf('\\contenttype_testable\\content', $content);
+
+ $file = $content->get_file();
+ $this->assertEquals($dummyfile->get_filename(), $file->get_filename());
+ $this->assertEquals($dummyfile->get_userid(), $file->get_userid());
+ $this->assertEquals($dummyfile->get_mimetype(), $file->get_mimetype());
+ $this->assertEquals($dummyfile->get_contenthash(), $file->get_contenthash());
+ $this->assertEquals('contentbank', $file->get_component());
+ $this->assertEquals('public', $file->get_filearea());
+ $this->assertEquals('/', $file->get_filepath());
+ }
+
+ /**
+ * Data provider for test_rename_content.
+ *
+ * @return array
+ */
+ public function upload_content_provider() {
+ return [
+ 'With record' => [true],
+ 'Without record' => [false],
+ ];
+ }
+
+ /**
+ * Tests for behaviour of upload_content() with a file wrong file.
+ *
+ * @covers ::upload_content
+ */
+ public function test_upload_content_exception(): void {
+ global $USER, $DB;
+
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ // The testing contenttype thows exception if filename is "error.*".
+ $dummy = [
+ 'contextid' => context_user::instance($USER->id)->id,
+ 'component' => 'user',
+ 'filearea' => 'draft',
+ 'itemid' => 1,
+ 'filepath' => '/',
+ 'filename' => 'error.txt',
+ 'userid' => $USER->id,
+ ];
+ $fs = get_file_storage();
+ $dummyfile = $fs->create_file_from_string($dummy, 'Dummy content');
+
+ $contenttype = new contenttype(context_system::instance());
+ $cbcontents = $DB->count_records('contentbank_content');
+
+ // We need to capture the exception to check no content is created.
+ try {
+ $content = $contenttype->upload_content($dummyfile);
+ $this->assertTrue(false);
+ } catch (Exception $e) {
+ $this->assertTrue(true);
+ }
+ $this->assertEquals($cbcontents, $DB->count_records('contentbank_content'));
+ $this->assertEquals(1, $DB->count_records('files', ['contenthash' => $dummyfile->get_contenthash()]));
+ }
+
/**
* Test the behaviour of can_delete().
*/
*/
public function rename_content_provider() {
return [
- 'Standard name' => ['New name', 'New name'],
- 'Name with digits' => ['Today is 17/04/2017', 'Today is 17/04/2017'],
- 'Name with symbols' => ['Follow us: @moodle', 'Follow us: @moodle'],
- 'Name with tags' => ['This is <b>bold</b>', 'This is bold'],
- 'Long name' => [str_repeat('a', 100), str_repeat('a', 100)],
- 'Too long name' => [str_repeat('a', 300), str_repeat('a', 255)]
+ 'Standard name' => ['New name', 'New name', true],
+ 'Name with digits' => ['Today is 17/04/2017', 'Today is 17/04/2017', true],
+ 'Name with symbols' => ['Follow us: @moodle', 'Follow us: @moodle', true],
+ 'Name with tags' => ['This is <b>bold</b>', 'This is bold', true],
+ 'Long name' => [str_repeat('a', 100), str_repeat('a', 100), true],
+ 'Too long name' => [str_repeat('a', 300), str_repeat('a', 255), true],
+ 'Empty name' => ['', 'Test content ', false],
+ 'Blanks only' => [' ', 'Test content ', false],
];
}
* @dataProvider rename_content_provider
* @param string $newname The name to set
* @param string $expected The name result
+ * @param bool $result The bolean result expected when renaming
*
* @covers ::rename_content
*/
- public function test_rename_content(string $newname, string $expected) {
+ public function test_rename_content(string $newname, string $expected, bool $result) {
global $DB;
$this->resetAfterTest();
// Check the content is renamed as expected by a user with permission.
$renamed = $contenttype->rename_content($content, $newname);
- $this->assertTrue($renamed);
+ $this->assertEquals($result, $renamed);
$record = $DB->get_record('contentbank_content', ['id' => $content->get_id()]);
- $this->assertNotEquals($oldname, $record->name);
$this->assertEquals($expected, $record->name);
}
*/
public function rename_content_provider() {
return [
- 'Standard name' => ['New name', 'New name'],
- 'Name with digits' => ['Today is 17/04/2017', 'Today is 17/04/2017'],
- 'Name with symbols' => ['Follow us: @moodle', 'Follow us: @moodle'],
- 'Name with tags' => ['This is <b>bold</b>', 'This is bold'],
- 'Long name' => [str_repeat('a', 100), str_repeat('a', 100)],
- 'Too long name' => [str_repeat('a', 300), str_repeat('a', 255)]
+ 'Standard name' => ['New name', 'New name', true],
+ 'Name with digits' => ['Today is 17/04/2017', 'Today is 17/04/2017', true],
+ 'Name with symbols' => ['Follow us: @moodle', 'Follow us: @moodle', true],
+ 'Name with tags' => ['This is <b>bold</b>', 'This is bold', true],
+ 'Long name' => [str_repeat('a', 100), str_repeat('a', 100), true],
+ 'Too long name' => [str_repeat('a', 300), str_repeat('a', 255), true],
+ 'Empty name' => ['', 'Test content ', false],
+ 'Blanks only' => [' ', 'Test content ', false],
];
}
*
* @dataProvider rename_content_provider
* @param string $newname The name to set
- * @param string $expected The name result
+ * @param string $expectedname The name result
+ * @param bool $expectedresult The bolean result expected when renaming
*
* @covers ::execute
*/
- public function test_rename_content_with_permission(string $newname, string $expected) {
+ public function test_rename_content_with_permission(string $newname, string $expectedname, bool $expectedresult) {
global $DB;
$this->resetAfterTest();
// Call the WS and check the content is renamed as expected.
$result = rename_content::execute($content->get_id(), $newname);
$result = external_api::clean_returnvalue(rename_content::execute_returns(), $result);
- $this->assertTrue($result['result']);
+ $this->assertEquals($expectedresult, $result['result']);
$record = $DB->get_record('contentbank_content', ['id' => $content->get_id()]);
- $this->assertNotEquals($oldname, $record->name);
- $this->assertEquals($expected, $record->name);
+ $this->assertEquals($expectedname, $record->name);
// Call the WS using an unexisting contentid and check an error is thrown.
$this->expectException(\invalid_response_exception::class);
namespace contenttype_testable;
+use file_exception;
+use stored_file;
+
/**
* Testable content plugin class.
*
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class content extends \core_contentbank\content {
+
+ /**
+ * Import a file as a valid content.
+ *
+ * This method will thow an error if the filename is "error.*"
+ *
+ * @param stored_file $file File to store in the content file area.
+ * @return stored_file|null the stored content file or null if the file is discarted.
+ * @throws file_exception if the filename contains the word "error"
+ */
+ public function import_file(stored_file $file): ?stored_file {
+ $filename = $file->get_filename();
+ if (strrpos($filename, 'error') !== false) {
+ throw new file_exception('yourerrorthanks', 'contenttype_test');
+ }
+ return parent::import_file($file);
+ }
}
require('../config.php');
require_once("$CFG->dirroot/contentbank/files_form.php");
+use core\output\notification;
+
require_login();
$contextid = optional_param('contextid', \context_system::instance()->id, PARAM_INT);
$mform = new contentbank_files_form(null, ['contextid' => $contextid, 'data' => $data, 'options' => $options]);
+$error = '';
+
if ($mform->is_cancelled()) {
redirect($returnurl);
} else if ($formdata = $mform->get_data()) {
if (!empty($files)) {
$file = reset($files);
$content = $cb->create_content_from_file($context, $USER->id, $file);
- file_save_draft_area_files($formdata->file, $contextid, 'contentbank', 'public', $content->get_id());
$viewurl = new \moodle_url('/contentbank/view.php', ['id' => $content->get_id(), 'contextid' => $contextid]);
redirect($viewurl);
+ } else {
+ $error = get_string('errornofile', 'contentbank');
}
- redirect($returnurl);
}
echo $OUTPUT->header();
echo $OUTPUT->box_start('generalbox');
+if (!empty($error)) {
+ echo $OUTPUT->notification($error, notification::NOTIFY_ERROR);
+}
+
$mform->display();
echo $OUTPUT->box_end();
$mform->addElement('editor', 'description_editor', get_string('description'), null,
$this->get_description_editor_options());
+ $mform->setType('description_editor', PARAM_RAW);
if (!empty($CFG->allowcategorythemes)) {
$themes = array(''=>get_string('forceno'));
return array(
'maxfiles' => EDITOR_UNLIMITED_FILES,
'maxbytes' => $CFG->maxbytes,
- 'trusttext' => true,
+ 'trusttext' => false,
+ 'noclean' => true,
'context' => $context,
'subdirs' => file_area_contains_subdirs($context, 'coursecat', 'description', $itemid),
);
}
require_capability('moodle/course:create', $context);
+ // Fullname and short name are required to be non-empty.
+ if (trim($course['fullname']) === '') {
+ throw new moodle_exception('errorinvalidparam', 'webservice', '', 'fullname');
+ } else if (trim($course['shortname']) === '') {
+ throw new moodle_exception('errorinvalidparam', 'webservice', '', 'shortname');
+ }
+
// Make sure lang is valid
if (array_key_exists('lang', $course)) {
if (empty($availablelangs[$course['lang']])) {
$course['category'] = $course['categoryid'];
}
- // Check if the user can change fullname.
+ // Check if the user can change fullname, and the new value is non-empty.
if (array_key_exists('fullname', $course) && ($oldcourse->fullname != $course['fullname'])) {
require_capability('moodle/course:changefullname', $context);
+ if (trim($course['fullname']) === '') {
+ throw new moodle_exception('errorinvalidparam', 'webservice', '', 'fullname');
+ }
}
- // Check if the user can change shortname.
+ // Check if the user can change shortname, and the new value is non-empty.
if (array_key_exists('shortname', $course) && ($oldcourse->shortname != $course['shortname'])) {
require_capability('moodle/course:changeshortname', $context);
+ if (trim($course['shortname']) === '') {
+ throw new moodle_exception('errorinvalidparam', 'webservice', '', 'shortname');
+ }
}
// Check if the user can change the idnumber.
*/
protected function need_restore_numsections() {
$backupinfo = $this->step->get_task()->get_info();
- $backuprelease = $backupinfo->backup_release;
- return version_compare($backuprelease, '3.3', 'lt');
+ $backuprelease = $backupinfo->backup_release; // The major version: 2.9, 3.0, 3.10...
+ return version_compare($backuprelease, '3.3', '<');
}
/**
*/
protected function is_pre_33_backup() {
$backupinfo = $this->step->get_task()->get_info();
- $backuprelease = $backupinfo->backup_release;
- return version_compare($backuprelease, '3.3', 'lt');
+ $backuprelease = $backupinfo->backup_release; // The major version: 2.9, 3.0, 3.10...
+ return version_compare($backuprelease, '3.3', '<');
}
/**
if ($mform->is_cancelled()) {
if ($return && !empty($cm->id)) {
- redirect("$CFG->wwwroot/mod/$module->name/view.php?id=$cm->id");
+ $urlparams = [
+ 'id' => $cm->id, // We always need the activity id.
+ 'forceview' => 1, // Stop file downloads in resources.
+ ];
+ $activityurl = new moodle_url("/mod/$module->name/view.php", $urlparams);
+ redirect($activityurl);
} else {
redirect(course_get_url($course, $cw->section, array('sr' => $sectionreturn)));
}
$createdsubcats = core_course_external::create_courses($courses);
}
+ /**
+ * Data provider for testing empty fields produce expected exceptions
+ *
+ * @see test_create_courses_empty_field
+ * @see test_update_courses_empty_field
+ *
+ * @return array
+ */
+ public function course_empty_field_provider(): array {
+ return [
+ [[
+ 'fullname' => '',
+ 'shortname' => 'ws101',
+ ], 'fullname'],
+ [[
+ 'fullname' => ' ',
+ 'shortname' => 'ws101',
+ ], 'fullname'],
+ [[
+ 'fullname' => 'Web Services',
+ 'shortname' => '',
+ ], 'shortname'],
+ [[
+ 'fullname' => 'Web Services',
+ 'shortname' => ' ',
+ ], 'shortname'],
+ ];
+ }
+
+ /**
+ * Test creating courses with empty fields throws an exception
+ *
+ * @param array $course
+ * @param string $expectedemptyfield
+ *
+ * @dataProvider course_empty_field_provider
+ */
+ public function test_create_courses_empty_field(array $course, string $expectedemptyfield): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ // Create a category for the new course.
+ $course['categoryid'] = $this->getDataGenerator()->create_category()->id;
+
+ $this->expectException(moodle_exception::class);
+ $this->expectExceptionMessageRegExp("/{$expectedemptyfield}/");
+ core_course_external::create_courses([$course]);
+ }
+
+ /**
+ * Test updating courses with empty fields returns warnings
+ *
+ * @param array $course
+ * @param string $expectedemptyfield
+ *
+ * @dataProvider course_empty_field_provider
+ */
+ public function test_update_courses_empty_field(array $course, string $expectedemptyfield): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ // Create a course to update.
+ $course['id'] = $this->getDataGenerator()->create_course()->id;
+
+ $result = core_course_external::update_courses([$course]);
+ $result = core_course_external::clean_returnvalue(core_course_external::update_courses_returns(), $result);
+
+ $this->assertCount(1, $result['warnings']);
+
+ $warning = reset($result['warnings']);
+ $this->assertEquals('errorinvalidparam', $warning['warningcode']);
+ $this->assertContains($expectedemptyfield, $warning['message']);
+ }
+
/**
* Test delete_courses
*/
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16px" height="16px" viewBox="0 0 16 16" version="1.1" preserveAspectRatio="xMinYMid meet">
+<g id="surface1">
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(60%,60%,60%);fill-opacity:1;" d="M 13.144531 8.304688 L 13.144531 11.144531 C 13.144531 11.851562 12.890625 12.457031 12.386719 12.960938 C 11.886719 13.460938 11.28125 13.714844 10.570312 13.714844 L 3.144531 13.714844 C 2.433594 13.714844 1.828125 13.460938 1.324219 12.960938 C 0.824219 12.457031 0.570312 11.851562 0.570312 11.144531 L 0.570312 3.714844 C 0.570312 3.007812 0.824219 2.398438 1.324219 1.898438 C 1.828125 1.394531 2.433594 1.144531 3.144531 1.144531 L 10.570312 1.144531 C 10.945312 1.144531 11.292969 1.21875 11.617188 1.367188 C 11.707031 1.40625 11.757812 1.476562 11.777344 1.570312 C 11.792969 1.671875 11.769531 1.757812 11.695312 1.832031 L 11.257812 2.269531 C 11.199219 2.328125 11.132812 2.355469 11.054688 2.355469 C 11.035156 2.355469 11.007812 2.351562 10.972656 2.339844 C 10.835938 2.304688 10.703125 2.285156 10.570312 2.285156 L 3.144531 2.285156 C 2.75 2.285156 2.414062 2.425781 2.132812 2.707031 C 1.855469 2.984375 1.714844 3.320312 1.714844 3.714844 L 1.714844 11.144531 C 1.714844 11.535156 1.855469 11.871094 2.132812 12.152344 C 2.414062 12.429688 2.75 12.570312 3.144531 12.570312 L 10.570312 12.570312 C 10.964844 12.570312 11.300781 12.429688 11.582031 12.152344 C 11.859375 11.871094 12 11.535156 12 11.144531 L 12 8.875 C 12 8.796875 12.027344 8.730469 12.082031 8.679688 L 12.652344 8.105469 C 12.710938 8.046875 12.78125 8.019531 12.855469 8.019531 C 12.894531 8.019531 12.929688 8.027344 12.964844 8.042969 C 13.082031 8.09375 13.144531 8.179688 13.144531 8.304688 Z M 15.207031 3.9375 L 7.9375 11.207031 C 7.792969 11.347656 7.625 11.417969 7.429688 11.417969 C 7.230469 11.417969 7.0625 11.347656 6.917969 11.207031 L 3.082031 7.367188 C 2.9375 7.222656 2.867188 7.054688 2.867188 6.855469 C 2.867188 6.660156 2.9375 6.492188 3.082031 6.347656 L 4.0625 5.367188 C 4.207031 5.222656 4.375 5.152344 4.570312 5.152344 C 4.769531 5.152344 4.9375 5.222656 5.082031 5.367188 L 7.429688 7.714844 L 13.207031 1.9375 C 13.347656 1.792969 13.519531 1.722656 13.714844 1.722656 C 13.910156 1.722656 14.082031 1.792969 14.222656 1.9375 L 15.207031 2.917969 C 15.347656 3.0625 15.417969 3.230469 15.417969 3.429688 C 15.417969 3.625 15.347656 3.792969 15.207031 3.9375 Z M 15.207031 3.9375 "/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16px" height="16px" viewBox="0 0 16 16" version="1.1" preserveAspectRatio="xMinYMid meet">
+<g id="surface1">
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(60%,60%,60%);fill-opacity:1;" d="M 11.714844 2.285156 L 4.285156 2.285156 C 3.894531 2.285156 3.554688 2.425781 3.277344 2.707031 C 2.996094 2.984375 2.855469 3.320312 2.855469 3.714844 L 2.855469 11.144531 C 2.855469 11.535156 2.996094 11.871094 3.277344 12.152344 C 3.554688 12.429688 3.894531 12.570312 4.285156 12.570312 L 11.714844 12.570312 C 12.105469 12.570312 12.445312 12.429688 12.722656 12.152344 C 13.003906 11.871094 13.144531 11.535156 13.144531 11.144531 L 13.144531 3.714844 C 13.144531 3.320312 13.003906 2.984375 12.722656 2.707031 C 12.445312 2.425781 12.105469 2.285156 11.714844 2.285156 Z M 14.285156 3.714844 L 14.285156 11.144531 C 14.285156 11.851562 14.035156 12.457031 13.53125 12.960938 C 13.027344 13.460938 12.421875 13.714844 11.714844 13.714844 L 4.285156 13.714844 C 3.578125 13.714844 2.972656 13.460938 2.46875 12.960938 C 1.964844 12.457031 1.714844 11.851562 1.714844 11.144531 L 1.714844 3.714844 C 1.714844 3.007812 1.964844 2.398438 2.46875 1.898438 C 2.972656 1.394531 3.578125 1.144531 4.285156 1.144531 L 11.714844 1.144531 C 12.421875 1.144531 13.027344 1.394531 13.53125 1.898438 C 14.035156 2.398438 14.285156 3.007812 14.285156 3.714844 Z M 14.285156 3.714844 "/>
+</g>
+</svg>
$returnurl = new moodle_url('/group/index.php', array('id'=>$id));
-$mform_post = new groups_import_form(null, array('id'=>$id));
+$importform = new groups_import_form(null, ['id' => $id]);
// If a file has been uploaded, then process it
-if ($mform_post->is_cancelled()) {
+if ($importform->is_cancelled()) {
redirect($returnurl);
-} else if ($mform_post->get_data()) {
+} else if ($formdata = $importform->get_data()) {
echo $OUTPUT->header();
- $csv_encode = '/\&\#44/';
- if (isset($CFG->CSV_DELIMITER)) {
- $csv_delimiter = $CFG->CSV_DELIMITER;
+ $text = $importform->get_file_content('userfile');
+ $text = preg_replace('!\r\n?!', "\n", $text);
- if (isset($CFG->CSV_ENCODE)) {
- $csv_encode = '/\&\#' . $CFG->CSV_ENCODE . '/';
- }
- } else {
- $csv_delimiter = ",";
+ $rawlines = explode("\n", $text);
+
+ require_once($CFG->libdir . '/csvlib.class.php');
+ $importid = csv_import_reader::get_new_iid('groupimport');
+ $csvimport = new csv_import_reader($importid, 'groupimport');
+ $delimiter = $formdata->delimiter_name;
+ $encoding = $formdata->encoding;
+ $readcount = $csvimport->load_csv_content($text, $encoding, $delimiter);
+
+ if ($readcount === false) {
+ print_error('csvfileerror', 'error', $PAGE->url, $csvimport->get_error());
+ } else if ($readcount == 0) {
+ print_error('csvemptyfile', 'error', $PAGE->url, $csvimport->get_error());
+ } else if ($readcount == 1) {
+ print_error('csvnodata', 'error', $PAGE->url);
}
- $text = $mform_post->get_file_content('userfile');
- $text = preg_replace('!\r\n?!',"\n",$text);
+ $csvimport->init();
- $rawlines = explode("\n", $text);
unset($text);
// make arrays of valid fields for error checking
);
// --- get header (field names) ---
- $header = explode($csv_delimiter, array_shift($rawlines));
+ $header = explode($csvimport::get_delimiter($delimiter), array_shift($rawlines));
// check for valid field names
foreach ($header as $i => $h) {
$h = trim($h); $header[$i] = $h; // remove whitespace
if (!(isset($required[$h]) or isset($optionalDefaults[$h]) or isset($optional[$h]))) {
- print_error('invalidfieldname', 'error', 'import.php?id='.$id, $h);
+ print_error('invalidfieldname', 'error', $PAGE->url, $h);
}
if (isset($required[$h])) {
$required[$h] = 2;
// check for required fields
foreach ($required as $key => $value) {
if ($value < 2) {
- print_error('fieldrequired', 'error', 'import.php?id='.$id, $key);
+ print_error('fieldrequired', 'error', $PAGE->url, $key);
}
}
$linenum = 2; // since header is line 1
- foreach ($rawlines as $rawline) {
+ while ($line = $csvimport->next()) {
$newgroup = new stdClass();//to make Martin happy
foreach ($optionalDefaults as $key => $value) {
$newgroup->$key = current_language(); //defaults to current language
}
- //Note: commas within a field should be encoded as , (for comma separated csv files)
- //Note: semicolon within a field should be encoded as ; (for semicolon separated csv files)
- $line = explode($csv_delimiter, $rawline);
foreach ($line as $key => $value) {
- //decode encoded commas
- $record[$header[$key]] = preg_replace($csv_encode, $csv_delimiter, trim($value));
+ $record[$header[$key]] = trim($value);
}
- if (trim($rawline) !== '') {
+ if (trim(implode($line)) !== '') {
// add a new group to the database
// add fields to object $user
foreach ($record as $name => $value) {
// check for required values
if (isset($required[$name]) and !$value) {
- print_error('missingfield', 'error', 'import.php?id='.$id, $name);
+ print_error('missingfield', 'error', $PAGE->url, $name);
} else if ($name == "groupname") {
$newgroup->name = $value;
} else {
}
}
+ $csvimport->close();
echo $OUTPUT->single_button($returnurl, get_string('continue'), 'get');
echo $OUTPUT->footer();
die;
/// Print the form
echo $OUTPUT->header();
echo $OUTPUT->heading_with_help($strimportgroups, 'importgroups', 'core_group');
-$mform_post ->display();
+$importform->display();
echo $OUTPUT->footer();
}
require_once($CFG->libdir.'/formslib.php');
+require_once($CFG->libdir . '/csvlib.class.php');
/**
* Groups import form class
$mform->addElement('hidden', 'id');
$mform->setType('id', PARAM_INT);
+ $choices = csv_import_reader::get_delimiter_list();
+ $mform->addElement('select', 'delimiter_name', get_string('csvdelimiter', 'group'), $choices);
+ if (array_key_exists('cfg', $choices)) {
+ $mform->setDefault('delimiter_name', 'cfg');
+ } else if (get_string('listsep', 'langconfig') == ';') {
+ $mform->setDefault('delimiter_name', 'semicolon');
+ } else {
+ $mform->setDefault('delimiter_name', 'comma');
+ }
+
+ $choices = core_text::get_encodings();
+ $mform->addElement('select', 'encoding', get_string('encoding', 'group'), $choices);
+ $mform->setDefault('encoding', 'UTF-8');
$this->add_action_buttons(true, get_string('importgroups', 'core_group'));
$this->set_data($data);
unset($library->major_version);
$library->minorVersion = (int) $library->minorversion;
unset($library->minorversion);
+ $library->metadataSettings = json_decode($library->metadatasettings);
// If we already add this library means that it is an old version,as the previous query was sorted by version.
if (isset($added[$library->name])) {
if ($libraries !== null) {
// Get details for the specified libraries.
$librariesin = [];
- $fields = 'title, runnable';
+ $fields = 'title, runnable, metadatasettings';
foreach ($libraries as $library) {
$params = [
if ($details) {
$library->title = $details->title;
$library->runnable = $details->runnable;
+ $library->metadataSettings = json_decode($details->metadatasettings);
$librariesin[] = $library;
}
}
} else {
- $fields = 'id, machinename as name, title, majorversion, minorversion';
+ $fields = 'id, machinename as name, title, majorversion, minorversion, metadatasettings';
$librariesin = api::get_contenttype_libraries($fields);
}
* - dropLibraryCss(optional): list of associative arrays containing:
* - machineName: machine name for the librarys that are to drop their css
* - semantics(optional): Json describing the content structure for the library
+ * - metadataSettings(optional): object containing:
+ * - disable: 1 if metadata is disabled completely
+ * - disableExtraTitleField: 1 if the title field is hidden in the form
* @param bool $new Whether it is a new or existing library.
*/
public function saveLibraryData(&$librarydata, $new = true) {
'addto' => isset($librarydata['addTo']) ? json_encode($librarydata['addTo']) : null,
'coremajor' => isset($librarydata['coreApi']['majorVersion']) ? $librarydata['coreApi']['majorVersion'] : null,
'coreminor' => isset($librarydata['coreApi']['majorVersion']) ? $librarydata['coreApi']['minorVersion'] : null,
+ 'metadatasettings' => isset($librarydata['metadataSettings']) ? $librarydata['metadataSettings'] : null,
);
if ($new) {
'addto' => '/regex11/',
'coremajor' => null,
'coreminor' => null,
+ 'metadatasettings' => null,
];
$this->assertEquals($expected, $data);
Mesedez, erabili --help aukera.';
$string['cliyesnoprompt'] = 'idatzi b (bai esateko) edo e (ez esateko)';
$string['environmentrequireinstall'] = 'derrigorrezkoa da instalatuta eta gaituta izatea';
-$string['environmentrequireversion'] = '{$a->needed} bertsioa beharrezkoa da eta zu {$a->current} ari zara egikaritzen';
+$string['environmentrequireversion'] = '{$a->needed} bertsioa beharrezkoa da eta zu {$a->current} ari zara exekutatzen';
$string['upgradekeyset'] = 'Eguneraketa-kodea (utzi hutsik kodea erabili nahi ez baduzu)';
--- /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/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package installer
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['thislanguage'] = 'Qaraqalpaq tili';
--- /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/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package installer
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['language'] = 'زبان';
+$string['moodlelogo'] = 'لوگوی مودل (Moodle)';
+$string['next'] = 'بعدی';
+$string['previous'] = 'قبلی';
+$string['reload'] = 'بارگیری مجدد';
$string['error:invalidexpireperiod'] = 'Expiry period cannot be negative or equal 0.';
$string['error:invalidparambadge'] = 'Badge does not exist. ';
$string['error:noactivities'] = 'There are no activities with completion criteria enabled in this course.';
-$string['error:nobadges'] = 'There are no course or site badges with access enabled to be added as criteria.';
+$string['error:nobadges'] = 'There are currently no badges with access enabled to be added as criteria. A site badge can only have other site badges as criteria. A course badge can have other course badges or site badges as criteria.';
$string['error:invalidparamcohort'] = 'Cohort does not exist. ';
$string['error:noactivities'] = 'There are no activities with completion criteria enabled in this course.';
$string['error:nocohorts'] = 'No cohorts';
$string['customexport'] = 'Custom range ({$a->timestart} - {$a->timeend})';
$string['daily'] = 'Daily';
$string['day'] = 'Day';
+$string['dayeventsmany'] = '{$a->num} events, {$a->day}';
+$string['dayeventsnone'] = 'No events, {$a}';
+$string['dayeventsone'] = '1 event, {$a}';
$string['daynext'] = 'Next day';
$string['dayprev'] = 'Previous day';
$string['dayviewfor'] = 'Day view for:';
$string['contentsmoved'] = 'Content bank contents moved to {$a}.';
$string['contenttypenoaccess'] = 'You cannot view this {$a} instance.';
$string['contenttypenoedit'] = 'You can not edit this content';
+$string['emptynamenotallowed'] = 'Empty name is not allowed';
$string['eventcontentcreated'] = 'Content created';
$string['eventcontentdeleted'] = 'Content deleted';
$string['eventcontentupdated'] = 'Content updated';
$string['eventcontentuploaded'] = 'Content uploaded';
$string['eventcontentviewed'] = 'Content viewed';
$string['errordeletingcontentfromcategory'] = 'Error deleting content from category {$a}.';
+$string['errornofile'] = 'A compatible file is needed to create content.';
$string['deletecontent'] = 'Delete content';
$string['deletecontentconfirm'] = 'Are you sure you want to delete the content <em>\'{$a->name}\'</em> and all associated files? This action cannot be undone.';
$string['displaydetails'] = 'Display content bank with file details';
$string['courserequestdisabled'] = 'Sorry, but course requests have been disabled by the administrator.';
$string['csvcolumnduplicates'] = 'Duplicate columns detected';
$string['csvemptyfile'] = 'The CSV file is empty';
+$string['csvfileerror'] = 'There is something wrong with the format of the CSV file. Please check the number of headings and columns match, and that the delimiter and file encoding are correct: {$a}';
$string['csvfewcolumns'] = 'Not enough columns, please verify the delimiter setting';
$string['csvinvalidcols'] = '<b>Invalid CSV file:</b> First line must include "Header Fields" and the file must be type of <br />"Expanded Fields/Comma Separated"<br />or<br /> "Expanded Fields with CAVV Result Code/Comma Separated"';
$string['csvinvalidcolsnum'] = 'Invalid CSV file - each line must include 49 or 70 fields';
$string['csvloaderror'] = 'An error occurred while loading the CSV file: {$a}';
+$string['csvnodata'] = 'Invalid CSV file - The CSV file has headers but does not contain any data.';
$string['csvweirdcolumns'] = 'Invalid CSV file format - number of columns is not constant!';
$string['dbconnectionfailed'] = '<p>Error: Database connection failed</p>
<p>It is possible that the database is overloaded or otherwise not running properly.</p>
$string['destinationcmnotexit'] = 'The destination course module does not exist';
$string['detectedbrokenplugin'] = 'Plugin "{$a}" is defective or outdated, can not continue, sorry.';
$string['dmlexceptiononinstall'] = '<p>A database error has occurred [{$a->errorcode}].<br />{$a->debuginfo}</p>';
+$string['dmlparseexception'] = 'Error parsing SQL query';
$string['dmlreadexception'] = 'Error reading from database';
$string['dmltransactionexception'] = 'Database transaction error';
$string['dmlwriteexception'] = 'Error writing to database';
$string['creategroupinselectedgrouping'] = 'Create group in grouping';
$string['createingrouping'] = 'Grouping of auto-created groups';
$string['createorphangroup'] = 'Create orphan group';
+$string['csvdelimiter'] = 'CSV delimiter';
$string['databaseupgradegroups'] = 'Groups version is now {$a}';
$string['defaultgrouping'] = 'Default grouping';
$string['defaultgroupingname'] = 'Grouping';
$string['editusersgroupsa'] = 'Edit groups for "{$a}"';
$string['enablemessaging'] = 'Group messaging';
$string['enablemessaging_help'] = 'If enabled, group members can send messages to the others in their group via the messaging drawer.';
+$string['encoding'] = 'Encoding';
$string['enrolmentkey'] = 'Enrolment key';
$string['enrolmentkey_help'] = 'An enrolment key enables access to the course to be restricted to only those who know the key. If a group enrolment key is specified, then not only will entering that key let the user into the course, but it will also automatically make them a member of this group.
$string['group:html_video'] = 'Video files natively supported by browsers';
$string['group:image'] = 'Image files';
$string['group:media_source'] = 'Streaming media';
+$string['group:optimised_image'] = 'Image files to be optimised, such as badges';
$string['group:presentation'] = 'Presentation files';
$string['group:sourcecode'] = 'Source code';
$string['group:spreadsheet'] = 'Spreadsheet files';
return $('[name="' + name + '"],[name="' + name + '[]"]');
}
- /**
- * Find the name of the given element
- * @param {EventTarget} el
- * @returns {String}
- */
- function getElementName(el) {
- return $(el).attr('name').replace(/\[]/, '');
- }
-
/**
* Check to see whether a particular condition is met
* @param {*|jQuery|HTMLElement} $dependon
}
/**
- * Show / hide the elements that depend on the element(s) with the given name
- * OR (if no dependonname given) the element(s) with the same name as the element that
- * triggered the event e.
- * @param {Event} e
- * @param {String} dependonname (optional)
+ * Show / hide the elements that depend on some elements.
*/
- function updateDependencies(e, dependonname) {
- dependonname = dependonname || getElementName(e.currentTarget);
- var $dependon = getElementsByName(dependonname);
- if (!dependencies.hasOwnProperty(dependonname)) {
- return;
- }
- // Process all dependency conditions related to the updated element.
+ function updateDependencies() {
+ // Process all dependency conditions.
var toHide = {};
- $.each(dependencies[dependonname], function(condition, values) {
- $.each(values, function(value, elements) {
- var hide = checkDependency($dependon, condition, value);
- $.each(elements, function(idx, elToHide) {
- if (toHide.hasOwnProperty(elToHide)) {
- toHide[elToHide] = toHide[elToHide] || hide;
- } else {
- toHide[elToHide] = hide;
- }
+ $.each(dependencies, function(dependonname) {
+ var dependon = getElementsByName(dependonname);
+ $.each(dependencies[dependonname], function(condition, values) {
+ $.each(values, function(value, elements) {
+ var hide = checkDependency(dependon, condition, value);
+ $.each(elements, function(idx, elToHide) {
+ if (toHide.hasOwnProperty(elToHide)) {
+ toHide[elToHide] = toHide[elToHide] || hide;
+ } else {
+ toHide[elToHide] = hide;
+ }
+ });
});
});
});
var $el = getElementsByName(depname);
if ($el.length) {
$el.on('change', updateDependencies);
- updateDependencies(null, depname);
}
});
+ updateDependencies();
}
/**
* Class containing utility methods for dataformats
*
* @package core
- * @copyright 2020 Moodle Pty Ltd <support@moodle.com>
- * @author 2020 Paul Holden <paulh@moodle.com>
+ * @copyright 2020 Paul Holden <paulh@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- * @license Moodle Workplace License, distribution is restricted, contact support@moodle.com
*/
namespace core;
* Dataformat utility class
*
* @package core
- * @copyright 2020 Moodle Pty Ltd <support@moodle.com>
- * @author 2020 Paul Holden <paulh@moodle.com>
+ * @copyright 2020 Paul Holden <paulh@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- * @license Moodle Workplace License, distribution is restricted, contact support@moodle.com
*/
class dataformat {
/**
- * Return an instead of a dataformat writer from given dataformat type
+ * Return an instance of a dataformat writer from given dataformat type
*
* @param string $dataformat
* @return dataformat\base
'groups' => array('spreadsheet')),
'gslides' => array('type' => 'application/vnd.google-apps.presentation', 'icon' => 'powerpoint',
'groups' => array('presentation')),
- 'gif' => array('type' => 'image/gif', 'icon' => 'gif', 'groups' => array('image', 'web_image'), 'string' => 'image'),
+ 'gif' => array('type' => 'image/gif', 'icon' => 'gif', 'groups' => array('image', 'web_image', 'optimised_image'),
+ 'string' => 'image'),
'gtar' => array('type' => 'application/x-gtar', 'icon' => 'archive',
'groups' => array('archive'), 'string' => 'archive'),
'tgz' => array('type' => 'application/g-zip', 'icon' => 'archive', 'groups' => array('archive'), 'string' => 'archive'),
'jmt' => array('type' => 'text/xml', 'icon' => 'markup'),
'jmx' => array('type' => 'text/xml', 'icon' => 'markup'),
'jnlp' => array('type' => 'application/x-java-jnlp-file', 'icon' => 'markup'),
- 'jpe' => array('type' => 'image/jpeg', 'icon' => 'jpeg', 'groups' => array('image', 'web_image'), 'string' => 'image'),
- 'jpeg' => array('type' => 'image/jpeg', 'icon' => 'jpeg', 'groups' => array('image', 'web_image'), 'string' => 'image'),
- 'jpg' => array('type' => 'image/jpeg', 'icon' => 'jpeg', 'groups' => array('image', 'web_image'), 'string' => 'image'),
+ 'jpe' => array('type' => 'image/jpeg', 'icon' => 'jpeg', 'groups' => array('image', 'web_image', 'optimised_image'),
+ 'string' => 'image'),
+ 'jpeg' => array('type' => 'image/jpeg', 'icon' => 'jpeg', 'groups' => array('image', 'web_image', 'optimised_image'),
+ 'string' => 'image'),
+ 'jpg' => array('type' => 'image/jpeg', 'icon' => 'jpeg', 'groups' => array('image', 'web_image', 'optimised_image'),
+ 'string' => 'image'),
'jqz' => array('type' => 'text/xml', 'icon' => 'markup'),
'js' => array('type' => 'application/x-javascript', 'icon' => 'text', 'groups' => array('web_file')),
'json' => array('type' => 'application/json', 'icon' => 'text'),
'php' => array('type' => 'text/plain', 'icon' => 'sourcecode'),
'pic' => array('type' => 'image/pict', 'icon' => 'image', 'groups' => array('image'), 'string' => 'image'),
'pict' => array('type' => 'image/pict', 'icon' => 'image', 'groups' => array('image'), 'string' => 'image'),
- 'png' => array('type' => 'image/png', 'icon' => 'png', 'groups' => array('image', 'web_image'), 'string' => 'image'),
+ 'png' => array('type' => 'image/png', 'icon' => 'png', 'groups' => array('image', 'web_image', 'optimised_image'),
+ 'string' => 'image'),
'pps' => array('type' => 'application/vnd.ms-powerpoint', 'icon' => 'powerpoint', 'groups' => array('presentation')),
'ppt' => array('type' => 'application/vnd.ms-powerpoint', 'icon' => 'powerpoint', 'groups' => array('presentation')),
'pptx' => array('type' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
public function execute() {
global $DB;
- $timenow = time();
-
- // Delete old backup_controllers and logs.
$loglifetime = get_config('backup', 'loglifetime');
- if (!empty($loglifetime)) { // Value in days.
- $loglifetime = $timenow - ($loglifetime * 3600 * 24);
- // Delete child records from backup_logs.
- $DB->execute("DELETE FROM {backup_logs}
- WHERE EXISTS (
- SELECT 'x'
- FROM {backup_controllers} bc
- WHERE bc.backupid = {backup_logs}.backupid
- AND bc.timecreated < ?)", array($loglifetime));
- // Delete records from backup_controllers.
- $DB->execute("DELETE FROM {backup_controllers}
- WHERE timecreated < ?", array($loglifetime));
+
+ if (empty($loglifetime)) {
+ throw new coding_exception('The \'loglifetime\' config is not set. Can\'t proceed and delete old backup records.');
}
+ // First, get the list of all backupids older than loglifetime.
+ $timecreated = time() - ($loglifetime * DAYSECS);
+ $records = $DB->get_records_select('backup_controllers', 'timecreated < ?', array($timecreated), 'id', 'id, backupid');
+
+ foreach ($records as $record) {
+ // Check if there is no incomplete adhoc task relying on the given backupid.
+ $params = array('%' . $record->backupid . '%');
+ $select = $DB->sql_like('customdata', '?', false);
+ $count = $DB->count_records_select('task_adhoc', $select, $params);
+ if ($count === 0) {
+ // Looks like there is no adhoc task, so we can delete logs and controllers for this backupid.
+ $DB->delete_records('backup_logs', array('backupid' => $record->backupid));
+ $DB->delete_records('backup_controllers', array('backupid' => $record->backupid));
+ }
+ }
}
}
<?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20200504" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20200804" COMMENT="XMLDB file for core Moodle tables"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
>
<FIELD NAME="addto" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Plugin configuration data"/>
<FIELD NAME="coremajor" TYPE="int" LENGTH="4" NOTNULL="false" SEQUENCE="false" COMMENT="H5P core API major version required"/>
<FIELD NAME="coreminor" TYPE="int" LENGTH="4" NOTNULL="false" SEQUENCE="false" COMMENT="H5P core API minor version required"/>
+ <FIELD NAME="metadatasettings" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Library metadata settings"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
</INDEXES>
</TABLE>
</TABLES>
-</XMLDB>
\ No newline at end of file
+</XMLDB>
$DB->delete_records('competency_userevidencecomp', ['userevidenceid' => $userevidence->id]);
$DB->delete_records('competency_userevidence', ['id' => $userevidence->id]);
- $context = context_user::instance($userevidence->userid);
- $fs->delete_area_files($context->id, 'core_competency', 'userevidence', $userevidence->id);
+ if ($record = $DB->get_record('context', ['contextlevel' => CONTEXT_USER, 'instanceid' => $userevidence->userid],
+ '*', IGNORE_MISSING)) {
+ // Delete all orphaned user evidences files.
+ $fs->delete_area_files($record->id, 'core_competency', 'userevidence', $userevidence->userid);
+ }
}
$sql = "SELECT cp.id
upgrade_main_savepoint(true, 2020061501.04);
}
+ if ($oldversion < 2020061501.09) {
+ // Delete all user evidence files from users that have been deleted.
+ $sql = "SELECT DISTINCT f.*
+ FROM {files} f
+ LEFT JOIN {context} c ON f.contextid = c.id
+ WHERE f.component = :component
+ AND f.filearea = :filearea
+ AND c.id IS NULL";
+ $stalefiles = $DB->get_records_sql($sql, ['component' => 'core_competency', 'filearea' => 'userevidence']);
+
+ $fs = get_file_storage();
+ foreach ($stalefiles as $stalefile) {
+ $fs->get_file_instance($stalefile)->delete();
+ }
+
+ upgrade_main_savepoint(true, 2020061501.09);
+ }
+
+ if ($oldversion < 2020061501.11) {
+
+ // Define field metadatasettings to be added to h5p_libraries.
+ $table = new xmldb_table('h5p_libraries');
+ $field = new xmldb_field('metadatasettings', XMLDB_TYPE_TEXT, null, null, null, null, null, 'coreminor');
+
+ // Conditionally launch add field metadatasettings.
+ if (!$dbman->field_exists($table, $field)) {
+ $dbman->add_field($table, $field);
+ }
+
+ // Get installed library files that have no metadata settings value.
+ $params = [
+ 'component' => 'core_h5p',
+ 'filearea' => 'libraries',
+ 'filename' => 'library.json',
+ ];
+ $sql = "SELECT l.id, f.id as fileid
+ FROM {files} f
+ LEFT JOIN {h5p_libraries} l ON f.itemid = l.id
+ WHERE f.component = :component
+ AND f.filearea = :filearea
+ AND f.filename = :filename";
+ $libraries = $DB->get_records_sql($sql, $params);
+
+ // Update metadatasettings field when the attribute is present in the library.json file.
+ $fs = get_file_storage();
+ foreach ($libraries as $library) {
+ $jsonfile = $fs->get_file_by_id($library->fileid);
+ $jsoncontent = json_decode($jsonfile->get_content());
+ if (isset($jsoncontent->metadataSettings)) {
+ unset($library->fileid);
+ $library->metadatasettings = json_encode($jsoncontent->metadataSettings);
+ $DB->update_record('h5p_libraries', $library);
+ }
+ }
+
+ // Main savepoint reached.
+ upgrade_main_savepoint(true, 2020061501.11);
+ }
+
return true;
}
/**
* Prepare the statement for execution
- * @throws dml_connection_exception
+ *
* @param string $sql
* @return resource
+ *
+ * @throws dml_exception
*/
protected function parse_query($sql) {
$stmt = oci_parse($this->oci, $sql);
if ($stmt == false) {
- throw new dml_connection_exception('Can not parse sql query'); //TODO: maybe add better info
+ throw new dml_exception('dmlparseexception', null, $this->get_last_error());
}
return $stmt;
}
*/
_redoHandler: function(e) {
e.preventDefault();
+
+ // Don't do anything if redo stack is empty.
+ if (this._redoStack.length === 0) {
+ return;
+ }
+
var html = this._getHTML(),
redo = this._getRedo();
* commonly used in moodle the following groups:
* - web_image - image that can be included as <img> in HTML
* - image - image that we can parse using GD to find it's dimensions, also used for portfolio format
+ * - optimised_image - image that will be processed and optimised
* - video - file that can be imported as video in text editor
* - audio - file that can be imported as audio in text editor
* - archive - we can extract files from this archive
// All these three files are in both "image" and also "web_image"
// groups. We display both groups.
$data = $util->data_for_browser('jpg png gif', true, '.gif');
- $this->assertEquals(2, count($data));
+ $this->assertEquals(3, count($data));
$this->assertTrue($data[0]->key !== $data[1]->key);
foreach ($data as $group) {
- $this->assertTrue(($group->key === 'image' || $group->key === 'web_image'));
+ $this->assertTrue(($group->key === 'image' || $group->key === 'web_image' || $group->key === 'optimised_image'));
$this->assertEquals(3, count($group->types));
$this->assertFalse($group->selectable);
foreach ($group->types as $ext) {
}
}
- // There is a group web_image which is a subset of the group image. The
- // file extensions that fall into both groups will be displayed twice.
+ // The groups web_image and optimised_image are a subset of the group image. The
+ // file extensions that fall into these groups will be displayed thrice.
$data = $util->data_for_browser('web_image');
foreach ($data as $group) {
- $this->assertTrue(($group->key === 'image' || $group->key === 'web_image'));
+ $this->assertTrue(($group->key === 'image' || $group->key === 'web_image' || $group->key === 'optimised_image'));
}
// Check that "All file types" are displayed first.
$course = $this->page->course;
if (\core\session\manager::is_loggedinas()) {
$realuser = \core\session\manager::get_realuser();
- $fullname = fullname($realuser, true);
+ $fullname = fullname($realuser);
if ($withlinks) {
$loginastitle = get_string('loginas');
$realuserinfo = " [<a href=\"$CFG->wwwroot/course/loginas.php?id=$course->id&sesskey=".sesskey()."\"";
} else if (isloggedin()) {
$context = context_course::instance($course->id);
- $fullname = fullname($USER, true);
+ $fullname = fullname($USER);
// Since Moodle 2.0 this link always goes to the public profile page (not the course profile page)
if ($withlinks) {
$linktitle = get_string('viewprofile');
tableRoot.dataset.tableLastInitial = lastInitial;
}
- if (pageNumber !== null) {
- if (tableRoot.dataset.tablePageNumber != pageNumber) {
- tableConfigChanged = true;
- }
-
- tableRoot.dataset.tablePageNumber = pageNumber;
- }
-
if (pageSize !== null) {
if (tableRoot.dataset.tablePageSize != pageSize) {
tableConfigChanged = true;
tableRoot.dataset.tableFilters = filterJson;
}
+ // Reset to page 1 when table content is being altered by filtering or sorting.
+ // This ensures the table page being loaded always exists, and gives a consistent experience.
+ if (tableConfigChanged) {
+ pageNumber = 1;
+ }
+
// Update hidden columns.
if (hiddenColumns) {
const columnJson = JSON.stringify(hiddenColumns);
tableRoot.dataset.tableHiddenColumns = columnJson;
}
+ if (pageNumber !== null) {
+ if (tableRoot.dataset.tablePageNumber != pageNumber) {
+ tableConfigChanged = true;
+ }
+
+ tableRoot.dataset.tablePageNumber = pageNumber;
+ }
+
// Refresh.
if (refreshContent && tableConfigChanged) {
return refreshTableContent(tableRoot)
* @param {HTMLElement} tableRoot
* @param {String} columnToHide
* @param {Bool} refreshContent
+ * @returns {Promise}
*/
export const hideColumn = (tableRoot, columnToHide, refreshContent = true) => {
const hiddenColumns = JSON.parse(tableRoot.dataset.tableHiddenColumns);
hiddenColumns.push(columnToHide);
- updateTable(tableRoot, {hiddenColumns}, refreshContent);
+ return updateTable(tableRoot, {hiddenColumns}, refreshContent);
};
/**
* @param {HTMLElement} tableRoot
* @param {String} columnToShow
* @param {Bool} refreshContent
+ * @returns {Promise}
*/
export const showColumn = (tableRoot, columnToShow, refreshContent = true) => {
let hiddenColumns = JSON.parse(tableRoot.dataset.tableHiddenColumns);
hiddenColumns = hiddenColumns.filter(columnName => columnName !== columnToShow);
- updateTable(tableRoot, {hiddenColumns}, refreshContent);
+ return updateTable(tableRoot, {hiddenColumns}, refreshContent);
};
/**
}
}
- // Now, update the column attributes for collapsed columns
- foreach (array_keys($this->columns) as $column) {
- if (!empty($this->prefs['collapse'][$column])) {
- $this->column_style[$column]['width'] = '10px';
- }
- }
+ $this->set_hide_show_preferences();
+ $this->set_sorting_preferences();
+ $this->set_initials_preferences();
- // Now, update the column attributes for collapsed columns
+ // Now, reduce the width of collapsed columns and remove the width from columns that should be expanded.
foreach (array_keys($this->columns) as $column) {
if (!empty($this->prefs['collapse'][$column])) {
$this->column_style[$column]['width'] = '10px';
+ } else {
+ unset($this->column_style[$column]['width']);
}
}
- $this->set_sorting_preferences();
- $this->set_initials_preferences();
-
if (empty($this->baseurl)) {
debugging('You should set baseurl when using flexible_table.');
global $PAGE;
<hr>
<div class="row">
<div class="col-sm-3">
- {{#action}}<h4><a href="{{action}}">{{text}}</a><h4>{{/action}}
- {{^action}}<h4>{{text}}<h4>{{/action}}
+ {{#action}}<h4><a href="{{action}}">{{text}}</a></h4>{{/action}}
+ {{^action}}<h4>{{text}}</h4>{{/action}}
</div>
<div class="col">
<ul class="list-unstyled">
<div class="container">
<div class="row">
<div class="col-sm-3">
- {{#action}}<h4><a href="{{action}}">{{text}}</a><h4>{{/action}}
- {{^action}}<h4>{{text}}<h4>{{/action}}
+ {{#action}}<h4><a href="{{action}}">{{text}}</a></h4>{{/action}}
+ {{^action}}<h4>{{text}}</h4>{{/action}}
</div>
<div class="col-sm-9">
<ul class="list-unstyled">
<hr>
<div class="row">
<div class="col-sm-3">
- {{#action}}<h4><a href="{{action}}">{{text}}</a><h4>{{/action}}
- {{^action}}<h4>{{text}}<h4>{{/action}}
+ {{#action}}<h4><a href="{{action}}">{{text}}</a></h4>{{/action}}
+ {{^action}}<h4>{{text}}</h4>{{/action}}
</div>
<div class="col-sm-9">
<ul class="list-unstyled">
global $CFG;
// Visit the Ionic URL and wait for it to load.
- $this->execute('behat_general::i_visit', [$url]);
+ $this->getSession()->visit($url);
$this->spin(
function($context, $args) {
$title = $context->getSession()->getPage()->find('xpath', '//title');
$this->assertContains('2:15 AM', core_text::strtoupper($userdate));
}
- public function test_reset_scheduled_tasks_for_component() {
- global $DB;
-
+ public function test_reset_scheduled_tasks_for_component_customised(): void {
$this->resetAfterTest(true);
- // Remember the defaults.
- $defaulttasks = \core\task\manager::load_scheduled_tasks_for_component('moodle');
- $initcount = count($defaulttasks);
+
+ $tasks = \core\task\manager::load_scheduled_tasks_for_component('moodle');
+
// Customise a task.
- $firsttask = reset($defaulttasks);
- $firsttask->set_minute('1');
- $firsttask->set_hour('2');
- $firsttask->set_month('3');
- $firsttask->set_day_of_week('4');
- $firsttask->set_day('5');
- $firsttask->set_customised('1');
- \core\task\manager::configure_scheduled_task($firsttask);
- $firsttaskrecord = \core\task\manager::record_from_scheduled_task($firsttask);
- // We reset this field, because we do not want to compare it.
- $firsttaskrecord->nextruntime = '0';
+ $task = reset($tasks);
+ $task->set_minute('1');
+ $task->set_hour('2');
+ $task->set_month('3');
+ $task->set_day_of_week('4');
+ $task->set_day('5');
+ $task->set_customised('1');
+ \core\task\manager::configure_scheduled_task($task);
+
+ // Now call reset.
+ \core\task\manager::reset_scheduled_tasks_for_component('moodle');
+
+ // Fetch the task again.
+ $taskafterreset = \core\task\manager::get_scheduled_task(get_class($task));
+
+ // The task should still be the same as the customised.
+ $this->assertTaskEquals($task, $taskafterreset);
+ }
+
+ public function test_reset_scheduled_tasks_for_component_deleted(): void {
+ global $DB;
+ $this->resetAfterTest(true);
// Delete a task to simulate the fact that its new.
- $secondtask = next($defaulttasks);
- $DB->delete_records('task_scheduled', array('classname' => '\\' . trim(get_class($secondtask), '\\')));
- $this->assertFalse(\core\task\manager::get_scheduled_task(get_class($secondtask)));
+ $tasklist = \core\task\manager::load_scheduled_tasks_for_component('moodle');
- // Edit a task to simulate a change in its definition (as if it was not customised).
- $thirdtask = next($defaulttasks);
- $thirdtask->set_minute('1');
- $thirdtask->set_hour('2');
- $thirdtask->set_month('3');
- $thirdtask->set_day_of_week('4');
- $thirdtask->set_day('5');
- $thirdtaskbefore = \core\task\manager::get_scheduled_task(get_class($thirdtask));
- $thirdtaskbefore->set_next_run_time(null); // Ignore this value when comparing.
- \core\task\manager::configure_scheduled_task($thirdtask);
- $thirdtask = \core\task\manager::get_scheduled_task(get_class($thirdtask));
- $thirdtask->set_next_run_time(null); // Ignore this value when comparing.
- $this->assertNotEquals($thirdtaskbefore, $thirdtask);
+ // Note: This test must use a task which does not use any random values.
+ $task = \core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class);
+
+ $DB->delete_records('task_scheduled', array('classname' => '\\' . trim(get_class($task), '\\')));
+ $this->assertFalse(\core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class));
// Now call reset on all the tasks.
\core\task\manager::reset_scheduled_tasks_for_component('moodle');
- // Load the tasks again.
- $defaulttasks = \core\task\manager::load_scheduled_tasks_for_component('moodle');
- $finalcount = count($defaulttasks);
- // Compare the first task.
- $newfirsttask = reset($defaulttasks);
- $newfirsttaskrecord = \core\task\manager::record_from_scheduled_task($newfirsttask);
- // We reset this field, because we do not want to compare it.
- $newfirsttaskrecord->nextruntime = '0';
+ // Assert that the second task was added back.
+ $taskafterreset = \core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class);
+ $this->assertNotFalse($taskafterreset);
- // Assert a customised task was not altered by reset.
- $this->assertEquals($firsttaskrecord, $newfirsttaskrecord);
+ $this->assertTaskEquals($task, $taskafterreset);
+ $this->assertCount(count($tasklist), \core\task\manager::load_scheduled_tasks_for_component('moodle'));
+ }
- // Assert that the second task was added back.
- $secondtaskafter = \core\task\manager::get_scheduled_task(get_class($secondtask));
- $secondtaskafter->set_next_run_time(null); // Do not compare the nextruntime.
- $secondtask->set_next_run_time(null);
- $this->assertEquals($secondtask, $secondtaskafter);
-
- // Assert that the third task edits were overridden.
- $thirdtaskafter = \core\task\manager::get_scheduled_task(get_class($thirdtask));
- $thirdtaskafter->set_next_run_time(null);
- $this->assertEquals($thirdtaskbefore, $thirdtaskafter);
-
- // Assert we have the same number of tasks.
- $this->assertEquals($initcount, $finalcount);
+ public function test_reset_scheduled_tasks_for_component_changed_in_source(): void {
+ $this->resetAfterTest(true);
+
+ // Delete a task to simulate the fact that its new.
+ // Note: This test must use a task which does not use any random values.
+ $task = \core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class);
+
+ // Get a copy of the task before maing changes for later comparison.
+ $taskbeforechange = \core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class);
+
+ // Edit a task to simulate a change in its definition (as if it was not customised).
+ $task->set_minute('1');
+ $task->set_hour('2');
+ $task->set_month('3');
+ $task->set_day_of_week('4');
+ $task->set_day('5');
+ \core\task\manager::configure_scheduled_task($task);
+
+ // Fetch the task out for comparison.
+ $taskafterchange = \core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class);
+
+ // The task should now be different to the original.
+ $this->assertTaskNotEquals($taskbeforechange, $taskafterchange);
+
+ // Now call reset.
+ \core\task\manager::reset_scheduled_tasks_for_component('moodle');
+
+ // Fetch the task again.
+ $taskafterreset = \core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class);
+
+ // The task should now be the same as the original.
+ $this->assertTaskEquals($taskbeforechange, $taskafterreset);
}
/**
$this->assertEquals(0, $task->get_fail_delay());
$this->assertLessThan($before + 70, $task->get_next_run_time());
}
+
+ /**
+ * Assert that the specified tasks are equal.
+ *
+ * @param \core\task\task_base $task
+ * @param \core\task\task_base $comparisontask
+ */
+ public function assertTaskEquals(\core\task\task_base $task, \core\task\task_base $comparisontask): void {
+ // Convert both to an object.
+ $task = \core\task\manager::record_from_scheduled_task($task);
+ $comparisontask = \core\task\manager::record_from_scheduled_task($comparisontask);
+
+ // Reset the nextruntime field as it is intentionally dynamic.
+ $task->nextruntime = null;
+ $comparisontask->nextruntime = null;
+
+ $args = array_merge(
+ [
+ $task,
+ $comparisontask,
+ ],
+ array_slice(func_get_args(), 2)
+ );
+
+ call_user_func_array([$this, 'assertEquals'], $args);
+ }
+
+ /**
+ * Assert that the specified tasks are not equal.
+ *
+ * @param \core\task\task_base $task
+ * @param \core\task\task_base $comparisontask
+ */
+ public function assertTaskNotEquals(\core\task\task_base $task, \core\task\task_base $comparisontask): void {
+ // Convert both to an object.
+ $task = \core\task\manager::record_from_scheduled_task($task);
+ $comparisontask = \core\task\manager::record_from_scheduled_task($comparisontask);
+
+ // Reset the nextruntime field as it is intentionally dynamic.
+ $task->nextruntime = null;
+ $comparisontask->nextruntime = null;
+
+ $args = array_merge(
+ [
+ $task,
+ $comparisontask,
+ ],
+ array_slice(func_get_args(), 2)
+ );
+
+ call_user_func_array([$this, 'assertNotEquals'], $args);
+ }
}
if (isset($CFG->config_php_settings['upgradekey'])) {
if ($upgradekeyhash === null or $upgradekeyhash !== sha1($CFG->config_php_settings['upgradekey'])) {
if (!$PAGE->headerprinted) {
+ $PAGE->set_title(get_string('upgradekeyreq', 'admin'));
$output = $PAGE->get_renderer('core', 'admin');
echo $output->upgradekey_form_page(new moodle_url('/admin/index.php', array('cache' => 0)));
die();
});
};
+ /**
+ * Create a plain version of an HTML text.
+ *
+ * This texts is used as a message preview while is sent to the server. This way
+ * it is possible to prevent self-xss.
+ *
+ * @param {String} text Text to send.
+ * @return {String} The plain text version of the text.
+ */
+ const previewText = function(text) {
+ // Remove all script and styles from text (we don't want it there).
+ let plaintext = text.replace(/<style([\s\S]*?)<\/style>/gi, '');
+ plaintext = plaintext.replace(/<script([\s\S]*?)<\/script>/gi, '');
+ // Beautify a bit the output adding some line breaks.
+ plaintext = plaintext.replace(/<\/div>/ig, '\n');
+ plaintext = plaintext.replace(/<\/li>/ig, '\n');
+ plaintext = plaintext.replace(/<li>/ig, ' * ');
+ plaintext = plaintext.replace(/<\/ul>/ig, '\n');
+ plaintext = plaintext.replace(/<\/p>/ig, '\n');
+ plaintext = plaintext.replace(/<br[^>]*>/gi, '\n');
+ // Remove all remaining tags and convert line breaks into html.
+ plaintext = plaintext.replace(/<[^>]+>/ig, '');
+ plaintext = plaintext.replace(/\n+/ig, '\n');
+ return plaintext.replace(/\n/ig, '<br>');
+ };
+
/**
* Buffers messages to be sent to the server. We use a buffer here to allow the
* user to freely input messages without blocking the interface for them.
*/
var sendMessage = function(text) {
var id = 'temp' + Date.now();
+ // Render a preview version of the message while sending.
+ let loadingmessage = {
+ id: id,
+ useridfrom: viewState.loggedInUserId,
+ text: previewText(text),
+ timecreated: null
+ };
+ var newState = StateManager.addMessages(viewState, [loadingmessage]);
+ render(newState);
+ // Send the real message.
var message = {
id: id,
useridfrom: viewState.loggedInUserId,
text: text,
timecreated: null
};
- var newState = StateManager.addMessages(viewState, [message]);
- render(newState);
sendMessageBuffer.push(message);
processSendMessageBuffer();
};
>
<div class="container-fluid">
<div class="row-fluid h-100 no-gutters">
- <div class="col-4 d-flex flex-column">
+ <div class="col-4 d-flex flex-column conversationcontainer">
<div class="border-right h-100">
<div class="panel-header-container" data-region="panel-header-container">
{{> core_message/message_drawer_view_overview_header }}
$annotations = page_editor::get_annotations($grade->id, $index, $draft);
$page->annotations = $annotations;
$response->pages[] = $page;
-
- $component = 'assignfeedback_editpdf';
- $filearea = document_services::PAGE_IMAGE_FILEAREA;
- $filepath = '/';
- $fs = get_file_storage();
- $files = $fs->get_directory_files($context->id, $component, $filearea, $grade->id, $filepath);
- $response->pageready = count($files);
}
+
+ $component = 'assignfeedback_editpdf';
+ $filearea = document_services::PAGE_IMAGE_FILEAREA;
+ $filepath = '/';
+ $fs = get_file_storage();
+ $files = $fs->get_directory_files($context->id, $component, $filearea, $grade->id, $filepath);
+ $response->pageready = count($files);
}
echo json_encode($response);
}
}
- if (empty($pages)) {
+ $totalpagesforattempt = self::page_number_for_attempt($assignment, $userid, $attemptnumber, false);
+ // Here we are comparing the total number of images against the total number of pages from the combined PDF.
+ if (empty($pages) || count($pages) != $totalpagesforattempt) {
if ($readonly) {
// This should never happen, there should be a version of the pages available
// whenever we are requesting the readonly version.
require_once($CFG->dirroot . '/mod/assign/submissionconfirmform.php');
// Check that all of the submission plugins are ready for this submission.
+ // Also check whether there is something to be submitted as well against atleast one.
$notifications = array();
$submission = $this->get_user_submission($USER->id, false);
+ if ($this->get_instance()->teamsubmission) {
+ $submission = $this->get_group_submission($USER->id, 0, false);
+ }
+
$plugins = $this->get_submission_plugins();
+ $hassubmission = false;
foreach ($plugins as $plugin) {
if ($plugin->is_enabled() && $plugin->is_visible()) {
$check = $plugin->precheck_submission($submission);
if ($check !== true) {
$notifications[] = $check;
}
+
+ if (is_object($submission) && !$plugin->is_empty($submission)) {
+ $hassubmission = true;
+ }
}
}
+ // If there are no submissions and no existing notifications to be displayed the stop.
+ if (!$hassubmission && !$notifications) {
+ $notifications[] = get_string('addsubmission_help', 'assign');
+ }
+
$data = new stdClass();
$adminconfig = $this->get_admin_config();
$requiresubmissionstatement = $this->get_instance()->requiresubmissionstatement;
$gradefordisplay = $this->display_grade($gradebookgrade->grade, false);
}
$gradeddate = $gradebookgrade->dategraded;
- if (isset($grade->grader) && $grade->grader > 0) {
- $grader = $DB->get_record('user', array('id' => $grade->grader));
- } else if (isset($gradebookgrade->usermodified) && $gradebookgrade->usermodified > 0) {
- $grader = $DB->get_record('user', array('id' => $gradebookgrade->usermodified));
+
+ // Only display the grader if it is in the right state.
+ if (in_array($gradingstatus, [ASSIGN_GRADING_STATUS_GRADED, ASSIGN_MARKING_WORKFLOW_STATE_RELEASED])){
+ if (isset($grade->grader) && $grade->grader > 0) {
+ $grader = $DB->get_record('user', array('id' => $grade->grader));
+ } else if (isset($gradebookgrade->usermodified)
+ && $gradebookgrade->usermodified > 0
+ && has_capability('mod/assign:grade', $this->get_context(), $gradebookgrade->usermodified)) {
+ // Grader not provided. Check that usermodified is a user who can grade.
+ // Case 1: When an assignment is reopened an empty assign_grade is created so the feedback
+ // plugin can know which attempt it's referring to. In this case, usermodifed is a student.
+ // Case 2: When an assignment's grade is overrided via the gradebook, usermodified is a grader
+ $grader = $DB->get_record('user', array('id' => $gradebookgrade->usermodified));
+ }
}
}
{{/duedate}}
</div>
-</span>
</div>
{{!
</div>
{{#js}}
require(['mod_assign/grading_navigation', 'core/tooltip'], function(GradingNavigation, ToolTip) {
- var nav = new GradingNavigation('[data-region="user-selector"]');
- var tooltip = new ToolTip('[data-region="assignment-tooltip"]');
+ new GradingNavigation('[data-region="user-selector"]');
+ new ToolTip('[data-region="assignment-tooltip"]');
});
{{/js}}
</small>
</span>
-<span data-region="configure-filters" id="filter-configuration-{{uniqid}}" class="card card-large p-2">
+<div data-region="configure-filters" id="filter-configuration-{{uniqid}}" class="card card-large p-2">
<form>
<span class="row px-3 py-1">
<label class="text-right w-25 p-2 m-0" for="filter-general-{{uniqid}}">
</span>
{{/hasmarkingworkflow}}
</form>
-</span>
+</div>
<a href="#" data-region="user-filters" title="{{#str}}changefilters, mod_assign{{/str}}" aria-expanded="false" aria-controls="filter-configuration-{{uniqid}}">
<span class="accesshide">
);
}
- $mform->addElement('text', 'title', get_string('chaptertitle', 'mod_book'), array('size'=>'30'));
+ $mform->addElement('text', 'title', get_string('chaptertitle', 'mod_book'),
+ ['size' => '30', 'maxlength' => '255']);
$mform->setType('title', PARAM_RAW);
$mform->addRule('title', null, 'required', null, 'client');
+ $mform->addRule('title', get_string('maximumchars', '', 255), 'maxlength', 255, 'client');
$mform->addElement('advcheckbox', 'subchapter', get_string('subchapter', 'mod_book'), $disabledmsg);
$action = optional_param('action', '', PARAM_ALPHANUM);
$beepid = optional_param('beep', '', PARAM_RAW);
$chatsid = required_param('chat_sid', PARAM_ALPHANUM);
-$theme = required_param('theme', PARAM_ALPHANUMEXT);
+$theme = required_param('chat_theme', PARAM_ALPHANUMEXT);
$chatmessage = optional_param('chat_message', '', PARAM_RAW);
$chatlasttime = optional_param('chat_lasttime', 0, PARAM_INT);
$chatlastrow = optional_param('chat_lastrow', 1, PARAM_INT);
$id = required_param('id', PARAM_INT);
$groupid = optional_param('groupid', 0, PARAM_INT); // Only for teachers.
-$theme = optional_param('theme', 'course_theme', PARAM_SAFEDIR); // The value course_theme == the current theme.
+$theme = optional_param('chat_theme', 'course_theme', PARAM_SAFEDIR); // The value course_theme == the current theme.
$url = new moodle_url('/mod/chat/gui_ajax/index.php', array('id' => $id));
if ($groupid !== 0) {
'<ul id="messages-list"></ul>', '', 'chat-messages');
$table = new html_table();
$table->data = array(
- array('<div class="form-inline"><label class="accesshide" for="input-message">'.get_string('entermessage', 'chat').' </label>'.
+ array('<div class="form-inline"><div class="d-flex"><label class="accesshide" for="input-message">'.
+ get_string('entermessage', 'chat').' </label>'.
'<span class="form-group"><input type="text" disabled="true" class="form-control" ' .
'id="input-message" value="Loading..." size="48" /></span>'.
'<span class="form-group"><input type="button" id="button-send" class="btn btn-secondary mx-1" ' .
- 'value="'.get_string('send', 'chat').'" /></span> <span class="form-group"><a id="choosetheme" href="###">'.
+ 'value="'.get_string('send', 'chat').'" />' .$OUTPUT->help_icon('usingchat', 'chat'). '</span></div>' .
+ ' <div class="form-group d-flex ml-auto"><a id="choosetheme" href="###">'.
get_string('themes').
- ' » </a></span></div>'));
+ ' » </a></div></div>'));
echo $OUTPUT->box(html_writer::tag('h2', get_string('composemessage', 'chat'), array('class' => 'accesshide')) .
html_writer::table($table), '', 'chat-input-area');
echo $OUTPUT->box('', '', 'chat-notify');
action : 'init',
chat_init : 1,
chat_sid : this.cfg.sid,
- theme : this.theme
+ chat_theme : this.cfg.theme
}),
on : {
success : function(tid, outcome) {
// Create and initalise theme changing menu.
this.thememenu = new Y.YUI2.widget.Menu('basicmenu', {xy:[0,0]});
this.thememenu.addItems([
- {text: M.util.get_string('bubble', 'mod_chat'), url: this.cfg.chaturl + '&theme=bubble'},
- {text: M.util.get_string('compact', 'mod_chat'), url: this.cfg.chaturl + '&theme=compact'},
- {text: M.util.get_string('coursetheme', 'mod_chat'), url: this.cfg.chaturl + '&theme=course_theme'}
+ {text: M.util.get_string('bubble', 'mod_chat'), url: this.cfg.chaturl + '&chat_theme=bubble'},
+ {text: M.util.get_string('compact', 'mod_chat'), url: this.cfg.chaturl + '&chat_theme=compact'},
+ {text: M.util.get_string('coursetheme', 'mod_chat'), url: this.cfg.chaturl + '&chat_theme=course_theme'}
]);
this.thememenu.render(document.body);
Y.one('#choosetheme').on('click', function(e) {
this.moveTo((e.pageX - 20), (e.pageY - 20));
this.show();
}, this.thememenu);
+
+ // Set the data-placement for the help-icon to display all the content.
+ this.helpicon = Y.one('#button-send + a');
+ this.dataset = this.helpicon.get('dataset');
+ this.dataset.placement = 'top';
+ this.helpicon.set('dataset', this.dataset);
},
append_message : function(key, message, row) {
var data = {
chat_message : (!beep) ? this.messageinput.get('value') : '',
chat_sid : this.cfg.sid,
- theme : this.cfg.theme
+ chat_theme : this.cfg.theme
};
if (beep) {
data.beep = beep
chat_lastrow : this.cfg.chat_lastrow || false,
chat_lasttime : this.cfg.chat_lasttime,
chat_sid : this.cfg.sid,
- theme : this.cfg.theme
+ chat_theme : this.cfg.theme
}),
on : {
success : this.update_messages_callback
border-width: 9px 0 9px 21px;
border-color: transparent #fff;
}
+
+#button-send + a > i.text-info {
+ /* stylelint-disable-next-line declaration-no-important */
+ color: black !important;
+}
background: #e3e8f0;
height: 100%;
}
+
+#button-send + a > i.text-info {
+ /* stylelint-disable-next-line declaration-no-important */
+ color: black !important;
+}
echo '<div id="send">';
echo '<form id="editing" method="post" action="index.php">';
-echo '<h2><label for="message">'.get_string('sendmessage', 'message').'</label></h2>';
+echo '<h2><label for="message">' . get_string('sendmessage', 'message');
+echo $OUTPUT->help_icon('usingchat', 'chat');
+echo '</label></h2>';
echo '<div class="mb-1">';
echo '<input type="text" id="message" class="form-control" name="message" value="'.s($refreshedmessage, true).'" size="60" />';
echo '</div><div class="mb-1">';
if (preg_match('/^val(\d+)$/', $column, $matches)) {
$items = $this->feedbackstructure->get_items();
$itemobj = feedback_get_item_class($items[$matches[1]]->typ);
- return trim($itemobj->get_printval($items[$matches[1]], (object) ['value' => $row->$column] ));
+ $printval = $itemobj->get_printval($items[$matches[1]], (object) ['value' => $row->$column]);
+ if ($this->is_downloading()) {
+ $printval = html_entity_decode($printval, ENT_QUOTES);
+ }
+ return trim($printval);
}
return $row->$column;
}
$DB->update_record($this->get_table_name(), $grade);
- // Update in the gradebook.
+ // Update in the gradebook (note that 'cmidnumber' is required in order to update grades).
$mapper = forum_container::get_legacy_data_mapper_factory()->get_forum_data_mapper();
- forum_update_grades($mapper->to_legacy_object($this->forum), $grade->userid);
+ $forumrecord = $mapper->to_legacy_object($this->forum);
+ $forumrecord->cmidnumber = $this->forum->get_course_module_record()->idnumber;
+
+ forum_update_grades($forumrecord, $grade->userid);
return true;
}
use mod_forum\local\factories\vault as vault_factory;
use mod_forum\local\managers\capability as capability_manager;
use mod_forum\local\renderers\posts as posts_renderer;
+use forum_portfolio_caller;
use core\output\notification;
use context;
use context_module;
use url_select;
require_once($CFG->dirroot . '/mod/forum/lib.php');
+require_once($CFG->dirroot . '/mod/forum/locallib.php');
/**
* Discussion renderer class.
'movediscussion' => null,
'pindiscussion' => null,
'neighbourlinks' => $this->get_neighbour_links_html(),
- 'exportdiscussion' => !empty($CFG->enableportfolios) ? $this->get_export_discussion_html() : null
+ 'exportdiscussion' => !empty($CFG->enableportfolios) ? $this->get_export_discussion_html($user) : null
]
]);
/**
* Get the HTML to render the export discussion button.
*
- * @return string|null
+ * @param stdClass $user The user viewing the discussion
+ * @return string|null
*/
- private function get_export_discussion_html() : ?string {
+ private function get_export_discussion_html(stdClass $user) : ?string {
global $CFG;
- require_once($CFG->libdir . '/portfoliolib.php');
- $discussion = $this->discussion;
+ if (!$this->capabilitymanager->can_export_discussions($user)) {
+ return null;
+ }
+
$button = new \portfolio_add_button();
- $button->set_callback_options('forum_portfolio_caller', ['discussionid' => $discussion->get_id()], 'mod_forum');
+ $button->set_callback_options('forum_portfolio_caller', ['discussionid' => $this->discussion->get_id()], 'mod_forum');
$button = $button->to_html(PORTFOLIO_ADD_FULL_FORM, get_string('exportdiscussion', 'mod_forum'));
return $button ?: null;
}
defined('MOODLE_INTERNAL') || die();
+require_once($CFG->dirroot . '/grade/grading/lib.php');
+
/**
* Implementation of the privacy subsystem plugin provider for the forum activity module.
*
protected static function export_all_posts(int $userid, array $mappings) {
global $DB;
- // Find all of the posts, and post subscriptions for this forum.
- list($foruminsql, $forumparams) = $DB->get_in_or_equal(array_keys($mappings), SQL_PARAMS_NAMED);
- $ratingsql = \core_rating\privacy\provider::get_sql_join('rat', 'mod_forum', 'post', 'p.id', $userid);
- $sql = "SELECT
- p.discussion AS id,
- f.id AS forumid,
- d.name,
- d.groupid
- FROM {forum} f
- JOIN {forum_discussions} d ON d.forum = f.id
- JOIN {forum_posts} p ON p.discussion = d.id
- LEFT JOIN {forum_read} fr ON fr.postid = p.id AND fr.userid = :readuserid
- {$ratingsql->join}
- WHERE f.id ${foruminsql} AND
- (
- p.userid = :postuserid OR
- p.privatereplyto = :privatereplyrecipient OR
- fr.id IS NOT NULL OR
- {$ratingsql->userwhere}
- )
- GROUP BY f.id, p.discussion, d.name, d.groupid
- ";
+ $commonsql = "SELECT p.discussion AS id, f.id AS forumid, d.name, d.groupid
+ FROM {forum} f
+ JOIN {forum_discussions} d ON d.forum = f.id
+ JOIN {forum_posts} p ON p.discussion = d.id";
+
+ // All discussions with posts authored by the user or containing private replies to the user.
+ list($foruminsql1, $forumparams1) = $DB->get_in_or_equal(array_keys($mappings), SQL_PARAMS_NAMED);
+ $sql1 = "{$commonsql}
+ WHERE f.id {$foruminsql1}
+ AND (p.userid = :postuserid OR p.privatereplyto = :privatereplyrecipient)";
+
+ // All discussions with the posts marked as read by the user.
+ list($foruminsql2, $forumparams2) = $DB->get_in_or_equal(array_keys($mappings), SQL_PARAMS_NAMED);
+ $sql2 = "{$commonsql}
+ JOIN {forum_read} fr ON fr.postid = p.id
+ WHERE f.id {$foruminsql2}
+ AND fr.userid = :readuserid";
+
+ // All discussions with ratings provided by the user.
+ list($foruminsql3, $forumparams3) = $DB->get_in_or_equal(array_keys($mappings), SQL_PARAMS_NAMED);
+ $ratingsql = \core_rating\privacy\provider::get_sql_join('rat', 'mod_forum', 'post', 'p.id', $userid, true);
+ $sql3 = "{$commonsql}
+ {$ratingsql->join}
+ WHERE f.id {$foruminsql3}
+ AND {$ratingsql->userwhere}";
+
+ $sql = "SELECT *
+ FROM ({$sql1} UNION {$sql2} UNION {$sql3}) united
+ GROUP BY id, forumid, name, groupid";
$params = [
- 'postuserid' => $userid,
- 'readuserid' => $userid,
+ 'postuserid' => $userid,
+ 'readuserid' => $userid,
'privatereplyrecipient' => $userid,
];
- $params += $forumparams;
+ $params += $forumparams1;
+ $params += $forumparams2;
+ $params += $forumparams3;
$params += $ratingsql->params;
$discussions = $DB->get_records_sql($sql, $params);
$this->log("Adding post {$post->id} in format {$maildigest} without HTML", 2);
}
- if ($maildigest == 1 && $CFG->forum_usermarksread) {
+ if ($maildigest == 1 && !$CFG->forum_usermarksread) {
// Create an array of postid's for this user to mark as read.
$this->markpostsasread[] = $post->id;
}
<?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="mod/forum/db" VERSION="20191001" COMMENT="XMLDB file for Moodle mod/forum"
+<XMLDB PATH="mod/forum/db" VERSION="20200508" COMMENT="XMLDB file for Moodle mod/forum"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
>
<INDEX NAME="userid" UNIQUE="false" FIELDS="userid"/>
<INDEX NAME="created" UNIQUE="false" FIELDS="created"/>
<INDEX NAME="mailed" UNIQUE="false" FIELDS="mailed"/>
+ <INDEX NAME="privatereplyto" UNIQUE="false" FIELDS="privatereplyto" COMMENT="The field is used in certain queries (such as privacy requests) to search for private replies to the user."/>
</INDEXES>
</TABLE>
<TABLE NAME="forum_queue" COMMENT="For keeping track of posts that will be mailed in digest form">
// Automatically generated Moodle v3.9.0 release upgrade line.
// Put any upgrade step following this.
+ if ($oldversion < 2020061501) {
+ // Add index privatereplyto (not unique) to the forum_posts table.
+ $table = new xmldb_table('forum_posts');
+ $index = new xmldb_index('privatereplyto', XMLDB_INDEX_NOTUNIQUE, ['privatereplyto']);
+
+ if (!$dbman->index_exists($table, $index)) {
+ $dbman->add_index($table, $index);
+ }
+
+ upgrade_mod_savepoint(true, 2020061501, 'forum');
+ }
+
return true;
}
function forum_update_grades($forum, $userid = 0): void {
global $CFG, $DB;
require_once($CFG->libdir.'/gradelib.php');
- $cm = get_coursemodule_from_instance('forum', $forum->id);
- $forum->cmidnumber = $cm->idnumber;
$ratings = null;
if ($forum->assessed) {
require_once($CFG->dirroot.'/rating/lib.php');
+ $cm = get_coursemodule_from_instance('forum', $forum->id);
+
$rm = new rating_manager();
$ratings = $rm->get_user_grades((object) [
'component' => 'mod_forum',
{{> mod_forum/local/grades/local/grader/user_picker_placeholder }}
</div>
</div>
- <div class="body-container position-relative d-flex flex-column overflow-auto" data-region="body-container">
- <div class="grader-grading-panel-display pt-3 overflow-auto" data-region="grading-panel-container">
+ <div class="body-container position-relative d-flex flex-column" data-region="body-container">
+ <div class="grader-grading-panel-display pt-3" data-region="grading-panel-container">
<h4 class="mb-0 h5 px-3 font-weight-normal">
{{#pix}} i/grading, core {{/pix}}{{#str}} gradingmodulename, core_grades, {{moduleName}} {{/str}}
</h4>
"moduleName": "Chef the Forum"
}
}}
-<nav id="nav-container-{{uniqid}}" class="grader-grading_navigation navbar" aria-label="{{#str}} forumgradingnavigation, mod_forum {{/str}}">
+<nav id="nav-container-{{uniqid}}" class="grader-grading_navigation navbar px-0 px-sm-3" aria-label="{{#str}} forumgradingnavigation, mod_forum {{/str}}">
<div class="d-none d-sm-flex align-items-center">
<a href="{{{courseUrl}}}" class="btn btn-link px-2 colour-inherit">
<h5 class="d-inline px-0 mb-0">{{courseName}}</h5>
<h5 class="d-inline px-2 mb-0 font-weight-bold">{{#str}}grading, forum{{/str}}</h5>
</div>
- <div class="ml-auto">
+ <div class="ml-1 ml-sm-auto">
<button
class="btn btn-icon icon-no-margin drawer-button mr-1 active"
data-action="expand-grading-drawer"
<span class="dir-rtl-hide" aria-hidden="true">{{#pix}} show-grader-panel, mod_forum, {{#str}} showgraderpanel, mod_forum {{/str}} {{/pix}}</span>
</button>
<button
- class="btn btn-icon icon-no-margin drawer-button mr-1"
+ class="btn btn-icon icon-no-margin drawer-button mr-1 d-none d-sm-inline-block"
aria-label="{{#str}} hidegraderpanel, mod_forum {{/str}}"
data-action="collapse-grading-drawer"
aria-controls="grading-drawer-{{uniqid}}"
<span aria-hidden="true">{{#pix}} hide-grader-panel, mod_forum, {{#str}} hidegraderpanel, mod_forum {{/str}} {{/pix}}</span>
</button>
<button
- class="btn btn-primary font-weight-bold ml-2 px-4"
+ class="btn btn-primary font-weight-bold ml-sm-2 px-4"
data-action="savegrade"
>
{{#str}} save {{/str}}
</button>
<button
- class="btn btn-secondary font-weight-bold ml-2 px-4"
+ class="btn btn-secondary font-weight-bold ml-1 ml-sm-2 px-4"
aria-label="{{#str}} closegrader, mod_forum {{/str}}"
data-action="closegrader"
type="button"
>
{{#str}} close, mod_forum {{/str}}
</button>
- <div class="btn-group">
- <button
+ <div class="btn-group d-none d-sm-inline-block">
+ <button
class="btn btn-icon text-muted icon-no-margin icon-size-3 ml-2"
type="button"
id="grader-actions-menu-{{uniqid}}"
<div class="d-flex align-items-center user-picker-container mb-2 py-2">
<div aria-live="polite" data-region="user_picker/current_user" class="sr-only"></div>
- <div class="d-flex align-items-center" data-region="user_picker/user"></div>
+ <div class="d-flex align-items-center overflow-hidden" data-region="user_picker/user"></div>
<div class="ml-auto flex-shrink-0">
<nav aria-label="{{#str}} usernavigation, mod_forum {{/str}}">
<ul class="pagination mb-0">
aria-hidden="true"
>
{{/profileimage}}
-<div>
+<div class="overflow-hidden">
<h5
class="mb-0 font-weight-bold user-full-name text-truncate"
data-region="name"
--- /dev/null
+@mod @mod_forum
+Feature: Ensure only users with appropriate permissions can export forum discussions.
+
+ Background:
+ Given the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@example.com |
+ | student1 | Student | 1 | student1@example.com |
+ And the following "courses" exist:
+ | fullname | shortname | category |
+ | Course 1 | C1 | 0 |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | editingteacher |
+ | student1 | C1 | student |
+
+ Scenario: A teacher can export discussions to a portfolio.
+ Given the following "activities" exist:
+ | activity | name | intro | course | idnumber | groupmode |
+ | forum | Test forum 1 | Test forum 2 | C1 | forum | 0 |
+ And I log in as "admin"
+ And the following config values are set as admin:
+ | enableportfolios | 1 |
+ And I navigate to "Plugins > Portfolios > Manage portfolios" in site administration
+ And I set portfolio instance "File download" to "Enabled and visible"
+ And I click on "Save" "button"
+ And I log out
+ When I log in as "student1"
+ And I am on "Course 1" course homepage
+ And I follow "Test forum 1"
+ And I add a new discussion to "Test forum 1" forum with:
+ | Subject | Discussion 1 |
+ | Message | Test post message |
+ And I reload the page
+ And I follow "Discussion 1"
+ Then "Export whole discussion to portfolio" "button" should not exist
+ And I log out
+ And I log in as "teacher1"
+ And I am on "Course 1" course homepage
+ And I follow "Test forum 1"
+ And I follow "Discussion 1"
+ And "Export whole discussion to portfolio" "button" should exist
+ And I press "Export whole discussion to portfolio"
+ And I should see "Exporting to portfolio"
$digesttime = usergetmidnight(time(), \core_date::get_server_timezone()) + ($CFG->digestmailtime * 3600);
$this->assertLessThanOrEqual($digesttime, $task->nextruntime);
}
+
+ /**
+ * The sending of a digest marks posts as read if automatic message read marking is set.
+ */
+ public function test_cron_digest_marks_posts_read() {
+ global $DB, $CFG;
+
+ $this->resetAfterTest(true);
+
+ // Disable the 'Manual message read marking' option.
+ $CFG->forum_usermarksread = false;
+
+ // Set up a basic user enrolled in a course.
+ $userhelper = $this->helper_setup_user_in_course();
+ $user = $userhelper->user;
+ $course1 = $userhelper->courses->course1;
+ $forum1 = $userhelper->forums->forum1;
+ $posts = [];
+
+ // Set the tested user's default maildigest, trackforums, read tracking settings.
+ $DB->set_field('user', 'maildigest', 1, ['id' => $user->id]);
+ $DB->set_field('user', 'trackforums', 1, ['id' => $user->id]);
+ set_user_preference('forum_markasreadonnotification', 1, $user->id);
+
+ // Set the maildigest preference for forum1 to default.
+ forum_set_user_maildigest($forum1, -1, $user);
+
+ // Add 5 discussions to forum 1.
+ for ($i = 0; $i < 5; $i++) {
+ list($discussion, $post) = $this->helper_post_to_forum($forum1, $user, ['mailnow' => 1]);
+ $posts[] = $post;
+ }
+
+ // There should be unread posts for the forum.
+ $expectedposts = [
+ $forum1->id => (object) [
+ 'id' => $forum1->id,
+ 'unread' => count($posts),
+ ],
+ ];
+ $this->assertEquals($expectedposts, forum_tp_get_course_unread_posts($user->id, $course1->id));
+
+ // One digest mail should be sent and no other messages.
+ $expect = [
+ (object) [
+ 'userid' => $user->id,
+ 'messages' => 0,
+ 'digests' => 1,
+ ],
+ ];
+ $this->queue_tasks_and_assert($expect);
+
+ $this->send_digests_and_assert($user, $posts);
+
+ // Verify that there are no unread posts for any forums.
+ $this->assertEmpty(forum_tp_get_course_unread_posts($user->id, $course1->id));
+ }
+
+ /**
+ * The sending of a digest does not mark posts as read when manual message read marking is set.
+ */
+ public function test_cron_digest_leaves_posts_unread() {
+ global $DB, $CFG;
+
+ $this->resetAfterTest(true);
+
+ // Enable the 'Manual message read marking' option.
+ $CFG->forum_usermarksread = true;
+
+ // Set up a basic user enrolled in a course.
+ $userhelper = $this->helper_setup_user_in_course();
+ $user = $userhelper->user;
+ $course1 = $userhelper->courses->course1;
+ $forum1 = $userhelper->forums->forum1;
+ $posts = [];
+
+ // Set the tested user's default maildigest, trackforums, read tracking settings.
+ $DB->set_field('user', 'maildigest', 1, ['id' => $user->id]);
+ $DB->set_field('user', 'trackforums', 1, ['id' => $user->id]);
+ set_user_preference('forum_markasreadonnotification', 1, $user->id);
+
+ // Set the maildigest preference for forum1 to default.
+ forum_set_user_maildigest($forum1, -1, $user);
+
+ // Add 5 discussions to forum 1.
+ for ($i = 0; $i < 5; $i++) {
+ list($discussion, $post) = $this->helper_post_to_forum($forum1, $user, ['mailnow' => 1]);
+ $posts[] = $post;
+ }
+
+ // There should be unread posts for the forum.
+ $expectedposts = [
+ $forum1->id => (object) [
+ 'id' => $forum1->id,
+ 'unread' => count($posts),
+ ],
+ ];
+ $this->assertEquals($expectedposts, forum_tp_get_course_unread_posts($user->id, $course1->id));
+
+ // One digest mail should be sent and no other messages.
+ $expect = [
+ (object) [
+ 'userid' => $user->id,
+ 'messages' => 0,
+ 'digests' => 1,
+ ],
+ ];
+ $this->queue_tasks_and_assert($expect);
+
+ $this->send_digests_and_assert($user, $posts);
+
+ // Verify that there are still the same unread posts for the forum.
+ $this->assertEquals($expectedposts, forum_tp_get_course_unread_posts($user->id, $course1->id));
+ }
}
defined('MOODLE_INTERNAL') || die();
-$plugin->version = 2020061500; // The current module version (Date: YYYYMMDDXX)
+$plugin->version = 2020061501; // The current module version (Date: YYYYMMDDXX)
$plugin->requires = 2020060900; // Requires this Moodle version
$plugin->component = 'mod_forum'; // Full name of the plugin (used for diagnostics)
--- /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/>.
+
+/**
+ * Lesson's numeric helper lib.
+ *
+ * Contains any helper functions for the numeric pagetyep
+ *
+ * @package mod_lesson
+ * @copyright 2020 Peter Dias <peter@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace mod_lesson\local\numeric;
+
+/**
+ * Lesson numeric page helper
+ *
+ * @copyright 2020 Peter Dias<peter@moodle.com>
+ * @package core_lesson
+ */
+class helper {
+
+ /**
+ * Helper function to unformat a given numeric value from locale specific values with n:n signifying ranges to standards
+ * with decimal point numbers/ranges
+ *
+ * @param string $value The value to be formatted
+ * @return string|float|bool $formattedvalue unformatted value
+ * String - If it is a range it will return a value e.g. 2:4
+ * Float - if it's a properly formatted float
+ * Null - If empty and could not be converted
+ */
+ public static function lesson_unformat_numeric_value(string $value) {
+ if (strpos($value, ':')) {
+ list($min, $max) = explode(':', $value);
+ $formattedvalue = unformat_float($min) . ':' . unformat_float($max);
+ } else {
+ $formattedvalue = unformat_float($value);
+ }
+
+ return $formattedvalue;
+ }
+
+ /**
+ * Helper function to format a given value into locale specific values with n:n signifying ranges
+ *
+ * @param string|number $value The value to be formatted
+ * @return string $formattedvalue Formatted value OR $value if not numeric
+ */
+ public static function lesson_format_numeric_value($value) : string {
+ $formattedvalue = $value;
+ if (strpos($value, ':')) {
+ list($min, $max) = explode(':', $value);
+ $formattedvalue = $min . ':' . $max;
+ if (is_numeric($min) && is_numeric($max)) {
+ $formattedvalue = format_float($min, strlen($min), true, true) . ':'
+ . format_float($max, strlen($max), true, true);
+ }
+ } else {
+ $formattedvalue = is_numeric($value) ? format_float($value, strlen($value), true, true) : $value;
+ }
+
+ return $formattedvalue;
+ }
+
+}
$string['numberofpagesviewedheader'] = 'Number of questions answered';
$string['numberofpagesviewednotice'] = 'Number of questions answered: {$a->nquestions} (You should answer at least {$a->minquestions})';
$string['numerical'] = 'Numerical';
+$string['numericanswer_help'] = 'You can specify a single number, or a range of numbers by using colon. For example 2:5 means any answer between 2 and 5 and including 2 and 5 is correct.';
+$string['numericanswer'] = 'Numeric answer';
$string['offlinedatamessage'] = 'You have worked on this attempt using a mobile device. Data was last saved to this site {$a} ago. Please check that you do not have any unsaved work.';
$string['ongoing'] = 'Display ongoing score';
$string['ongoing_help'] = 'If enabled, each page will display the student\'s current points earned out of the total possible thus far.';
* @param string $label, null means default
* @param bool $required
* @param string $format
+ * @param array $help Add help text via the addHelpButton. Must be an array which contains the string identifier and
+ * component as it's elements
* @return void
*/
- protected final function add_answer($count, $label = null, $required = false, $format= '') {
+ protected final function add_answer($count, $label = null, $required = false, $format= '', array $help = []) {
if ($label === null) {
$label = get_string('answer', 'lesson');
}
$this->_form->setDefault('answer_editor['.$count.']', array('text' => '', 'format' => FORMAT_HTML));
} else {
$this->_form->addElement('text', 'answer_editor['.$count.']', $label,
- array('size' => '50', 'maxlength' => '200'));
+ array('size' => '50', 'maxlength' => '200'));
$this->_form->setType('answer_editor['.$count.']', PARAM_TEXT);
}
if ($required) {
$this->_form->addRule('answer_editor['.$count.']', get_string('required'), 'required', null, 'client');
}
+
+ if ($help) {
+ $this->_form->addHelpButton("answer_editor[$count]", $help['identifier'], $help['component']);
+ }
}
/**
* Convenience function: Adds an response editor
$this->answers[$i]->lessonid = $this->lesson->id;
$this->answers[$i]->pageid = $this->id;
$this->answers[$i]->timecreated = $this->timecreated;
+ $this->answers[$i]->answer = null;
}
if (isset($properties->answer_editor[$i])) {
$this->answers[$i]->answer = $properties->answer_editor[$i];
$this->answers[$i]->answerformat = FORMAT_MOODLE;
}
+ } else {
+ // If there is no data posted which means we want to reset the stored values.
+ $this->answers[$i]->answer = null;
}
if (!empty($properties->response_editor[$i]) && is_array($properties->response_editor[$i])) {
/** Numerical question type */
define("LESSON_PAGE_NUMERICAL", "8");
+use mod_lesson\local\numeric\helper;
+
class lesson_page_type_numerical extends lesson_page {
protected $type = lesson_page::TYPE_QUESTION;
return $this->typeidstring;
}
public function display($renderer, $attempt) {
- global $USER, $CFG, $PAGE;
- $mform = new lesson_display_answer_form_shortanswer($CFG->wwwroot.'/mod/lesson/continue.php', array('contents'=>$this->get_contents(), 'lessonid'=>$this->lesson->id));
+ global $USER, $PAGE;
+ $mform = new lesson_display_answer_form_numerical(new moodle_url('/mod/lesson/continue.php'),
+ array('contents' => $this->get_contents(), 'lessonid' => $this->lesson->id));
$data = new stdClass;
$data->id = $PAGE->cm->id;
$data->pageid = $this->properties->id;
}
public function check_answer() {
- global $CFG;
$result = parent::check_answer();
- $mform = new lesson_display_answer_form_shortanswer($CFG->wwwroot.'/mod/lesson/continue.php', array('contents'=>$this->get_contents()));
+ $mform = new lesson_display_answer_form_numerical(new moodle_url('/mod/lesson/continue.php'),
+ array('contents' => $this->get_contents()));
$data = $mform->get_data();
require_sesskey();
$result->response = '';
$result->newpageid = 0;
- if (!isset($data->answer) || !is_numeric($data->answer)) {
+ if (!isset($data->answer)) {
$result->noanswer = true;
return $result;
} else {
- // Just doing default PARAM_RAW, not doing PARAM_INT because it could be a float.
- $result->useranswer = (float)$data->answer;
+ $result->useranswer = $data->answer;
}
$result->studentanswer = $result->userresponse = $result->useranswer;
$answers = $this->get_answers();
} else {
$cells[] = '<label class="correct">' . get_string('answer', 'lesson') . ' ' . $i . '</label>:';
}
- $cells[] = format_text($answer->answer, $answer->answerformat, $options);
+ $formattedanswer = helper::lesson_format_numeric_value($answer->answer);
+ $cells[] = format_text($formattedanswer, $answer->answerformat, $options);
$table->data[] = new html_table_row($cells);
$cells = array();
unset($stats["total"]);
foreach ($stats as $valentered => $ntimes) {
$data = '<input class="form-control" type="text" size="50" ' .
- 'disabled="disabled" readonly="readonly" value="'.s($valentered).'" />';
+ 'disabled="disabled" readonly="readonly" value="'.
+ s(format_float($valentered, strlen($valentered), true, true)).'" />';
$percent = $ntimes / $total * 100;
$percent = round($percent, 2);
$percent .= "% ".get_string("enteredthis", "lesson");
empty($answerdata->answers)))) {
// Get in here when the user answered or for the last answer.
$data = '<input class="form-control" type="text" size="50" ' .
- 'disabled="disabled" readonly="readonly" value="'.s($useranswer->useranswer).'">';
+ 'disabled="disabled" readonly="readonly" value="'.
+ s(format_float($useranswer->useranswer, strlen($useranswer->useranswer), true, true)).'">';
if (isset($pagestats[$this->properties->id][$useranswer->useranswer])) {
$percent = $pagestats[$this->properties->id][$useranswer->useranswer] / $pagestats[$this->properties->id]["total"] * 100;
$percent = round($percent, 2);
*/
public function update_form_data(stdClass $data) : stdClass {
$answercount = count($this->get_answers());
+
+ // If no answers provided, then we don't need to check anything.
+ if (!$answercount) {
+ return $data;
+ }
+
// Check for other answer entry.
$lastanswer = $data->{'answer_editor[' . ($answercount - 1) . ']'};
if (strpos($lastanswer, LESSON_OTHER_ANSWERS) !== false) {
$answercount = $this->_customdata['lesson']->maxanswers;
for ($i = 0; $i < $answercount; $i++) {
$this->_form->addElement('header', 'answertitle'.$i, get_string('answer').' '.($i+1));
- $this->add_answer($i, null, ($i < 1));
+ $this->add_answer($i, null, ($i < 1), '', [
+ 'identifier' => 'numericanswer',
+ 'component' => 'mod_lesson'
+ ]);
$this->add_response($i);
$this->add_jumpto($i, null, ($i == 0 ? LESSON_NEXTPAGE : LESSON_THISPAGE));
$this->add_score($i, null, ($i===0)?1:0);
$this->add_jumpto($newcount, get_string('allotheranswersjump', 'lesson'), LESSON_NEXTPAGE);
$this->add_score($newcount, get_string('allotheranswersscore', 'lesson'), 0);
}
+
+ /**
+ * We call get data when storing the data into the db. Override to format the floats properly
+ *
+ * @return object|void
+ */
+ public function get_data() : ?stdClass {
+ $data = parent::get_data();
+
+ if (!empty($data->answer_editor)) {
+ foreach ($data->answer_editor as $key => $answer) {
+ $data->answer_editor[$key] = helper::lesson_unformat_numeric_value($answer);
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Return submitted data if properly submitted or returns NULL if validation fails or
+ * if there is no submitted data with formatted numbers
+ *
+ * @return object submitted data; NULL if not valid or not submitted or cancelled
+ */
+ public function get_submitted_data() : ?stdClass {
+ $data = parent::get_submitted_data();
+
+ if (!empty($data->answer_editor)) {
+ foreach ($data->answer_editor as $key => $answer) {
+ $data->answer_editor[$key] = helper::lesson_unformat_numeric_value($answer);
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Load in existing data as form defaults. Usually new entry defaults are stored directly in
+ * form definition (new entry form); this function is used to load in data where values
+ * already exist and data is being edited (edit entry form) after formatting numbers
+ *
+ *
+ * @param stdClass|array $defaults object or array of default values
+ */
+ public function set_data($defaults) {
+ if (is_object($defaults)) {
+ $defaults = (array) $defaults;
+ }
+
+ $editor = 'answer_editor';
+ foreach ($defaults as $key => $answer) {
+ if (substr($key, 0, strlen($editor)) == $editor) {
+ $defaults[$key] = helper::lesson_format_numeric_value($answer);
+ }
+ }
+
+ parent::set_data($defaults);
+ }
}
class lesson_display_answer_form_numerical extends moodleform {
$mform->addElement('hidden', 'pageid');
$mform->setType('pageid', PARAM_INT);
- $mform->addElement('text', 'answer', get_string('youranswer', 'lesson'), $attrs);
- $mform->setType('answer', PARAM_FLOAT);
+ $mform->addElement('float', 'answer', get_string('youranswer', 'lesson'), $attrs);
if ($hasattempt) {
$this->add_action_buttons(null, get_string("nextpage", "lesson"));
$this->add_action_buttons(null, get_string("submit", "lesson"));
}
}
-
}
--- /dev/null
+@mod @mod_lesson
+Feature: In a lesson activity, I need to edit pages in the lesson taking into account locale settings
+
+ Background:
+ Given the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@example.com |
+ | student1 | Student | 1 | student1@example.com |
+ And the following "courses" exist:
+ | fullname | shortname | category |
+ | Course 1 | C1 | 0 |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | editingteacher |
+ | student1 | C1 | student |
+ And the following "language customisations" exist:
+ | component | stringid | value |
+ | core_langconfig | decsep | # |
+ And I log in as "teacher1"
+ And I am on "Course 1" course homepage with editing mode on
+ And I add a "Lesson" to section "1" and I fill the form with:
+ | Name | Test lesson name |
+ | Description | Test lesson description |
+ | Allow student review | Yes |
+ And I follow "Test lesson name"
+ And I follow "Add a question page"
+ And I set the field "Select a question type" to "Numerical"
+ And I press "Add a question page"
+ And I set the following fields to these values:
+ | Page title | Hardest question ever |
+ | Page contents | 1 + 1? |
+ | id_answer_editor_0 | 2#87 |
+ | id_response_editor_0 | Correct answer |
+ | id_jumpto_0 | End of lesson |
+ | id_score_0 | 1 |
+ | id_answer_editor_1 | 2#1:2#8 |
+ | id_response_editor_1 | Incorrect answer |
+ | id_jumpto_1 | This page |
+ | id_score_1 | 0 |
+ And I press "Save page"
+ And I log out
+
+ Scenario: Edit a numerical question with the locale specific variables
+ Given I log in as "teacher1"
+ And I am on "Course 1" course homepage with editing mode on
+ And I follow "Test lesson name"
+ And I click on "Edit" "link" in the "region-main" "region"
+ And I follow "Hardest question ever"
+ Then I should see "2#87"
+ And I should see "2#1:2#8"
+ And I log out
+
+ Scenario: View the detailed page of lesson
+ Given I log in as "teacher1"
+ And I am on "Course 1" course homepage with editing mode on
+ And I follow "Test lesson name"
+ And I click on "Edit" "link" in the "region-main" "region"
+ And I click on "Expanded" "link" in the "region-main" "region"
+ Then I should see "2#87"
+ And I should see "2#1:2#8"
+ And I log out
+
+ Scenario: Attempt the lesson successfully as a student
+ Given I log in as "student1"
+ And I am on "Course 1" course homepage
+ And I follow "Test lesson name"
+ And I should see "1 + 1?"
+ And I set the following fields to these values:
+ | Your answer | 2#87 |
+ And I press "Submit"
+ Then I should see "Correct answer"
+ And I should not see "Incorrect answer"
+ And I press "Continue"
+ And I should see "Congratulations - end of lesson reached"
+ And I should see "Your score is 1 (out of 1)."
+ And I log out
+
+ Scenario: Attempt the lesson unsuccessfully as a student
+ Given I log in as "student1"
+ And I am on "Course 1" course homepage
+ And I follow "Test lesson name"
+ And I should see "1 + 1?"
+ And I set the following fields to these values:
+ | Your answer | 2#7 |
+ And I press "Submit"
+ Then I should not see "Correct answer"
+ And I should see "Incorrect answer"
+ And I press "Continue"
+ And I should see "Congratulations - end of lesson reached"
+ And I should see "Your score is 0 (out of 1)."
+ And I log out
+
+ Scenario: Attempt the lesson successfully as a student and review
+ Given I log in as "student1"
+ And I am on "Course 1" course homepage
+ And I follow "Test lesson name"
+ And I should see "1 + 1?"
+ And I set the following fields to these values:
+ | Your answer | 2#87 |
+ And I press "Submit"
+ Then I should see "Correct answer"
+ And I should not see "Incorrect answer"
+ And I press "Continue"
+ And I should see "Congratulations - end of lesson reached"
+ And I should see "Your score is 1 (out of 1)."
+ And I follow "Review lesson"
+ Then I should see "1 + 1?"
+ And the following fields match these values:
+ | Your answer | 2#87 |
+ And I log out
+
+ Scenario: Edit lesson question page with updated locale setting and wrong answer
+ Given I log in as "teacher1"
+ And the following "language customisations" exist:
+ | component | stringid | value |
+ | core_langconfig | decsep | , |
+ And I am on "Course 1" course homepage with editing mode on
+ And I follow "Test lesson name"
+ Then I click on "Edit" "link" in the "region-main" "region"
+ And I follow "Hardest question ever"
+ Then I should see "2,87"
+ And I should see "2,1:2,8"
+ And I log out
+ And I log in as "student1"
+ And I am on "Course 1" course homepage
+ And I follow "Test lesson name"
+ And I should see "1 + 1?"
+ And I set the following fields to these values:
+ | Your answer | 2,7 |
+ And I press "Submit"
+ And I should see "Incorrect answer"
+ And I should not see "Correct answer"
+ And I press "Continue"
+ And I should see "Congratulations - end of lesson reached"
+ And I should see "Your score is 0 (out of 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 page types classes
+ *
+ * @package mod_lesson
+ * @category test
+ * @copyright 2020 Peter Dias
+ * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+use mod_lesson\local\numeric\helper;
+
+/**
+ * This class contains the test cases for the numeric helper functions
+ *
+ * @copyright 2020 Peter Dias
+ * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
+ */
+class mod_lesson_numeric_type_helper_test extends advanced_testcase {
+ /**
+ * Test the lesson_unformat_numeric_value function.
+ *
+ * @dataProvider lesson_unformat_dataprovider
+ * @param $decsep
+ * @param $tests
+ */
+ public function test_lesson_unformat_numeric_value($decsep, $tests) {
+ $this->define_local_decimal_separator($decsep);
+
+ foreach ($tests as $test) {
+ $this->assertEquals($test[1], helper::lesson_unformat_numeric_value($test[0]));
+ }
+ }
+
+ /**
+ * Test the lesson_format_numeric_value function.
+ *
+ * @dataProvider lesson_format_dataprovider
+ * @param $decsep
+ * @param $tests
+ */
+ public function test_lesson_format_numeric_value($decsep, $tests) {
+ $this->define_local_decimal_separator($decsep);
+
+ foreach ($tests as $test) {
+ $this->assertEquals($test[1], helper::lesson_format_numeric_value($test[0]));
+ }
+ }
+
+ /**
+ * Provide various cases for the unformat test function
+ *
+ * @return array
+ */
+ public function lesson_unformat_dataprovider() {
+ return [
+ "Using a decimal as a separator" => [
+ "decsep" => ".",
+ "test" => [
+ ["2.1", 2.1],
+ ["1:4.2", "1:4.2"],
+ ["2,1", 2],
+ ["1:4,2", "1:4"],
+ ["", null]
+ ]
+ ],
+ "Using a comma as a separator" => [
+ "decsep" => ",",
+ "test" => [
+ ["2,1", 2.1],
+ ["1:4,2", "1:4.2"],
+ ["2.1", 2.1],
+ ["1:4.2", "1:4.2"],
+ ]
+ ],
+ "Using a X as a separator" => [
+ "decsep" => "X",
+ "test" => [
+ ["2X1", 2.1],
+ ["1:4X2", "1:4.2"],
+ ["2.1", 2.1],
+ ["1:4.2", "1:4.2"],
+ ]
+ ]
+ ];
+ }
+
+ /**
+ * Provide various cases for the unformat test function
+ *
+ * @return array
+ */
+ public function lesson_format_dataprovider() {
+ return [
+ "Using a decimal as a separator" => [
+ "decsep" => ".",
+ "test" => [
+ ["2.1", 2.1],
+ ["1:4.2", "1:4.2"],
+ ["2,1", "2,1"],
+ ["1:4,2", "1:4,2"]
+ ]
+ ],
+ "Using a comma as a separator" => [
+ "decsep" => ",",
+ "test" => [
+ ["2,1", "2,1"],
+ ["1:4,2", "1:4,2"],
+ ["2.1", "2,1"],
+ [2.1, "2,1"],
+ ["1:4.2", "1:4,2"],
+ ]
+ ],
+ "Using a X as a separator" => [
+ "decsep" => "X",
+ "test" => [
+ ["2X1", "2X1"],
+ ["1:4X2", "1:4X2"],
+ ["2.1", "2X1"],
+ ["1:4.2", "1:4X2"],
+ ]
+ ]
+ ];
+ }
+
+
+ /**
+ * Define a local decimal separator.
+ *
+ * It is not possible to directly change the result of get_string in
+ * a unit test. Instead, we create a language pack for language 'xx' in
+ * dataroot and make langconfig.php with the string we need to change.
+ * The default example separator used here is 'X'; on PHP 5.3 and before this
+ * must be a single byte character due to PHP bug/limitation in
+ * number_format, so you can't use UTF-8 characters.
+ *
+ * @param string $decsep Separator character. Defaults to `'X'`.
+ */
+ protected function define_local_decimal_separator(string $decsep = 'X') {
+ global $SESSION, $CFG;
+
+ $SESSION->lang = 'xx';
+ $langconfig = "<?php\n\$string['decsep'] = '$decsep';";
+ $langfolder = $CFG->dataroot . '/lang/xx';
+ check_dir_exists($langfolder);
+ file_put_contents($langfolder . '/langconfig.php', $langconfig);
+
+