theme/boost/amd/src/bootstrap/carousel.js
theme/boost/amd/src/bootstrap/collapse.js
theme/boost/amd/src/bootstrap/dropdown.js
-theme/boost/amd/src/bootstrap/index.js
theme/boost/amd/src/bootstrap/modal.js
theme/boost/amd/src/bootstrap/popover.js
theme/boost/amd/src/bootstrap/tools/sanitizer.js
theme/boost/amd/src/bootstrap/toast.js
theme/boost/amd/src/bootstrap/tooltip.js
theme/boost/amd/src/bootstrap/util.js
-theme/boost/scss/fontawesome/
\ No newline at end of file
+theme/boost/amd/src/index.js
+theme/boost/scss/fontawesome/
name: Core
-on: [push]
+on:
+ push:
+ branches-ignore:
+ - master
+ - MOODLE_[0-9]+_STABLE
+ tags-ignore:
+ - v[0-9]+.[0-9]+.[0-9]+*
env:
php: 7.4
'*/**/yui/src/*/meta/',
'*/**/build/',
].concat(thirdPartyPaths);
- grunt.file.write('.eslintignore', eslintIgnores.join('\n'));
+ grunt.file.write('.eslintignore', eslintIgnores.join('\n') + '\n');
// Generate .stylelintignore.
const stylelintIgnores = [
'theme/boost/style/moodle.css',
'theme/classic/style/moodle.css',
].concat(thirdPartyPaths);
- grunt.file.write('.stylelintignore', stylelintIgnores.join('\n'));
+ grunt.file.write('.stylelintignore', stylelintIgnores.join('\n') + '\n');
};
grunt.registerTask('ignorefiles', 'Generate ignore files for linters', handler);
theme/boost/amd/src/bootstrap/carousel.js
theme/boost/amd/src/bootstrap/collapse.js
theme/boost/amd/src/bootstrap/dropdown.js
-theme/boost/amd/src/bootstrap/index.js
theme/boost/amd/src/bootstrap/modal.js
theme/boost/amd/src/bootstrap/popover.js
theme/boost/amd/src/bootstrap/tools/sanitizer.js
theme/boost/amd/src/bootstrap/toast.js
theme/boost/amd/src/bootstrap/tooltip.js
theme/boost/amd/src/bootstrap/util.js
-theme/boost/scss/fontawesome/
\ No newline at end of file
+theme/boost/amd/src/index.js
+theme/boost/scss/fontawesome/
addons:
postgresql: "9.6"
+branches:
+ except:
+ - master
+ - /MOODLE_[0-9]+_STABLE/
+ - /^v[0-9]+\.[0-9]+\.[0-9]+.*/
+
jobs:
# Enable fast finish.
# This will fail the build if a single job fails (except those in allow_failures).
// Save processors enabled/disabled status.
foreach ($allprocessors as $processor) {
$enabled = isset($form->{$processor->name});
+ if ($enabled != $processor->enabled) {
+ add_to_config_log($processor->name, $processor->enabled, $enabled, 'core');
+ }
\core_message\api::update_processor_status($processor, $enabled);
}
foreach ($newpreferences as $name => $value) {
+ $old = isset($preferences->$name) ? $preferences->$name : '';
+
+ if ($old != $value) {
+ add_to_config_log($name, $old, $value, 'core');
+ }
+
set_config($name, $value, 'message');
}
$transaction->allow_commit();
if ($error) {
$message = optional_param('error_description', '', PARAM_RAW);
if ($message) {
- print_error($message);
+ $SESSION->loginerrormsg = $message;
+ redirect(new moodle_url(get_login_url()));
} else {
- print_error($error);
+ $SESSION->loginerrormsg = $error;
+ redirect(new moodle_url(get_login_url()));
}
- die();
}
// The authorization code generated by the authorization server.
$redirecturl->param('oauth2code', $code);
redirect($redirecturl);
} else {
- print_error('invalidsesskey');
+ $SESSION->loginerrormsg = get_string('invalidsesskey', 'error');
+ redirect(new moodle_url(get_login_url()));
}
$message = get_string('purgecachesfinished', 'admin');
}
+// Redirect and/or show notification message confirming cache(s) were purged.
if (isset($message)) {
- redirect($returnurl, $message);
+ if (!$PAGE->url->compare($returnurl, URL_MATCH_BASE)) {
+ redirect($returnurl, $message);
+ }
+
+ // We are already on the purge caches page, add the notification.
+ \core\notification::add($message, \core\output\notification::NOTIFY_INFO);
}
// Otherwise, show a form to actually purge the caches.
FROM {tool_cohortroles} uca
JOIN {user} u ON u.id = uca.userid
JOIN {cohort} c ON c.id = uca.cohortid";
- $params = array();
+
+ // Check if any additional filtering is required.
+ [$sqlwhere, $params] = $this->get_sql_where();
+ if ($sqlwhere) {
+ $sql .= " WHERE {$sqlwhere}";
+ }
// Add order by if needed.
if (!$count && $sqlsort = $this->get_sql_sort()) {
$title = get_string('existingcohortroles', 'tool_cohortroles');
echo $output->heading($title);
- $url = new moodle_url('/admin/tool/cohortroles/index.php');
- $table = new tool_cohortroles\output\cohort_role_assignments_table(uniqid(), $url);
+
+ $table = new tool_cohortroles\output\cohort_role_assignments_table('cohort-role-assignments', $pageurl);
echo $table->out(50, true);
echo $output->spacer();
--- /dev/null
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Javascript module for contacting the site DPO
+ *
+ * @module tool_dataprivacy/contactdpo
+ * @package tool_dataprivacy
+ * @copyright 2021 Paul Holden <paulh@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import ModalForm from 'core_form/modalform';
+import Notification from 'core/notification';
+import {get_string as getString} from 'core/str';
+import {add as addToast} from 'core/toast';
+
+const SELECTORS = {
+ CONTACT_DPO: '[data-action="contactdpo"]',
+};
+
+/**
+ * Initialize module
+ */
+export const init = () => {
+ const triggerElement = document.querySelector(SELECTORS.CONTACT_DPO);
+
+ triggerElement.addEventListener('click', event => {
+ event.preventDefault();
+
+ const modalForm = new ModalForm({
+ modalConfig: {
+ title: getString('contactdataprotectionofficer', 'tool_dataprivacy'),
+ },
+ formClass: 'tool_dataprivacy\\form\\contactdpo',
+ saveButtonText: getString('send', 'tool_dataprivacy'),
+ returnFocus: triggerElement,
+ });
+
+ // Show a toast notification when the form is submitted.
+ modalForm.addEventListener(modalForm.events.FORM_SUBMITTED, event => {
+ if (event.detail.result) {
+ getString('requestsubmitted', 'tool_dataprivacy').then(addToast).catch();
+ } else {
+ const warningMessages = event.detail.warnings.map(warning => warning.message);
+ Notification.addNotification({
+ type: 'error',
+ message: warningMessages.join('<br>')
+ });
+ }
+ });
+
+ modalForm.show();
+ });
+};
* @copyright 2018 Jun Pataleta
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-define([
- 'jquery',
- 'core/ajax',
- 'core/notification',
- 'core/str',
- 'core/modal_factory',
- 'core/modal_events',
- 'core/templates',
- 'core/pending'],
-function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates, Pending) {
- /**
- * List of action selectors.
- *
- * @type {{CANCEL_REQUEST: string}}
- * @type {{CONTACT_DPO: string}}
- */
- var ACTIONS = {
- CANCEL_REQUEST: '[data-action="cancel"]',
- CONTACT_DPO: '[data-action="contactdpo"]',
- };
+import Ajax from 'core/ajax';
+import Notification from 'core/notification';
+import Pending from 'core/pending';
+import {get_strings as getStrings} from 'core/str';
- /**
- * MyRequestActions class.
- */
- var MyRequestActions = function() {
- this.registerEvents();
- };
+const SELECTORS = {
+ CANCEL_REQUEST: '[data-action="cancel"][data-requestid]',
+};
- /**
- * Register event listeners.
- */
- MyRequestActions.prototype.registerEvents = function() {
- $(ACTIONS.CANCEL_REQUEST).click(function(e) {
- e.preventDefault();
-
- var requestId = $(this).data('requestid');
- var stringkeys = [
- {
- key: 'cancelrequest',
- component: 'tool_dataprivacy'
- },
- {
- key: 'cancelrequestconfirmation',
- component: 'tool_dataprivacy'
- }
- ];
-
- Str.get_strings(stringkeys).then(function(langStrings) {
- var title = langStrings[0];
- var confirmMessage = langStrings[1];
- return ModalFactory.create({
- title: title,
- body: confirmMessage,
- type: ModalFactory.types.SAVE_CANCEL
- }).then(function(modal) {
- modal.setSaveButtonText(title);
-
- // Handle save event.
- modal.getRoot().on(ModalEvents.save, function() {
- // Cancel the request.
- var params = {
- 'requestid': requestId
- };
-
- var request = {
- methodname: 'tool_dataprivacy_cancel_data_request',
- args: params
- };
-
- Ajax.call([request])[0].done(function(data) {
- if (data.result) {
- window.location.reload();
- } else {
- Notification.addNotification({
- message: data.warnings[0].message,
- type: 'error'
- });
- }
- }).fail(Notification.exception);
- });
-
- // Handle hidden event.
- modal.getRoot().on(ModalEvents.hidden, function() {
- // Destroy when hidden.
- modal.destroy();
- });
-
- return modal;
- });
- }).done(function(modal) {
- // Show the modal!
- modal.show();
-
- }).fail(Notification.exception);
- });
-
- $(ACTIONS.CONTACT_DPO).click(function(e) {
- var pendingPromise = new Pending('dataprivacy/crud:initModal:contactdpo');
- e.preventDefault();
+/**
+ * Initialize module
+ */
+export const init = () => {
+ document.addEventListener('click', event => {
+ const triggerElement = event.target.closest(SELECTORS.CANCEL_REQUEST);
+ if (triggerElement === null) {
+ return;
+ }
- var replyToEmail = $(this).data('replytoemail');
+ event.preventDefault();
- var keys = [
- {
- key: 'contactdataprotectionofficer',
- component: 'tool_dataprivacy'
- },
- {
- key: 'send',
- component: 'tool_dataprivacy'
- },
- ];
+ const requiredStrings = [
+ {key: 'cancelrequest', component: 'tool_dataprivacy'},
+ {key: 'cancelrequestconfirmation', component: 'tool_dataprivacy'},
+ ];
- var sendButtonText = '';
- Str.get_strings(keys).then(function(langStrings) {
- var modalTitle = langStrings[0];
- sendButtonText = langStrings[1];
- var context = {
- 'replytoemail': replyToEmail
+ getStrings(requiredStrings).then(([cancelRequest, cancelConfirm]) => {
+ return Notification.confirm(cancelRequest, cancelConfirm, cancelRequest, null, () => {
+ const pendingPromise = new Pending('tool/dataprivacy:cancelRequest');
+ const request = {
+ methodname: 'tool_dataprivacy_cancel_data_request',
+ args: {requestid: triggerElement.dataset.requestid}
};
- return ModalFactory.create({
- title: modalTitle,
- body: Templates.render('tool_dataprivacy/contact_dpo', context),
- type: ModalFactory.types.SAVE_CANCEL,
- large: true
- });
- }).then(function(modal) {
- modal.setSaveButtonText(sendButtonText);
-
- // Show the modal!
- modal.show();
- // Handle send event.
- modal.getRoot().on(ModalEvents.save, function(e) {
- var message = $('#message').val().trim();
- if (message.length === 0) {
- e.preventDefault();
- // Show validation error when the message is empty.
- $('[data-region="messageinput"]').addClass('has-danger notifyproblem');
- $('#id_error_message').removeAttr('hidden');
+ Ajax.call([request])[0].then(response => {
+ if (response.result) {
+ window.location.reload();
} else {
- // Send the message.
- sendMessageToDPO(message);
+ Notification.addNotification({
+ type: 'error',
+ message: response.warnings[0].message
+ });
}
- });
-
- // Handle hidden event.
- modal.getRoot().on(ModalEvents.hidden, function() {
- // Destroy when hidden.
- modal.destroy();
- });
-
- return;
- }).then(pendingPromise.resolve)
- .catch(Notification.exception);
- });
- };
-
- /**
- * Send message to the Data Protection Officer.
- *
- * @param {String} message The message to send.
- */
- function sendMessageToDPO(message) {
- var request = {
- methodname: 'tool_dataprivacy_contact_dpo',
- args: {
- message: message
- }
- };
-
- var requestType = 'success';
- Ajax.call([request])[0].then(function(data) {
- if (data.result) {
- return Str.get_string('requestsubmitted', 'tool_dataprivacy');
- }
- requestType = 'error';
- return data.warnings.join('<br>');
-
- }).done(function(message) {
- Notification.addNotification({
- message: message,
- type: requestType
+ return pendingPromise.resolve();
+ }).catch(Notification.exception);
});
-
- }).fail(Notification.exception);
- }
-
- return /** @alias module:tool_dataprivacy/myrequestactions */ {
- // Public variables and functions.
-
- /**
- * Initialise the unified user filter.
- *
- * @method init
- * @return {MyRequestActions}
- */
- 'init': function() {
- return new MyRequestActions();
- }
- };
-});
+ }).catch();
+ });
+};
$warnings[] = [
'item' => $dpo->id,
'warningcode' => 'errorsendingtodpo',
- 'message' => get_string('errorsendingmessagetodpo', 'tool_dataprivacy')
+ 'message' => get_string('errorsendingmessagetodpo', 'tool_dataprivacy',
+ fullname($dpo))
];
}
}
--- /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/>.
+
+
+namespace tool_dataprivacy\form;
+
+use context;
+use context_user;
+use moodle_exception;
+use moodle_url;
+use core_form\dynamic_form;
+use tool_dataprivacy\api;
+use tool_dataprivacy\external;
+
+/**
+ * Contact DPO modal form
+ *
+ * @package tool_dataprivacy
+ * @copyright 2021 Paul Holden <paulh@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class contactdpo extends dynamic_form {
+
+ /**
+ * Form definition
+ */
+ protected function definition() {
+ global $USER;
+
+ $mform = $this->_form;
+
+ $mform->addElement('static', 'replyto', get_string('replyto', 'tool_dataprivacy'), s($USER->email));
+
+ $mform->addElement('textarea', 'message', get_string('message', 'tool_dataprivacy'), 'cols="60" rows="8"');
+ $mform->setType('message', PARAM_TEXT);
+ $mform->addRule('message', get_string('required'), 'required', null, 'client');
+ }
+
+ /**
+ * Return form context
+ *
+ * @return context
+ */
+ protected function get_context_for_dynamic_submission(): context {
+ global $USER;
+
+ return context_user::instance($USER->id);
+ }
+
+ /**
+ * Check if current user has access to this form, otherwise throw exception
+ *
+ * @throws moodle_exception
+ */
+ protected function check_access_for_dynamic_submission(): void {
+ if (!api::can_contact_dpo()) {
+ throw new moodle_exception('errorcontactdpodisabled', 'tool_dataprivacy');
+ }
+ }
+
+ /**
+ * Process the form submission, used if form was submitted via AJAX
+ *
+ * @return array
+ */
+ public function process_dynamic_submission() {
+ return external::contact_dpo($this->get_data()->message);
+ }
+
+ /**
+ * Load in existing data as form defaults (not applicable)
+ */
+ public function set_data_for_dynamic_submission(): void {
+ return;
+ }
+
+ /**
+ * Returns url to set in $PAGE->set_url() when form is being rendered or submitted via AJAX
+ *
+ * @return moodle_url
+ */
+ protected function get_page_url_for_dynamic_submission(): moodle_url {
+ global $USER;
+
+ return new moodle_url('/user/profile.php', ['id' => $USER->id]);
+ }
+}
/**
* Render the contact DPO link.
*
- * @param string $replytoemail The Reply-to email address
* @return string The HTML for the link.
- * @throws coding_exception
*/
- public function render_contact_dpo_link($replytoemail) {
+ public function render_contact_dpo_link() {
$params = [
'data-action' => 'contactdpo',
- 'data-replytoemail' => $replytoemail,
];
return html_writer::link('#', get_string('contactdataprotectionofficer', 'tool_dataprivacy'), $params);
}
$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['errorcontactdpodisabled'] = 'Contacting the privacy officer is disabled';
$string['errorinvalidrequestcomments'] = 'The comments field may contain plain text only.';
$string['errorinvalidrequestcreationmethod'] = 'Invalid request creation method!';
$string['errorinvalidrequeststatus'] = 'Invalid request status!';
// Contact data protection officer link.
if (\tool_dataprivacy\api::can_contact_dpo() && $iscurrentuser) {
$renderer = $PAGE->get_renderer('tool_dataprivacy');
- $content = $renderer->render_contact_dpo_link($USER->email);
+ $content = $renderer->render_contact_dpo_link();
$node = new core_user\output\myprofile\node('privacyandpolicies', 'contactdpo', null, null, null, $content);
$category->add_node($node);
- $PAGE->requires->js_call_amd('tool_dataprivacy/myrequestactions', 'init');
+
+ // Require our Javascript module to handle contact DPO interaction.
+ $PAGE->requires->js_call_amd('tool_dataprivacy/contactdpo', 'init');
$url = new moodle_url('/admin/tool/dataprivacy/mydatarequests.php');
$node = new core_user\output\myprofile\node('privacyandpolicies', 'datarequests',
+++ /dev/null
-{{!
- This file is part of Moodle - http://moodle.org/
-
- Moodle is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- Moodle is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with Moodle. If not, see <http://www.gnu.org/licenses/>.
-}}
-{{!
- @template tool_dataprivacy/contact_dpo
-
- The purpose of this template is to enable the user to contact the site's DPO via email.
-
- Classes required for JS:
- * none
-
- Data attributes required for JS:
- * none
-
- Context variables required for this template:
- * userid int The user's ID.
- * email string The user's email address.
-
- Example context (json):
- {
- "userid": 1,
- "replytoemail": "martha@example.com"
- }
-}}
-<div class="container">
- <div class="row mb-2">
- <label class="col-md-3 col-form-label">{{#str}}replyto, tool_dataprivacy{{/str}}</label>
- <div class="col-md-9 col-form-label">{{replytoemail}}</div>
- </div>
- <div class="row" data-region="messageinput">
- <label for="message" class="col-md-3 col-form-label">
- {{#str}}message, tool_dataprivacy{{/str}}
- <span class="float-sm-right text-nowrap">
- <abbr class="initialism text-danger" title="{{#str}}required{{/str}}">{{#pix}}req, core, {{#str}}required{{/str}}{{/pix}}</abbr>
- </span>
- </label>
- <div class="col-md-9">
- <textarea class="form-control" id="message" cols="60" rows="8"></textarea>
- <div class="form-control-feedback" id="id_error_message" hidden="hidden">
- {{#str}}required, moodle{{/str}}
- </div>
- </div>
- </div>
-</div>
-
Given the following "users" exist:
| username | firstname | lastname | email |
| student1 | Student | 1 | s1@example.com |
- And I log in as "admin"
- And I set the following administration settings values:
- | contactdataprotectionofficer | 1 |
- And I log out
@javascript
Scenario: Contacting the privacy officer
- Given I log in as "student1"
+ Given the following config values are set as admin:
+ | contactdataprotectionofficer | 1 | tool_dataprivacy |
+ When I log in as "student1"
And I follow "Profile" in the user menu
- And I should see "Contact the privacy officer"
And I click on "Contact the privacy officer" "link"
And I set the field "Message" to "Hello DPO!"
And I click on "Send" "button" in the "Contact the privacy officer" "dialogue"
- And I should see "Your request has been submitted to the privacy officer"
+ Then I should see "Your request has been submitted to the privacy officer"
And I click on "Data requests" "link"
And I should see "Hello DPO!" in the "General inquiry" "table_row"
+
+ Scenario: Contacting the privacy officer when not enabled
+ When I log in as "student1"
+ And I follow "Profile" in the user menu
+ Then "Contact the privacy officer" "link" should not exist
--- /dev/null
+@tool @tool_dataprivacy
+Feature: Manage my own data requests
+ In order to manage my own data requests
+ As a user
+ I need to be able to view and cancel all my data requests
+
+ Background:
+ Given the following "users" exist:
+ | username | firstname | lastname | email |
+ | student1 | Student | 1 | s1@example.com |
+ And the following config values are set as admin:
+ | contactdataprotectionofficer | 1 | tool_dataprivacy |
+
+ @javascript
+ Scenario: Cancel my own data request
+ Given I log in as "student1"
+ And I follow "Profile" in the user menu
+ And I click on "Contact the privacy officer" "link"
+ And I set the field "Message" to "Hello DPO!"
+ And I click on "Send" "button" in the "Contact the privacy officer" "dialogue"
+ And I should see "Your request has been submitted to the privacy officer"
+ When I click on "Data requests" "link"
+ And I open the action menu in "Hello DPO!" "table_row"
+ And I choose "Cancel" in the open action menu
+ And I click on "Cancel request" "button" in the "Cancel request" "dialogue"
+ Then I should see "Cancelled" in the "Hello DPO!" "table_row"
This template does not have an example context because it includes ajax functionality.
}}
<div data-region="user-competency-full-info" data-node="user-competency" data-competencyid="{{usercompetency.competencyid}}" data-userid="{{usercompetency.userid}}" data-region-id="{{uniqid}}">
-<div data-region="competency-summary">
- {{#competency}}
- {{> tool_lp/competency_summary }}
- {{/competency}}
- <dl>
- {{#usercompetency}}
- <dt>{{#str}}reviewstatus, tool_lp{{/str}}</dt>
- <dd data-region="user-competency-status">{{statusname}}
+ <div data-region="competency-summary">
+ {{#competency}}
+ {{> tool_lp/competency_summary }}
+ {{/competency}}
+ <dl>
+ {{#usercompetency}}
+ <dt>{{#str}}reviewstatus, tool_lp{{/str}}</dt>
+ <dd data-region="user-competency-status">{{statusname}}
- {{#isstatusinreview}}
- - {{reviewer.fullname}}
- {{/isstatusinreview}}
+ {{#isstatusinreview}}
+ - {{reviewer.fullname}}
+ {{/isstatusinreview}}
- {{#isrequestreviewallowed}}<button class="btn btn-secondary" data-action="request-review">{{#str}}requestreview, tool_lp{{/str}}</button>{{/isrequestreviewallowed}}
- {{#iscancelreviewrequestallowed}}<button class="btn btn-secondary" data-action="cancel-review-request">{{#str}}cancelreviewrequest, tool_lp{{/str}}</button>{{/iscancelreviewrequestallowed}}
- {{#isstartreviewallowed}}<button class="btn btn-secondary" data-action="start-review">{{#str}}startreview, tool_lp{{/str}}</button>{{/isstartreviewallowed}}
- {{#isstopreviewallowed}}<button class="btn btn-secondary" data-action="stop-review">{{#str}}stopreview, tool_lp{{/str}}</button>{{/isstopreviewallowed}}
- </dd>
- <dt>{{#str}}proficient, tool_lp{{/str}}</dt>
- <dd>
- <span class="badge {{#proficiency}}badge-success{{/proficiency}}{{^proficiency}}badge-danger{{/proficiency}} float-left">
- {{proficiencyname}}
- </span>
- </dd>
- <dt>{{#str}}rating, tool_lp{{/str}}</dt>
- <dd>{{gradename}}
- {{#cangrade}}
- <button class="btn btn-secondary" id="rate_{{uniqid}}">{{#str}}rate, tool_lp{{/str}}</button>
- {{/cangrade}}
- </dd>
- {{#js}}
- require(['jquery', 'tool_lp/grade_user_competency_inline', 'tool_lp/user_competency_info', 'tool_lp/user_competency_workflow'], function($, mod, info, UserCompWorkflow) {
+ {{#isrequestreviewallowed}}<button class="btn btn-secondary" data-action="request-review">{{#str}}requestreview, tool_lp{{/str}}</button>{{/isrequestreviewallowed}}
+ {{#iscancelreviewrequestallowed}}<button class="btn btn-secondary" data-action="cancel-review-request">{{#str}}cancelreviewrequest, tool_lp{{/str}}</button>{{/iscancelreviewrequestallowed}}
+ {{#isstartreviewallowed}}<button class="btn btn-secondary" data-action="start-review">{{#str}}startreview, tool_lp{{/str}}</button>{{/isstartreviewallowed}}
+ {{#isstopreviewallowed}}<button class="btn btn-secondary" data-action="stop-review">{{#str}}stopreview, tool_lp{{/str}}</button>{{/isstopreviewallowed}}
+ </dd>
+ <dt>{{#str}}proficient, tool_lp{{/str}}</dt>
+ <dd>
+ <span class="badge {{#proficiency}}badge-success{{/proficiency}}{{^proficiency}}badge-danger{{/proficiency}} float-left">
+ {{proficiencyname}}
+ </span>
+ </dd>
+ <dt>{{#str}}rating, tool_lp{{/str}}</dt>
+ <dd>{{gradename}}
+ {{#cangrade}}
+ <button class="btn btn-secondary" id="rate_{{uniqid}}">{{#str}}rate, tool_lp{{/str}}</button>
+ {{/cangrade}}
+ </dd>
+ {{#js}}
+ require(['jquery', 'tool_lp/grade_user_competency_inline', 'tool_lp/user_competency_info', 'tool_lp/user_competency_workflow'], function($, mod, info, UserCompWorkflow) {
- var competencyElement = $('[data-region-id="{{uniqid}}"]');
- var infoReloader = new info(competencyElement, '{{competency.competency.id}}', '{{user.id}}');
+ var competencyElement = $('[data-region-id="{{uniqid}}"]');
+ var infoReloader = new info(competencyElement, '{{competency.competency.id}}', '{{user.id}}');
- var ucw = new UserCompWorkflow();
- ucw.registerEvents('[data-region="user-competency-status"]');
- ucw.on('status-changed', infoReloader.reload.bind(infoReloader));
- ucw.on('error-occured', infoReloader.reload.bind(infoReloader));
+ var ucw = new UserCompWorkflow();
+ ucw.registerEvents('[data-region="user-competency-status"]');
+ ucw.on('status-changed', infoReloader.reload.bind(infoReloader));
+ ucw.on('error-occured', infoReloader.reload.bind(infoReloader));
- var inlineGrader = new mod('#rate_{{uniqid}}', '{{competency.scaleid}}', '{{competency.competency.id}}', '{{user.id}}', '{{plan.id}}', '', '{{#str}}chooserating, tool_lp{{/str}}');
- inlineGrader.on('competencyupdated', infoReloader.reload.bind(infoReloader));
- });
- {{/js}}
- {{/usercompetency}}
- </dl>
- {{#commentarea}}
- {{#canpostorhascomments}}
- {{>tool_lp/comment_area}}
- {{/canpostorhascomments}}
- {{/commentarea}}
- <dl data-region="evidence-listing">
- <dt>{{#str}}evidence, tool_lp{{/str}}</dt>
- <dd>
- {{#evidence}}
- {{> tool_lp/evidence_summary }}
- {{/evidence}}
- {{^evidence}}
- <p>{{#str}}noevidence, tool_lp{{/str}}</p>
- {{/evidence}}
- </dd>
- </dl>
+ var inlineGrader = new mod('#rate_{{uniqid}}', '{{competency.scaleid}}', '{{competency.competency.id}}', '{{user.id}}', '{{plan.id}}', '', '{{#str}}chooserating, tool_lp{{/str}}');
+ inlineGrader.on('competencyupdated', infoReloader.reload.bind(infoReloader));
+ });
+ {{/js}}
+ {{/usercompetency}}
+ </dl>
+ {{#commentarea}}
+ {{#canpostorhascomments}}
+ {{>tool_lp/comment_area}}
+ {{/canpostorhascomments}}
+ {{/commentarea}}
+ <dl data-region="evidence-listing">
+ <dt>{{#str}}evidence, tool_lp{{/str}}</dt>
+ <dd>
+ {{#evidence}}
+ {{> tool_lp/evidence_summary }}
+ {{/evidence}}
+ {{^evidence}}
+ <p>{{#str}}noevidence, tool_lp{{/str}}</p>
+ {{/evidence}}
+ </dd>
+ </dl>
+ </div>
</div>
if ($issuer) {
$PAGE->navbar->add(get_string('editissuer', 'tool_oauth2', s($issuer->get('name'))));
} else {
- $PAGE->navbar->add(get_string('createnewservice', 'tool_oauth2') . get_string('custom_service', 'tool_oauth2'));
+ $PAGE->navbar->add(get_string('createnewservice', 'tool_oauth2') . ' ' . get_string('custom_service', 'tool_oauth2'));
}
$showrequireconfirm = false;
if ($issuer) {
echo $OUTPUT->heading(get_string('editissuer', 'tool_oauth2', s($issuer->get('name'))));
} else {
- echo $OUTPUT->heading(get_string('createnewservice', 'tool_oauth2') . get_string('custom_service', 'tool_oauth2'));
+ echo $OUTPUT->heading(get_string('createnewservice', 'tool_oauth2') . ' ' . get_string('custom_service', 'tool_oauth2'));
}
$mform->display();
echo $OUTPUT->footer();
redirect($PAGE->url, get_string('changessaved'), null, \core\output\notification::NOTIFY_SUCCESS);
} else {
echo $OUTPUT->header();
- echo $OUTPUT->heading(get_string('createnewservice', 'tool_oauth2') . get_string($type . '_service', 'tool_oauth2'));
+ echo $OUTPUT->heading(get_string('createnewservice', 'tool_oauth2') . ' ' . get_string($type . '_service', 'tool_oauth2'));
$mform->display();
echo $OUTPUT->footer();
}
$mform = new \tool_oauth2\form\issuer(null, ['persistent' => $issuer, 'type' => $type,
'showrequireconfirm' => $showrequireconfirm]);
- $PAGE->navbar->add(get_string('createnewservice', 'tool_oauth2') . get_string($type . '_service', 'tool_oauth2'));
+ $PAGE->navbar->add(get_string('createnewservice', 'tool_oauth2') . ' ' . get_string($type . '_service', 'tool_oauth2'));
echo $OUTPUT->header();
- echo $OUTPUT->heading(get_string('createnewservice', 'tool_oauth2') . get_string($type . '_service', 'tool_oauth2'));
+ echo $OUTPUT->heading(get_string('createnewservice', 'tool_oauth2') . ' ' . get_string($type . '_service', 'tool_oauth2'));
$mform->display();
echo $OUTPUT->footer();
echo $renderer->issuers_table($issuers);
echo $renderer->container_start();
- echo get_string('createnewservice', 'tool_oauth2');
+ echo get_string('createnewservice', 'tool_oauth2') . ' ';
// Google template.
$docs = 'admin/tool/oauth2/issuers/google';
$string['createfromtemplate'] = 'Create an OAuth 2 service from a template';
$string['createfromtemplatedesc'] = 'Choose one of the OAuth 2 service templates below to create an OAuth service with a valid configuration for one of the known service types. This will create the OAuth 2 service, with all the correct end points and parameters required for authentication, though you will still need to enter the client ID and secret for the new service before it can be used.';
$string['createnewendpoint'] = 'Create new endpoint for issuer "{$a}"';
-$string['createnewservice'] = 'Create new service: ';
+$string['createnewservice'] = 'Create new service:';
$string['createnewuserfieldmapping'] = 'Create new user field mapping for issuer "{$a}"';
$string['custom_service'] = 'Custom';
$string['deleteconfirm'] = 'Are you sure you want to delete the identity issuer "{$a}"? Any plugins relying on this issuer will stop working.';
$string['endpointurl'] = 'URL';
$string['facebook_service'] = 'Facebook';
$string['google_service'] = 'Google';
-$string['imsobv2p1_service'] = 'IMS OBv2.1';
+$string['imsobv2p1_service'] = 'OpenBadges';
$string['issuersetup'] = 'Detailed instructions on configuring the common OAuth 2 services';
$string['issuersetuptype'] = 'Detailed instructions on setting up the {$a} OAuth 2 provider';
$string['issueralloweddomains_help'] = 'If set, this setting is a comma separated list of domains that logins will be restricted to when using this provider.';
And I should see "Identity issuer deleted"
And I should not see "Testing service modified"
- Scenario: Create, edit and delete standard service for IMS OBv2.1
- Given I press "IMS OBv2.1"
- And I should see "Create new service: IMS OBv2.1"
+ Scenario: Create, edit and delete standard service for OpenBadges
+ Given I press "OpenBadges"
+ And I should see "Create new service: OpenBadges"
And I set the following fields to these values:
| Client ID | thisistheclientid |
| Client secret | supersecret |
| Service base URL | https://dc.imsglobal.org/ |
When I press "Save changes"
Then I should see "Changes saved"
- And I should see "IMS OBv2.1"
- And "Configured" "icon" should exist in the "IMS OBv2.1" "table_row"
- And "Do not allow login" "icon" should exist in the "IMS OBv2.1" "table_row"
- And "Service discovery successful" "icon" should exist in the "IMS OBv2.1" "table_row"
+ And I should see "OpenBadges"
+ And "Configured" "icon" should exist in the "OpenBadges" "table_row"
+ And "Do not allow login" "icon" should exist in the "OpenBadges" "table_row"
+ And "Service discovery successful" "icon" should exist in the "OpenBadges" "table_row"
And the "src" attribute of "table.admintable th img" "css_element" should contain "IMS-Global-Logo.png"
- And I click on "Configure endpoints" "link" in the "IMS OBv2.1" "table_row"
+ And I click on "Configure endpoints" "link" in the "OpenBadges" "table_row"
And I should see "https://dc.imsglobal.org/.well-known/badgeconnect.json" in the "discovery_endpoint" "table_row"
And I should see "authorization_endpoint"
And I follow "OAuth 2 services"
- And I click on "Configure user field mappings" "link" in the "IMS OBv2.1" "table_row"
+ And I click on "Configure user field mappings" "link" in the "OpenBadges" "table_row"
And I should not see "given_name"
And I should not see "middle_name"
And I follow "OAuth 2 services"
- And I click on "Edit" "link" in the "IMS OBv2.1" "table_row"
+ And I click on "Edit" "link" in the "OpenBadges" "table_row"
And I set the following fields to these values:
| Name | IMS Global |
And I press "Save changes"
$filename = 'tour_export_' . $tour->get_id() . '_' . time() . '.json';
// Force download.
- header('Last-Modified: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT');
- header('Cache-Control: private, must-revalidate, pre-check=0, post-check=0, max-age=0');
- header('Expires: ' . gmdate('D, d M Y H:i:s', 0) . 'GMT');
- header('Pragma: no-cache');
- header('Accept-Ranges: none');
- header('Content-disposition: attachment; filename=' . $filename);
- header('Content-length: ' . strlen($exportstring));
- header('Content-type: text/calendar; charset=utf-8');
-
- echo $exportstring;
- die;
+ send_file($exportstring, $filename, 0, 0, true, true);
}
/**
\core\event\prediction_action_started::create($eventdata)->trigger();
}
+ /**
+ * Get the executed actions.
+ *
+ * Actions could be filtered by actionname.
+ *
+ * @param array $actionnamefilter Limit the results obtained to this list of action names.
+ * @param int $userid the user id. Current user by default.
+ * @return array of actions.
+ */
+ public function get_executed_actions(array $actionnamefilter = null, int $userid = 0): array {
+ global $USER, $DB;
+
+ $conditions[] = "predictionid = :predictionid";
+ $params['predictionid'] = $this->get_prediction_data()->id;
+ if (!$userid) {
+ $userid = $USER->id;
+ }
+ $conditions[] = "userid = :userid";
+ $params['userid'] = $userid;
+ if ($actionnamefilter) {
+ list($actionsql, $actionparams) = $DB->get_in_or_equal($actionnamefilter, SQL_PARAMS_NAMED);
+ $conditions[] = "actionname $actionsql";
+ $params = $params + $actionparams;
+ }
+ return $DB->get_records_select('analytics_prediction_actions', implode(' AND ', $conditions), $params);
+ }
+
/**
* format_calculations
*
$this->assertEquals(2, $DB->count_records('analytics_prediction_actions'));
}
+ /**
+ * Data provider for test_get_executed_actions.
+ *
+ * @return array
+ */
+ public function execute_actions_provider(): array {
+ return [
+ 'Empty actions with no filter' => [
+ [],
+ [],
+ 0
+ ],
+ 'Empty actions with filter' => [
+ [],
+ [\core_analytics\prediction::ACTION_FIXED],
+ 0
+ ],
+ 'Multiple actions with no filter' => [
+ [
+ \core_analytics\prediction::ACTION_FIXED,
+ \core_analytics\prediction::ACTION_FIXED,
+ \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED
+ ],
+ [],
+ 3
+ ],
+ 'Multiple actions applying filter' => [
+ [
+ \core_analytics\prediction::ACTION_FIXED,
+ \core_analytics\prediction::ACTION_FIXED,
+ \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED
+ ],
+ [\core_analytics\prediction::ACTION_FIXED],
+ 2
+ ],
+ 'Multiple actions not applying filter' => [
+ [
+ \core_analytics\prediction::ACTION_FIXED,
+ \core_analytics\prediction::ACTION_FIXED,
+ \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED
+ ],
+ [\core_analytics\prediction::ACTION_NOT_APPLICABLE],
+ 0
+ ],
+ 'Multiple actions with multiple filter' => [
+ [
+ \core_analytics\prediction::ACTION_FIXED,
+ \core_analytics\prediction::ACTION_FIXED,
+ \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED
+ ],
+ [\core_analytics\prediction::ACTION_FIXED, \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED],
+ 3
+ ],
+ ];
+ }
+
+ /**
+ * Tests for get_executed_actions() function.
+ *
+ * @dataProvider execute_actions_provider
+ * @param array $actionstoexecute An array of actions to execute
+ * @param array $actionnamefilter Actions to filter
+ * @param int $returned Number of actions returned
+ *
+ * @covers \core_analytics\prediction::get_executed_actions
+ */
+ public function test_get_executed_actions(array $actionstoexecute, array $actionnamefilter, int $returned) {
+
+ $this->setUser($this->teacher2);
+ list($ignored, $predictions) = $this->model->get_predictions($this->context, true);
+ $prediction = reset($predictions);
+ $target = $this->model->get_target();
+ foreach($actionstoexecute as $action) {
+ $prediction->action_executed($action, $target);
+ }
+
+ $filteredactions = $prediction->get_executed_actions($actionnamefilter);
+ $this->assertCount($returned, $filteredactions);
+ }
+
/**
* test_get_predictions
*/
by updating the lib/db/analytics.php file and bumping the core version.
* Final deprecation - get_analysables(). Please see get_analysables_interator() instead.
get_analysables_iterator() needs to be overridden by the child class.
+* A new function get_executed_actions() has been added to \core_analytics\prediction class
+ to get all (or filtered by action name) executed actions of a prediction
=== 3.8 ===
Then I should not see "Page 2" in the "region-main" "region"
# Mark page 1 complete
- When I click on ".togglecompletion .icon" "css_element"
+ When I toggle the manual completion state of "Page 1"
Then I should see "Page 2" in the "region-main" "region"
$this->add_step(new backup_course_logs_structure_step('course_logs', 'logs.xml'));
// New log stores.
$this->add_step(new backup_course_logstores_structure_step('course_logstores', 'logstores.xml'));
+ // Last access to course logs.
+ $this->add_step(new backup_course_loglastaccess_structure_step('course_loglastaccess', 'loglastaccess.xml'));
}
// Generate the course competencies.
}
}
+/**
+ * Structure step in charge of constructing the loglastaccess.xml file for the course logs.
+ *
+ * This backup step will backup the logs of the user_lastaccess table.
+ */
+class backup_course_loglastaccess_structure_step extends backup_structure_step {
+
+ /**
+ * This function creates the structures for the loglastaccess.xml file.
+ * Expected structure would look like this.
+ * <loglastaccesses>
+ * <loglastaccess id=2>
+ * <userid>5</userid>
+ * <timeaccess>1616887341</timeaccess>
+ * </loglastaccess>
+ * </loglastaccesses>
+ *
+ * @return backup_nested_element
+ */
+ protected function define_structure() {
+
+ // To know if we are including userinfo.
+ $userinfo = $this->get_setting_value('users');
+
+ // Define the structure of logstores container.
+ $lastaccesses = new backup_nested_element('lastaccesses');
+ $lastaccess = new backup_nested_element('lastaccess', array('id'), array('userid', 'timeaccess'));
+
+ // Define build tree.
+ $lastaccesses->add_child($lastaccess);
+
+ // This element should only happen if we are including user info.
+ if ($userinfo) {
+ // Define sources.
+ $lastaccess->set_source_sql('
+ SELECT id, userid, timeaccess
+ FROM {user_lastaccess}
+ WHERE courseid = ?',
+ array(backup::VAR_COURSEID));
+
+ // Define userid annotation to user.
+ $lastaccess->annotate_ids('user', 'userid');
+ }
+
+ // Return the root element (lastaccessess).
+ return $lastaccesses;
+ }
+}
+
/**
* Structure step in charge of constructing the logstores.xml file for the activity logs.
*
$this->add_step(new restore_course_logs_structure_step('course_logs', 'course/logs.xml'));
// New log stores.
$this->add_step(new restore_course_logstores_structure_step('course_logstores', 'course/logstores.xml'));
+ // Last access to course logs.
+ $this->add_step(new restore_course_loglastaccess_structure_step('course_loglastaccess', 'course/loglastaccess.xml'));
}
// Review all the executed tasks having one after_restore method
}
}
+/**
+ * Structure step in charge of restoring the loglastaccess.xml file for the course logs.
+ *
+ * This restore step will rebuild the table for user_lastaccess table.
+ */
+class restore_course_loglastaccess_structure_step extends restore_structure_step {
+
+ /**
+ * Conditionally decide if this step should be executed.
+ *
+ * This function checks the following parameter:
+ *
+ * 1. the loglastaccess.xml file exists
+ *
+ * @return bool true is safe to execute, false otherwise
+ */
+ protected function execute_condition() {
+ // Check it is included in the backup.
+ $fullpath = $this->task->get_taskbasepath();
+ $fullpath = rtrim($fullpath, '/') . '/' . $this->filename;
+ if (!file_exists($fullpath)) {
+ // Not found, can't restore loglastaccess.xml information.
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Return the elements to be processed on restore of loglastaccess.
+ *
+ * @return restore_path_element[] array of elements to be processed on restore.
+ */
+ protected function define_structure() {
+
+ $paths = array();
+ // To know if we are including userinfo.
+ $userinfo = $this->get_setting_value('users');
+
+ if ($userinfo) {
+ $paths[] = new restore_path_element('lastaccess', '/lastaccesses/lastaccess');
+ }
+ // Return the paths wrapped.
+ return $paths;
+ }
+
+ /**
+ * Process the 'lastaccess' elements.
+ *
+ * @param array $data element data
+ */
+ protected function process_lastaccess($data) {
+ global $DB;
+
+ $data = (object)$data;
+
+ $data->courseid = $this->get_courseid();
+ if (!$data->userid = $this->get_mappingid('user', $data->userid)) {
+ return; // Nothing to do, not able to find the user to set the lastaccess time.
+ }
+
+ // Check if record does exist.
+ $exists = $DB->get_record('user_lastaccess', array('courseid' => $data->courseid, 'userid' => $data->userid));
+ if ($exists) {
+ // If the time of last access of the restore is newer, then replace and update.
+ if ($exists->timeaccess < $data->timeaccess) {
+ $exists->timeaccess = $data->timeaccess;
+ $DB->update_record('user_lastaccess', $exists);
+ }
+ } else {
+ $DB->insert_record('user_lastaccess', $data);
+ }
+ }
+}
+
/**
* Structure step in charge of restoring the logstores.xml file for the activity logs.
*
$exists = $DB->record_exists('contentbank_content', $params);
if (!$exists) {
$params['configdata'] = $data->configdata;
+ $params['timemodified'] = time();
+
+ // Trying to map users. Users cannot always be mapped, e.g. when copying.
$params['usercreated'] = $this->get_mappingid('user', $data->usercreated);
+ if (!$params['usercreated']) {
+ // Leave the content creator unchanged when we are restoring the same site.
+ // Otherwise use current user id.
+ if ($this->task->is_samesite()) {
+ $params['usercreated'] = $data->usercreated;
+ } else {
+ $params['usercreated'] = $this->task->get_userid();
+ }
+ }
$params['usermodified'] = $this->get_mappingid('user', $data->usermodified);
- $params['timemodified'] = time();
+ if (!$params['usermodified']) {
+ // Leave the content modifier unchanged when we are restoring the same site.
+ // Otherwise use current user id.
+ if ($this->task->is_samesite()) {
+ $params['usermodified'] = $data->usermodified;
+ } else {
+ $params['usermodified'] = $this->task->get_userid();
+ }
+ }
+
$newitemid = $DB->insert_record('contentbank_content', $params);
$this->set_mapping('contentbank_content', $oldid, $newitemid, true);
}
$user->id
);
$controller->execute_plan();
+ $controller->destroy(); // Unset all structures, close files...
return $controller->get_backupid();
}
This files describes API changes in /backup/*,
information provided here is intended especially for developers.
-=== 4.0 ===
- * Local plugins can now hook into a backup and restore process of grade items by
- using define_grade_item_plugin_structure method (See MDL-69418).
-
=== 3.11 ===
+
* New setting called "Include override permissions" has been implemented. The default
settings is OFF for import, and ON for restore.
+=== 3.10 ===
+
+ * Local plugins can now hook into a backup and restore process of grade items by
+ using define_grade_item_plugin_structure method (See MDL-69418).
+
=== 3.1 ===
* New close() method added to loggers so they can close any open resource. Previously
-@core @core_backup @core_contentbank
+@core @core_backup @core_contentbank @core_h5p @contenttype_h5p @_file_upload @javascript
Feature: Import course content bank content
In order to import content from a course contentbank
As a teacher
I need to confirm that errors will not happen
Background:
- Given the following "courses" exist:
+ Given I log in as "admin"
+ And I am on site homepage
+ And I turn editing mode on
+ And I add the "Navigation" block if not present
+ And I configure the "Navigation" block
+ And I set the following fields to these values:
+ | Page contexts | Display throughout the entire site |
+ And I press "Save changes"
+ And I navigate to "H5P > Manage H5P content types" in site administration
+ And I upload "h5p/tests/fixtures/ipsums.h5p" file to "H5P content type" filemanager
+ And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
+ And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
| Course 2 | C2 | 0 |
| teacher1 | C1 | editingteacher |
| teacher1 | C2 | editingteacher |
And the following "contentbank content" exist:
- | contextlevel | reference | contenttype | user | contentname |
- | Course | C1 | contenttype_h5p | teacher1 | ipsums.h5p |
+ | contextlevel | reference | contenttype | user | contentname | filepath |
+ | Course | C1 | contenttype_h5p | teacher1 | ipsums.h5p | /h5p/tests/fixtures/ipsums.h5p |
+ And I log out
And I log in as "teacher1"
Scenario: Import content bank content to another course
Given I am on "Course 2" course homepage
+ And I expand "Site pages" node
And I click on "Content bank" "link"
And I should not see "ipsums.h5p"
When I import "Course 1" course into "Course 2" course using this options:
+ And I expand "Site pages" node
And I click on "Content bank" "link"
Then I should see "ipsums.h5p"
And I am on "Course 1" course homepage
+ And I expand "Site pages" node
And I click on "Content bank" "link"
And I should see "ipsums.h5p"
Scenario: User could configure not to import content bank
Given I am on "Course 2" course homepage
+ And I expand "Site pages" node
And I click on "Content bank" "link"
And I should not see "ipsums.h5p"
When I import "Course 1" course into "Course 2" course using this options:
| Initial | Include content bank content | 0 |
+ And I expand "Site pages" node
And I click on "Content bank" "link"
Then I should not see "ipsums.h5p"
And I am on "Course 1" course homepage
+ And I expand "Site pages" node
And I click on "Content bank" "link"
And I should see "ipsums.h5p"
$returnurl = new moodle_url('/badges/backpack-connect.php',
['action' => 'authorization', 'sesskey' => sesskey(), 'backpackid' => $backpackid]);
+ // If scope is not passed as parameter, use the issuer supported scopes.
+ if (empty($scope)) {
+ $scope = $issuer->get('scopessupported');
+ }
$client = new core_badges\oauth2\client($issuer, $returnurl, $scope, $externalbackpack);
if ($client) {
if (!$client->is_logged_in()) {
$mform->addElement('text', 'backpackapiurl', get_string('backpackapiurl', 'core_badges'));
$mform->setType('backpackapiurl', PARAM_URL);
$mform->addRule('backpackapiurl', null, 'required', null, 'client');
- $mform->addRule('backpackapiurl', get_string('maximumchars', '', 255), 'maxlength', 50, 'client');
+ $mform->addRule('backpackapiurl', get_string('maximumchars', '', 255), 'maxlength', 255, 'client');
$mform->addElement('text', 'backpackweburl', get_string('backpackweburl', 'core_badges'));
$mform->setType('backpackweburl', PARAM_URL);
$mform->addRule('backpackweburl', null, 'required', null, 'client');
- $mform->addRule('backpackweburl', get_string('maximumchars', '', 255), 'maxlength', 50, 'client');
+ $mform->addRule('backpackweburl', get_string('maximumchars', '', 255), 'maxlength', 255, 'client');
$apiversions = badges_get_badge_api_versions();
$mform->addElement('select', 'apiversion', get_string('apiversion', 'core_badges'), $apiversions);
$callbackurl = self::callback_url();
if ($granttype == 'authorization_code') {
+ $this->basicauth = true;
$params = array('code' => $code,
'grant_type' => $granttype,
'redirect_uri' => $callbackurl->out(false),
);
}
if ($this->basicauth) {
- $idsecret = urlencode($this->clientid) . ':' . urlencode($this->clientsecret);
+ $idsecret = $this->clientid . ':' . $this->clientsecret;
$this->setHeader('Authorization: Basic ' . base64_encode($idsecret));
} else {
$params['client_id'] = $this->clientid;
}
// Requests can either use http GET or POST.
$response = $this->post($this->token_url(), $this->build_post_data($params));
- $r = json_decode($response);
if ($this->info['http_code'] !== 200) {
- throw new moodle_exception('Could not upgrade oauth token');
+ $debuginfo = !empty($this->error) ? $this->error : $response;
+ throw new moodle_exception('oauth2refreshtokenerror', 'core_error', '', $this->info['http_code'], $debuginfo);
}
+ $r = json_decode($response);
+
if (is_null($r)) {
throw new moodle_exception("Could not decode JSON token response");
}
return array($join, $where, $params);
} else {
// User is a member of ALL of the specified cohorts.
- $join = " LEFT JOIN {cohort_members} cm ON cm.userid = u.id";
+ $join = ' LEFT JOIN {cohort_members} cm ON cm.userid = u.id';
+ $where = ' AND (';
$i = 0;
foreach ($this->params as $param) {
- $i++;
- $where = ' AND cm.cohortid = :cohortid'.$i;
+ if ($i == 0) {
+ $where .= 'cm.cohortid = :cohortid'.$i;
+ } else {
+ $where .= ' OR cm.cohortid = :cohortid'.$i;
+ }
$params['cohortid'.$i] = $param['cohort'];
+ $i++;
}
+ $where .= ')
+ GROUP BY u.id, bi.badgeid
+ HAVING COUNT(cm.cohortid) = :cohortcount';
+ $params['cohortcount'] = $i;
+
return array($join, $where, $params);
}
}
if (!empty($CFG->badges_allowexternalbackpack) && ($expiration > $now)
&& $userbackpack = badges_get_user_backpack($USER->id)) {
- $assertion = null;
if (badges_open_badges_backpack_api($userbackpack->id) == OPEN_BADGES_V2P1) {
$assertion = new moodle_url('/badges/backpack-export.php', array('hash' => $ibadge->hash));
} else {
$assertion = new moodle_url('/badges/backpack-add.php', array('hash' => $ibadge->hash));
}
- if (!is_null(assertion)) {
- $attributes = ['class' => 'btn btn-secondary m-1', 'role' => 'button'];
- $tobackpack = html_writer::link($assertion, get_string('addtobackpack', 'badges'), $attributes);
- $output .= $tobackpack;
- }
+ $attributes = ['class' => 'btn btn-secondary m-1', 'role' => 'button'];
+ $tobackpack = html_writer::link($assertion, get_string('addtobackpack', 'badges'), $attributes);
+ $output .= $tobackpack;
}
}
$output .= html_writer::end_tag('div');
}
/**
- * Test badges observer when cohort_member_added event is fired.
+ * Test badges observer when cohort_member_added event is fired and user required to belong to any cohort.
+ *
+ * @covers award_criteria_cohort
*/
- public function test_badges_observer_cohort_criteria_review() {
+ public function test_badges_observer_any_cohort_criteria_review() {
global $CFG;
require_once("$CFG->dirroot/cohort/lib.php");
- $cohort = $this->getDataGenerator()->create_cohort();
+ $cohort1 = $this->getDataGenerator()->create_cohort();
+ $cohort2 = $this->getDataGenerator()->create_cohort();
$this->preventResetByRollback(); // Messaging is not compatible with transactions.
+
$badge = new badge($this->badgeid);
$this->assertFalse($badge->is_issued($this->user->id));
+ $this->assertSame(0, $badge->review_all_criteria()); // Verify award_criteria_cohort->get_completed_criteria_sql().
// Set up the badge criteria.
$criteriaoverall = award_criteria::build(array('criteriatype' => BADGE_CRITERIA_TYPE_OVERALL, 'badgeid' => $badge->id));
$criteriaoverall->save(array('agg' => BADGE_CRITERIA_AGGREGATION_ANY));
$criteriaoverall1 = award_criteria::build(array('criteriatype' => BADGE_CRITERIA_TYPE_COHORT, 'badgeid' => $badge->id));
- $criteriaoverall1->save(array('agg' => BADGE_CRITERIA_AGGREGATION_ANY, 'cohort_cohorts' => array('0' => $cohort->id)));
-
- // Make the badge active.
+ $criteriaoverall1->save(array('agg' => BADGE_CRITERIA_AGGREGATION_ANY,
+ 'cohort_cohorts' => array('0' => $cohort1->id, '1' => $cohort2->id)));
$badge->set_status(BADGE_STATUS_ACTIVE);
+ // Reload it to contain criteria.
+ $badge = new badge($this->badgeid);
+ $this->assertFalse($badge->is_issued($this->user->id));
+ $this->assertSame(0, $badge->review_all_criteria()); // Verify award_criteria_cohort->get_completed_criteria_sql().
+
// Add the user to the cohort.
- cohort_add_member($cohort->id, $this->user->id);
+ cohort_add_member($cohort2->id, $this->user->id);
+ $this->assertDebuggingCalled();
// Verify that the badge was awarded.
- $this->assertDebuggingCalled();
$this->assertTrue($badge->is_issued($this->user->id));
+ // As the badge has been awarded to user because core_badges_observer been called when the member has been added to the
+ // cohort, there are no other users that can award this badge.
+ $this->assertSame(0, $badge->review_all_criteria()); // Verify award_criteria_cohort->get_completed_criteria_sql().
+ }
+
+ /**
+ * Test badges observer when cohort_member_added event is fired and user required to belong to multiple (all) cohorts.
+ *
+ * @covers award_criteria_cohort
+ */
+ public function test_badges_observer_all_cohort_criteria_review() {
+ global $CFG;
+
+ require_once("$CFG->dirroot/cohort/lib.php");
+
+ $cohort1 = $this->getDataGenerator()->create_cohort();
+ $cohort2 = $this->getDataGenerator()->create_cohort();
+ $cohort3 = $this->getDataGenerator()->create_cohort();
+
+ // Add user2 to cohort1 and cohort3.
+ $user2 = $this->getDataGenerator()->create_user();
+ cohort_add_member($cohort3->id, $user2->id);
+ cohort_add_member($cohort1->id, $user2->id);
+
+ // Add user3 to cohort1, cohort2 and cohort3.
+ $user3 = $this->getDataGenerator()->create_user();
+ cohort_add_member($cohort1->id, $user3->id);
+ cohort_add_member($cohort2->id, $user3->id);
+ cohort_add_member($cohort3->id, $user3->id);
+
+ $this->preventResetByRollback(); // Messaging is not compatible with transactions.
+
+ // Cohort criteria are used in site badges.
+ $badge = new badge($this->badgeid);
+ $this->assertFalse($badge->is_issued($this->user->id));
+ $this->assertSame(0, $badge->review_all_criteria()); // Verify award_criteria_cohort->get_completed_criteria_sql().
+
+ // Set up the badge criteria.
+ $criteriaoverall = award_criteria::build(array('criteriatype' => BADGE_CRITERIA_TYPE_OVERALL, 'badgeid' => $badge->id));
+ $criteriaoverall->save(array('agg' => BADGE_CRITERIA_AGGREGATION_ANY));
+ $criteriaoverall1 = award_criteria::build(array('criteriatype' => BADGE_CRITERIA_TYPE_COHORT, 'badgeid' => $badge->id));
+ $criteriaoverall1->save(array('agg' => BADGE_CRITERIA_AGGREGATION_ALL,
+ 'cohort_cohorts' => array('0' => $cohort1->id, '1' => $cohort2->id, '2' => $cohort3->id)));
+ $badge->set_status(BADGE_STATUS_ACTIVE);
+
+ // Reload it to contain criteria.
+ $badge = new badge($this->badgeid);
+
+ // Verify that the badge was not awarded yet (ALL cohorts are needed and review_all_criteria has to be called).
+ $this->assertFalse($badge->is_issued($this->user->id));
+ $this->assertFalse($badge->is_issued($user2->id));
+ $this->assertFalse($badge->is_issued($user3->id));
+
+ // Verify that after calling review_all_criteria, users with the criteria (user3) award the badge instantly.
+ $this->assertSame(1, $badge->review_all_criteria()); // Verify award_criteria_cohort->get_completed_criteria_sql().
+ $this->assertFalse($badge->is_issued($this->user->id));
+ $this->assertFalse($badge->is_issued($user2->id));
+ $this->assertTrue($badge->is_issued($user3->id));
+ $this->assertDebuggingCalled();
+
+ // Add the user to the cohort1.
+ cohort_add_member($cohort1->id, $this->user->id);
+
+ // Verify that the badge was not awarded yet (ALL cohorts are needed).
+ $this->assertFalse($badge->is_issued($this->user->id));
+ $this->assertSame(0, $badge->review_all_criteria()); // Verify award_criteria_cohort->get_completed_criteria_sql().
+
+ // Add the user to the cohort3.
+ cohort_add_member($cohort3->id, $this->user->id);
+
+ // Verify that the badge was not awarded yet (ALL cohorts are needed).
+ $this->assertFalse($badge->is_issued($this->user->id));
+ $this->assertSame(0, $badge->review_all_criteria()); // Verify award_criteria_cohort->get_completed_criteria_sql().
+
+ // Add user to cohort2.
+ cohort_add_member($cohort2->id, $this->user->id);
+ $this->assertDebuggingCalled();
+
+ // Verify that the badge was awarded (ALL cohorts).
+ $this->assertTrue($badge->is_issued($this->user->id));
+ // As the badge has been awarded to user because core_badges_observer been called when the member has been added to the
+ // cohort, there are no other users that can award this badge.
+ $this->assertSame(0, $badge->review_all_criteria()); // Verify award_criteria_cohort->get_completed_criteria_sql().
}
/**
And I click on "Course 1" "link" in the "region-main" "region"
Then I should not see "badges"
And I am on "Course 1" course homepage
- And I click on "Not completed: Test assignment name" "icon"
+ And I toggle the manual completion state of "Test assignment name"
And I follow "Profile" in the user menu
And I click on "Course 1" "link" in the "region-main" "region"
Then I should see "Course Badge"
And I click on "Course 1" "link" in the "region-main" "region"
Then I should not see "badges"
And I am on "Course 1" course homepage
- And I click on "Not completed: Test assignment name" "icon"
+ And I toggle the manual completion state of "Test assignment name"
And I log out
# Completion cron won't mark the whole course completed unless the
# individual criteria was marked completed more than a second ago. So
Scenario: Student earns a badge using activity completion, but does not get passing grade
When I log in as "student1"
And I am on "Course 1" course homepage
- And the "Test quiz name" "quiz" activity with "auto" completion should be marked as not complete
+ And the "Receive a grade" completion condition of "Test quiz name" is displayed as "failed"
+ And the "Receive a pass grade or complete all available attempts" completion condition of "Test quiz name" is displayed as "todo"
And I follow "Test quiz name"
And I press "Re-attempt quiz"
And I set the field "False" to "1"
* @return string
*/
public function get_type_title() {
- return get_string('grade');
+ return get_string('gradenoun');
}
/**
);
}
+ /**
+ * Checks if the activity with specified name is maked as complete.
+ *
+ * @Given /^the "(?P<conditionname>(?:[^"]|\\")*)" completion condition of "(?P<activityname>(?:[^"]|\\")*)" is displayed as "(?P<completionstatus>(?:[^"]|\\")*)"$/
+ * @param string $conditionname The completion condition text.
+ * @param string $activityname The activity name.
+ * @param string $completionstatus The completion status. Must be either of the following: 'todo', 'done', 'failed'.
+ */
+ public function activity_completion_condition_displayed_as(string $conditionname, string $activityname,
+ string $completionstatus): void {
+
+ if (!in_array($completionstatus, ['todo', 'done', 'failed'])) {
+ throw new coding_exception('Invalid completion status. It must be of type "todo", "done", or "failed".');
+ }
+
+ $text = get_string("completion_automatic:$completionstatus", 'core_course') . ' ' . $conditionname;
+
+ $conditionslistlabel = get_string('completionrequirements', 'core_course', $activityname);
+ $selector = "div[aria-label='$conditionslistlabel']";
+
+ $this->execute("behat_general::assert_element_contains_text", [$text, $selector, "css_element"]);
+ }
+
+ /**
+ * Checks if the activity with specified name is maked as complete.
+ *
+ * @Given /^the "(?P<conditionname>(?:[^"]|\\")*)" completion condition of "(?P<activityname>(?:[^"]|\\")*)" overridden by "(?P<username>(?:[^"]|\\")*)" is displayed as "(?P<completionstatus>(?:[^"]|\\")*)"$/
+ * @param string $conditionname The completion condition text.
+ * @param string $activityname The activity name.
+ * @param string $username The full name of the user overriding the student's activity completion.
+ * @param string $completionstatus The override completion status. Must be either of the following: 'todo', 'done'.
+ */
+ public function overridden_activity_completion_condition_displayed_as(string $conditionname, string $activityname,
+ string $username, string $completionstatus): void {
+ if (!in_array($completionstatus, ['todo', 'done'])) {
+ throw new coding_exception('Invalid override completion status. It must be of type "todo" or "done".');
+ }
+
+ $conditionlabel = get_string('completion_setby:auto:' . $completionstatus, 'core_course', (object)[
+ 'condition' => $conditionname,
+ 'setby' => $username,
+ ]);
+ $conditionbadge = "span[aria-label='$conditionlabel']";
+
+ $conditionslistlabel = get_string('completionrequirements', 'core_course', $activityname);
+ $completionconditions = "div[aria-label='$conditionslistlabel']";
+
+ $params = [$conditionbadge, 'css_element', $completionconditions, 'css_element'];
+ $this->execute("behat_general::should_exist_in_the", $params);
+ }
+
+ /**
+ * Checks the manual completion state of an activity.
+ *
+ * @Given /^the manual completion button of "(?P<activityname>(?:[^"]|\\")*)" is displayed as "(?P<completionstatus>(?:[^"]|\\")*)"$/
+ * @param string $activityname The activity name.
+ * @param string $completionstatus The completion status shown on the manual completion button.
+ * Must be either 'Mark as done' or 'Done'.
+ */
+ public function manual_completion_button_displayed_as(string $activityname, string $completionstatus): void {
+ if (!in_array($completionstatus, ['Mark as done', 'Done'])) {
+ throw new coding_exception('Invalid completion status. It must be "Mark as done" or "Done".');
+ }
+
+ $langstringkey = $completionstatus === 'Done' ? 'done' : 'markdone';
+ $conditionslistlabel = get_string('completion_manual:aria:' . $langstringkey, 'core_course', $activityname);
+ $selector = "button[aria-label='$conditionslistlabel']";
+
+ $this->execute("behat_general::assert_element_contains_text", [$completionstatus, $selector, "css_element"]);
+ }
+
+ /**
+ * Checks the manual completion state of an activity.
+ *
+ * @Given /^the manual completion button of "(?P<activityname>(?:[^"]|\\")*)" overridden by "(?P<username>(?:[^"]|\\")*)" is displayed as "(?P<completionstatus>(?:[^"]|\\")*)"$/
+ * @param string $activityname The activity name.
+ * @param string $username The full name of the user overriding the student's activity completion.
+ * @param string $completionstatus The completion status shown on the manual completion button.
+ * Must be either 'Mark as done' or 'Done'.
+ */
+ public function overridden_manual_completion_button_displayed_as(string $activityname, string $username,
+ string $completionstatus): void {
+ if (!in_array($completionstatus, ['Mark as done', 'Done'])) {
+ throw new coding_exception('Invalid completion status. It must be "Mark as done" or "Done".');
+ }
+
+ $langstringkey = $completionstatus === 'Done' ? 'done' : 'markdone';
+ $conditionslistlabel = get_string('completion_setby:manual:' . $langstringkey, 'core_course', (object)[
+ 'activityname' => $activityname,
+ 'setby' => $username,
+ ]);
+ $selector = "button[aria-label='$conditionslistlabel']";
+
+ $this->execute("behat_general::assert_element_contains_text", [$completionstatus, $selector, "css_element"]);
+ }
+
+ /**
+ * Toggles the manual completion button for a given activity.
+ *
+ * @Given /^I toggle the manual completion state of "(?P<activityname>(?:[^"]|\\")*)"$/
+ * @param string $activityname The activity name.
+ */
+ public function toggle_the_manual_completion_state(string $activityname): void {
+ $selector = "button[data-action=toggle-manual-completion][data-activityname='{$activityname}']";
+
+ $this->execute("behat_general::i_click_on", [$selector, "css_element"]);
+ }
+
+ /**
+ * Check that the activity does show completion information.
+ *
+ * @Given /^there should be no completion information shown for "(?P<activityname>(?:[^"]|\\")*)"$/
+ * @param string $activityname The activity name.
+ */
+ public function there_should_be_no_completion_for_activity(string $activityname): void {
+ $containerselector = "div[data-region=activity-information][data-activityname='$activityname']";
+ try {
+ $this->find('css_element', $containerselector);
+ } catch (ElementNotFoundException $e) {
+ // If activity information container does not exist (activity dates not shown, completion info not shown), all good.
+ return;
+ }
+
+ // Otherwise, ensure that the completion information does not exist.
+ $elementselector = "div[data-region=completion-info]";
+ $params = [$elementselector, "css_element", $containerselector, "css_element"];
+ $this->execute("behat_general::should_not_exist_in_the", $params);
+ }
+
+ /**
+ * Check that the manual completion button for the activity is disabled.
+ *
+ * @Given /^the manual completion button for "(?P<activityname>(?:[^"]|\\")*)" should be disabled$/
+ * @param string $activityname The activity name.
+ */
+ public function the_manual_completion_button_for_activity_should_be_disabled(string $activityname): void {
+ $selector = "div[data-activityname='$activityname'] button";
+
+ $params = [$selector, "css_element"];
+ $this->execute("behat_general::the_element_should_be_disabled", $params);
+ }
+
+ /**
+ * Check that the activity has the given automatic completion condition.
+ *
+ * @Given /^"(?P<activityname>(?:[^"]|\\")*)" should have the "(?P<conditionname>(?:[^"]|\\")*)" completion condition$/
+ * @param string $activityname The activity name.
+ * @param string $conditionname The automatic condition name.
+ */
+ public function activity_should_have_the_completion_condition(string $activityname, string $conditionname): void {
+ $containerselector = "div[data-region=activity-information][data-activityname='$activityname']";
+
+ $params = [$conditionname, $containerselector, 'css_element'];
+ $this->execute("behat_general::assert_element_contains_text", $params);
+ }
+
/**
* Checks if the activity with specified name shows a information completion checkbox (i.e. showing the completion tracking
* configuration).
Feature: Show activity completion status or activity completion configuration on the course page
In order to understand the configuration or status of an activity's completion
As a user
- I want to see an appropriate checkbox icon besides the activity
+ I need to see the appropriate completion information for each activity in the course homepage
Background:
Given the following "courses" exist:
Given I log in as "student1"
And I am on "Course 1" course homepage
Then I should see "Your progress"
- And the "Test forum name" "Forum" activity with "manual" completion shows a status completion checkbox
- And the "Test assignment name" "Assign" activity with "auto" completion shows a status completion checkbox
- And the "Test quiz name" "Quiz" activity does not show any completion checkbox
+ And the manual completion button of "Test forum name" is displayed as "Mark as done"
+ And the "View" completion condition of "Test assignment name" is displayed as "todo"
+ And there should be no completion information shown for "Test quiz name"
Scenario: Show completion configuration to editing teachers
Given I log in as "teacher1"
And I am on "Course 1" course homepage
Then I should not see "Your progress"
- And the "Test forum name" "Forum" activity with "manual" completion shows a configuration completion checkbox
- And the "Test assignment name" "Assign" activity with "auto" completion shows a configuration completion checkbox
- And the "Test quiz name" "Quiz" activity does not show any completion checkbox
+ And the manual completion button for "Test forum name" should be disabled
+ And "Test assignment name" should have the "View" completion condition
+ And there should be no completion information shown for "Test quiz name"
And I am on "Course 1" course homepage with editing mode on
And I should not see "Your progress"
- And the "Test forum name" "Forum" activity with "manual" completion shows a configuration completion checkbox
- And the "Test assignment name" "Assign" activity with "auto" completion shows a configuration completion checkbox
- And the "Test quiz name" "Quiz" activity does not show any completion checkbox
+ And the manual completion button for "Test forum name" should be disabled
+ And "Test assignment name" should have the "View" completion condition
+ And there should be no completion information shown for "Test quiz name"
Scenario: Show completion configuration to non-editing teachers
Given I log in as "teacher2"
And I am on "Course 1" course homepage
Then I should not see "Your progress"
- And the "Test forum name" "Forum" activity with "manual" completion shows a configuration completion checkbox
- And the "Test assignment name" "Assign" activity with "auto" completion shows a configuration completion checkbox
- And the "Test quiz name" "Quiz" activity does not show any completion checkbox
+ And the manual completion button for "Test forum name" should be disabled
+ And "Test assignment name" should have the "View" completion condition
+ And there should be no completion information shown for "Test quiz name"
Scenario: Completion condition displaying for manual and auto completion
Given I log in as "teacher1"
- And I am on "Course 1" course homepage
+ When I am on "Course 1" course homepage
+ # The manual completion toggle button should be always displayed in both course homepage and activity view.
+ Then the manual completion button for "Test choice manual" should be disabled
And I follow "Test choice manual"
- And I should see "Mark as done"
+ And the manual completion button for "Test choice manual" should be disabled
+ # Automatic completion conditions should be displayed on both activity view page and course homepage if show completion conditions is enabled.
And I am on "Course 1" course homepage
- When I follow "Test choice auto"
- Then I should see "Make a choice" in the "[data-region=completionrequirements]" "css_element"
- # TODO MDL-70821: Check completion conditions display on course homepage.
+ And "Test choice auto" should have the "Make a choice" completion condition
+ And I follow "Test choice auto"
+ And "Test choice auto" should have the "Make a choice" completion condition
Scenario: Completion condition displaying setting can be disabled at course level
Given I log in as "teacher1"
When I set the following fields to these values:
| Show completion conditions | No |
And I click on "Save and display" "button"
+ # Automatic completion conditions should not be displayed on the course homepage if show completion conditions is disabled.
+ And there should be no completion information shown for "Test choice auto"
+ # Completion conditions are always shown in the module's view page.
And I follow "Test choice auto"
- # Completion conditions are always shown in the module's view page.
- Then I should see "Make a choice" in the "[data-region=completionrequirements]" "css_element"
+ Then "Test choice auto" should have the "Make a choice" completion condition
+ # The manual completion toggle button should be always displayed in both course homepage and activity view.
And I am on "Course 1" course homepage
+ And the manual completion button for "Test choice manual" should be disabled
And I follow "Test choice manual"
- And I should see "Mark as done"
+ And the manual completion button for "Test choice manual" should be disabled
Scenario: Default show completion conditions value in course form when default show completion conditions admin setting is set to No
Given I log in as "admin"
And I log out
And I log in as "student1"
And I am on "Course 1" course homepage
- When I click on "Not completed: Test forum name. Select to mark as complete." "icon"
- Then the "Test forum name" "forum" activity with "manual" completion should be marked as complete
+ When I toggle the manual completion state of "Test forum name"
+ Then the manual completion button of "Test forum name" is displayed as "Done"
And I log out
And I log in as "teacher1"
And I am on "Course 1" course homepage
And I am on "Course 1" course homepage
Then I should see "Not available unless: The activity Test label is marked complete"
And I should not see "Test page name"
- And I click on "Not completed: Test label. Select to mark as complete." "icon"
+ And I toggle the manual completion state of "Test label"
And I should see "Test page name"
And I should not see "Not available unless: The activity Test label is marked complete"
This files describes API changes in /completion/* - completion,
information provided here is intended especially for developers.
+=== 3.11 ===
+* New Behat steps for activity completion in the behat_completion class:
+ - activity_completion_condition_displayed_as()
+ - Given the "<Completion condition>" completion condition of "<Activity name>" is displayed as "<Status>"
+ - activity_should_have_the_completion_condition()
+ - Given "<Activity name>" should have the "<Condition name>" completion condition
+ - manual_completion_button_displayed_as()
+ - Given the manual completion button of "<Activity name>" is displayed as "<Status>"
+ - the_manual_completion_button_for_activity_should_be_disabled()
+ - Given the manual completion button for "<Activity name>" should be disabled
+ - there_should_be_no_completion_for_activity()
+ - Given there should be no completion information shown for "<Activity name>"
+ - toggle_the_manual_completion_state()
+ - Given I toggle the manual completion state of "<Activity name>"
+ - overridden_manual_completion_button_displayed_as
+ - Given the manual completion button of "<Activity name>" overridden by "<User>" is displayed as "<Status>"
+ - overridden_activity_completion_condition_displayed_as
+ - Given the "<Completion condition>" completion condition of "<Activity name>" overridden by "<User>" is displayed as "<Status>"
+
=== 3.7 ===
* External function core_completion_external::get_activities_completion_status new returns the following additional field:
- valueused (indicates whether the completion state affects the availability of other content)
--- /dev/null
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Module to handle AJAX interactions with content bank upload files.
+ *
+ * @module core_contentbank/upload
+ * @copyright 2021 Sara Arjona <sara@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+import ModalForm from 'core_form/modalform';
+import {get_string as getString} from 'core/str';
+
+/**
+ * Initialize upload files to the content bank form as Modal form.
+ *
+ * @param {String} elementSelector
+ * @param {String} formClass
+ * @param {Integer} contextId
+ * @param {Integer} contentId
+ */
+export const initModal = (elementSelector, formClass, contextId, contentId) => {
+ const element = document.querySelector(elementSelector);
+ element.addEventListener('click', function(e) {
+ e.preventDefault();
+ const form = new ModalForm({
+ formClass,
+ args: {
+ contextid: contextId,
+ id: contentId,
+ },
+ modalConfig: {title: getString('upload', 'contentbank')},
+ returnFocus: e.target,
+ });
+ form.addEventListener(form.events.FORM_SUBMITTED, (event) => {
+ document.location = event.detail.returnurl;
+ });
+ form.show();
+ });
+};
--- /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/>.
+
+namespace core_contentbank\form;
+
+/**
+ * Upload files to content bank form
+ *
+ * @package core_contentbank
+ * @copyright 2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class upload_files extends \core_form\dynamic_form {
+
+ /**
+ * Add elements to this form.
+ */
+ public function definition() {
+ $mform = $this->_form;
+
+ $mform->addElement('hidden', 'contextid');
+ $mform->setType('contextid', PARAM_INT);
+
+ $mform->addElement('hidden', 'id');
+ $mform->setType('id', PARAM_INT);
+
+ $mform->addElement('filepicker', 'file', get_string('file', 'core_contentbank'), null, $this->get_options());
+ $mform->addHelpButton('file', 'file', 'core_contentbank');
+ $mform->addRule('file', null, 'required');
+ }
+
+ /**
+ * Validate incoming data.
+ *
+ * @param array $data
+ * @param array $files
+ * @return array
+ */
+ public function validation($data, $files) {
+ $errors = array();
+ $draftitemid = $data['file'];
+ $options = $this->get_options();
+ if (file_is_draft_area_limit_reached($draftitemid, $options['areamaxbytes'])) {
+ $errors['file'] = get_string('userquotalimit', 'error');
+ }
+ return $errors;
+ }
+
+ /**
+ * Check if current user has access to this form, otherwise throw exception
+ *
+ * Sometimes permission check may depend on the action and/or id of the entity.
+ * If necessary, form data is available in $this->_ajaxformdata or
+ * by calling $this->optional_param()
+ */
+ protected function check_access_for_dynamic_submission(): void {
+ require_capability('moodle/contentbank:upload', $this->get_context_for_dynamic_submission());
+
+ // Check the context used by the content bank is allowed.
+ $cb = new \core_contentbank\contentbank();
+ if (!$cb->is_context_allowed($this->get_context_for_dynamic_submission())) {
+ throw new \moodle_exception('contextnotallowed', 'core_contentbank');
+ }
+
+ // If $id is defined, the file content will be replaced (instead of uploading a new one).
+ // Check that the user has the right permissions to replace this content file.
+ $id = $this->optional_param('id', null, PARAM_INT);
+ if ($id) {
+ $content = $cb->get_content_from_id($id);
+ $contenttype = $content->get_content_type_instance();
+ if (!$contenttype->can_manage($content) || !$contenttype->can_upload()) {
+ throw new \moodle_exception('nopermissions', 'error', '', null, get_string('replacecontent', 'contentbank'));
+ }
+ }
+ }
+
+ /**
+ * Returns form context
+ *
+ * If context depends on the form data, it is available in $this->_ajaxformdata or
+ * by calling $this->optional_param()
+ *
+ * @return \context
+ */
+ protected function get_context_for_dynamic_submission(): \context {
+ $contextid = $this->optional_param('contextid', null, PARAM_INT);
+ return \context::instance_by_id($contextid, MUST_EXIST);
+ }
+
+ /**
+ * File upload options
+ *
+ * @return array
+ * @throws \coding_exception
+ */
+ protected function get_options(): array {
+ global $CFG;
+
+ $maxbytes = $CFG->userquota;
+ $maxareabytes = $CFG->userquota;
+ if (has_capability('moodle/user:ignoreuserquota', $this->get_context_for_dynamic_submission())) {
+ $maxbytes = USER_CAN_IGNORE_FILE_SIZE_LIMITS;
+ $maxareabytes = FILE_AREA_MAX_BYTES_UNLIMITED;
+ }
+
+ $cb = new \core_contentbank\contentbank();
+ $id = $this->optional_param('id', null, PARAM_INT);
+ if ($id) {
+ $content = $cb->get_content_from_id($id);
+ $contenttype = $content->get_content_type_instance();
+ $extensions = $contenttype->get_manageable_extensions();
+ $acceptedtypes = implode(',', $extensions);
+ } else {
+ $acceptedtypes = $cb->get_supported_extensions_as_string($this->get_context_for_dynamic_submission());
+ }
+
+ return ['subdirs' => 1, 'maxbytes' => $maxbytes, 'maxfiles' => -1, 'accepted_types' => $acceptedtypes,
+ 'areamaxbytes' => $maxareabytes];
+ }
+
+ /**
+ * Process the form submission, used if form was submitted via AJAX
+ *
+ * This method can return scalar values or arrays that can be json-encoded, they will be passed to the caller JS.
+ *
+ * Submission data can be accessed as: $this->get_data()
+ *
+ * @return mixed
+ */
+ public function process_dynamic_submission() {
+ global $USER;
+
+ // Get the file and create the content based on it.
+ $usercontext = \context_user::instance($USER->id);
+ $fs = get_file_storage();
+ $files = $fs->get_area_files($usercontext->id, 'user', 'draft', $this->get_data()->file, 'itemid, filepath,
+ filename', false);
+ if (!empty($files)) {
+ $file = reset($files);
+ $cb = new \core_contentbank\contentbank();
+ try {
+ if ($this->get_data()->id) {
+ $content = $cb->get_content_from_id($this->get_data()->id);
+ $contenttype = $content->get_content_type_instance();
+ $content = $contenttype->replace_content($file, $content);
+ } else {
+ $content = $cb->create_content_from_file($this->get_context_for_dynamic_submission(), $USER->id, $file);
+ }
+ $params = ['id' => $content->get_id(), 'contextid' => $this->get_context_for_dynamic_submission()->id];
+ $url = new \moodle_url('/contentbank/view.php', $params);
+ } catch (\Exception $e) {
+ // Redirect to the right page (depending on if content is new or existing) and display an error.
+ if ($this->get_data()->id) {
+ $content = $cb->get_content_from_id($this->get_data()->id);
+ $params = [
+ 'id' => $content->get_id(),
+ 'contextid' => $this->get_context_for_dynamic_submission()->id,
+ 'errormsg' => 'notvalidpackage',
+ ];
+ $url = new \moodle_url('/contentbank/view.php', $params);
+ } else {
+ $url = new \moodle_url('/contentbank/index.php', [
+ 'contextid' => $this->get_context_for_dynamic_submission()->id,
+ 'errormsg' => 'notvalidpackage'],
+ );
+ }
+ }
+
+ return ['returnurl' => $url->out(false)];
+ }
+
+ return null;
+ }
+
+ /**
+ * Load in existing data as form defaults
+ *
+ * Can be overridden to retrieve existing values from db by entity id and also
+ * to preprocess editor and filemanager elements
+ *
+ * Example:
+ * $this->set_data(get_entity($this->_ajaxformdata['id']));
+ */
+ public function set_data_for_dynamic_submission(): void {
+ $data = (object)[
+ 'contextid' => $this->optional_param('contextid', null, PARAM_INT),
+ 'id' => $this->optional_param('id', null, PARAM_INT),
+ ];
+ $this->set_data($data);
+ }
+
+ /**
+ * Returns url to set in $PAGE->set_url() when form is being rendered or submitted via AJAX
+ *
+ * This is used in the form elements sensitive to the page url, such as Atto autosave in 'editor'
+ *
+ * If the form has arguments (such as 'id' of the element being edited), the URL should
+ * also have respective argument.
+ *
+ * @return \moodle_url
+ */
+ protected function get_page_url_for_dynamic_submission(): \moodle_url {
+ $params = ['contextid' => $this->get_context_for_dynamic_submission()->id];
+
+ $id = $this->optional_param('id', null, PARAM_INT);
+ if ($id) {
+ $url = '/contentbank/view.php';
+ $params['id'] = $id;
+ } else {
+ $url = '/contentbank/index.php';
+ }
+
+ return new \moodle_url($url, $params);
+ }
+}
namespace contenttype_h5p;
-use stdClass;
-use html_writer;
-
/**
* H5P Content manager class
*
*/
class content extends \core_contentbank\content {
+ /**
+ * Returns user has access permission for the content itself.
+ * If the H5P content-type library is disabled, the user won't have access to it.
+ *
+ * @return bool True if content could be accessed. False otherwise.
+ */
+ public function is_view_allowed(): bool {
+ // Force H5P content to be deployed.
+ $fileurl = $this->get_file_url();
+ // Skip capability check when creating the H5P content (because it has been created by trusted users).
+ $h5pplayer = new \core_h5p\player($fileurl, new \stdClass(), true, '', true);
+ // Flush error messages.
+ $h5pplayer->get_messages();
+
+ // Check if the H5P entry has been created and if the main library is enabled.
+ $file = $this->get_file();
+ if (!empty($file)) {
+ $h5p = \core_h5p\api::get_content_from_pathnamehash($file->get_pathnamehash());
+ if (empty($h5p)) {
+ // If there is no H5P entry for this content, it won't be displayed unless the user has the manageanycontent
+ // capability. Reasons for contents without a proper H5P entry in DB:
+ // - Invalid H5P package (it won't be never deployed).
+ // - Disabled content-type library (it can't be deployed so there is no way to know the mainlibraryid).
+ $context = \context::instance_by_id($this->content->contextid);
+ if (!has_capability('moodle/contentbank:manageanycontent', $context)) {
+ return false;
+ }
+ } else if (!\core_h5p\api::is_library_enabled((object) ['id' => $h5p->mainlibraryid])) {
+ // If the main library is disabled, it won't be displayed.
+ return false;
+ }
+ }
+
+ return parent::is_view_allowed();
+ }
+
+ /**
+ * Import a file as a valid content.
+ * Before importing the file, this method will check if the file is a valid H5P package. If it's not valid, it will thrown
+ * an exception.
+ *
+ * @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 {
+ // The H5P content can be only deployed if the author of the .h5p file can update libraries or if all the
+ // content-type libraries exist, to avoid users without the h5p:updatelibraries capability upload malicious content.
+ $onlyupdatelibs = !\core_h5p\helper::can_update_library($file);
+
+ if (!\core_h5p\api::is_valid_package($file, $onlyupdatelibs)) {
+ throw new \file_exception('invalidpackage');
+ }
+ return parent::import_file($file);
+ }
}
$types = [];
$h5pfilestorage = new file_storage();
foreach ($h5pcontenttypes as $h5pcontenttype) {
- $library = [
- 'name' => $h5pcontenttype->machine_name,
- 'majorVersion' => $h5pcontenttype->major_version,
- 'minorVersion' => $h5pcontenttype->minor_version,
- ];
- $key = H5PCore::libraryToString($library);
- $type = new stdClass();
- $type->key = $key;
- $type->typename = $h5pcontenttype->title;
- $type->typeeditorparams = 'library=' . $key;
- $type->typeicon = $h5pfilestorage->get_icon_url(
- $h5pcontenttype->id,
- $h5pcontenttype->machine_name,
- $h5pcontenttype->major_version,
- $h5pcontenttype->minor_version);
- $types[] = $type;
+ if ($h5pcontenttype->enabled) {
+ // Only enabled content-types will be displayed.
+ $library = [
+ 'name' => $h5pcontenttype->machine_name,
+ 'majorVersion' => $h5pcontenttype->major_version,
+ 'minorVersion' => $h5pcontenttype->minor_version,
+ ];
+ $key = H5PCore::libraryToString($library);
+ $type = new stdClass();
+ $type->key = $key;
+ $type->typename = $h5pcontenttype->title;
+ $type->typeeditorparams = 'library=' . $key;
+ $type->typeicon = $h5pfilestorage->get_icon_url(
+ $h5pcontenttype->id,
+ $h5pcontenttype->machine_name,
+ $h5pcontenttype->major_version,
+ $h5pcontenttype->minor_version);
+ $types[] = $type;
+ }
}
return $types;
-@core @core_contentbank @contenttype_h5p @_file_upload @_switch_iframe @javascript
+@core @core_contentbank @core_h5p @contenttype_h5p @_file_upload @_switch_iframe @javascript
Feature: Replace H5P file from an existing content
In order to replace an H5P content from the content bank
As an admin
-@core @core_contentbank @contenttype_h5p @_file_upload @_switch_iframe @javascript
+@core @core_contentbank @core_h5p @contenttype_h5p @_file_upload @_switch_iframe @javascript
Feature: H5P file upload to content bank for admins
In order import new H5P content to content bank
As an admin
--- /dev/null
+@core @core_contentbank @core_h5p @contenttype_h5p @_file_upload @javascript
+Feature: Disable H5P content-types from the content bank
+ In order to disable H5P content-types
+ As an admin
+ I need to be able to check they are not displayed in the content bank
+
+ Background:
+ Given the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@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 |
+ And the following "contentbank contents" exist:
+ | contextlevel | reference | contenttype | user | contentname | filepath |
+ | Course | C1 | contenttype_h5p | admin | filltheblanks | /h5p/tests/fixtures/filltheblanks.h5p |
+ | Course | C1 | contenttype_h5p | admin | accordion | /h5p/tests/fixtures/ipsums.h5p |
+ | Course | C1 | contenttype_h5p | admin | invalidh5p | /h5p/tests/fixtures/h5ptest.zip |
+ And I log in as "admin"
+ And I am on "Course 1" course homepage with editing mode on
+ And I add the "Navigation" block if not present
+ And I log out
+
+ Scenario: Teachers cannot view disabled or invalid content-types
+ Given I log in as "teacher1"
+ And I am on "Course 1" course homepage
+ And I expand "Site pages" node
+ And I click on "Content bank" "link"
+ And I should see "accordion"
+ And I should see "filltheblanks"
+ And I should not see "invalidh5p"
+ And I log out
+ And I log in as "admin"
+ And I navigate to "H5P > Manage H5P content types" in site administration
+ And I click on "Disable" "link" in the "Accordion" "table_row"
+ And I log out
+ When I log in as "teacher1"
+ And I am on "Course 1" course homepage
+ And I expand "Site pages" node
+ And I click on "Content bank" "link"
+ Then I should not see "accordion"
+ And I should see "filltheblanks"
+ And I should not see "invalidh5p"
+
+ Scenario: Admins cannot view disabled content-types
+ Given I log in as "admin"
+ And I am on "Course 1" course homepage
+ And I expand "Site pages" node
+ And I click on "Content bank" "link"
+ And I should see "accordion"
+ And I should see "filltheblanks"
+ And I should see "invalidh5p"
+ And I navigate to "H5P > Manage H5P content types" in site administration
+ And I click on "Disable" "link" in the "Accordion" "table_row"
+ When I am on "Course 1" course homepage
+ And I expand "Site pages" node
+ And I click on "Content bank" "link"
+ Then I should not see "accordion"
+ And I should see "filltheblanks"
+ And I should see "invalidh5p"
+
+ Scenario: Teachers cannot create disabled content-types
+ Given I log in as "teacher1"
+ And I am on "Course 1" course homepage
+ And I expand "Site pages" node
+ And I click on "Content bank" "link"
+ And I click on "[data-action=Add-content]" "css_element"
+ And I should see "Accordion"
+ And I should see "Fill in the Blanks"
+ And I log out
+ And I log in as "admin"
+ And I navigate to "H5P > Manage H5P content types" in site administration
+ And I click on "Disable" "link" in the "Accordion" "table_row"
+ And I log out
+ When I log in as "teacher1"
+ And I am on "Course 1" course homepage
+ And I expand "Site pages" node
+ And I click on "Content bank" "link"
+ And I click on "[data-action=Add-content]" "css_element"
+ Then I should not see "Accordion"
+ And I should see "Fill in the Blanks"
+
+ Scenario: Admins cannot create disabled content-types
+ Given I log in as "admin"
+ And I am on "Course 1" course homepage
+ And I expand "Site pages" node
+ And I click on "Content bank" "link"
+ And I click on "[data-action=Add-content]" "css_element"
+ And I should see "Accordion"
+ And I should see "Fill in the Blanks"
+ And I navigate to "H5P > Manage H5P content types" in site administration
+ And I click on "Disable" "link" in the "Accordion" "table_row"
+ When I am on "Course 1" course homepage
+ And I expand "Site pages" node
+ And I click on "Content bank" "link"
+ And I click on "[data-action=Add-content]" "css_element"
+ Then I should not see "Accordion"
+ And I should see "Fill in the Blanks"
-@core @core_contentbank @contenttype_h5p @_file_upload @javascript
+@core @core_contentbank @core_h5p @contenttype_h5p @_file_upload @javascript
Feature: Manage H5P content from the content bank
In order to manage H5P content in the content bank
As an admin
I need to be able to edit any H5P content in the content bank
Background:
- Given the following "users" exist:
+ Given I log in as "admin"
+ And I navigate to "H5P > Manage H5P content types" in site administration
+ And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "H5P content type" filemanager
+ And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
+ And I upload "h5p/tests/fixtures/ipsums.h5p" file to "H5P content type" filemanager
+ And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
+ And the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "courses" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "contentbank content" exist:
- | contextlevel | reference | contenttype | user | contentname |
- | Course | C1 | contenttype_h5p | admin | filltheblanks.h5p |
- | Course | C1 | contenttype_h5p | teacher1 | ipsums.h5p |
- And I log in as "admin"
+ | contextlevel | reference | contenttype | user | contentname | filepath |
+ | Course | C1 | contenttype_h5p | admin | filltheblanks.h5p | /h5p/tests/fixtures/filltheblanks.h5p |
+ | Course | C1 | contenttype_h5p | teacher1 | ipsums.h5p | /h5p/tests/fixtures/ipsums.h5p |
And I am on "Course 1" course homepage with editing mode on
And I add the "Navigation" block if not present
And I log out
-@core @core_contentbank @contenttype_h5p @_file_upload @_switch_iframe @javascript
+@core @core_contentbank @core_h5p @contenttype_h5p @_file_upload @_switch_iframe @javascript
Feature: Replace H5P file from an existing content requires special capabilities
In order replace an H5P content from the content bank
As a teacher
I need to be able to replace the content only if certain capabilities are allowed
Background:
- Given the following "users" exist:
+ Given I log in as "admin"
+ And I navigate to "H5P > Manage H5P content types" in site administration
+ And I upload "h5p/tests/fixtures/ipsums.h5p" file to "H5P content type" filemanager
+ And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
+ And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "H5P content type" filemanager
+ And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
+ And the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "categories" exist:
| contextlevel | reference | contenttype | user | contentname | filepath |
| Course | C1 | contenttype_h5p | admin | admincontent | /h5p/tests/fixtures/ipsums.h5p |
| Course | C1 | contenttype_h5p | teacher1 | teachercontent | /h5p/tests/fixtures/filltheblanks.h5p |
+ And I log out
And I log in as "teacher1"
And I am on "Course 1" course homepage with editing mode on
And I add the "Navigation" block if not present
-@core @core_contentbank @contenttype_h5p @_file_upload @_switch_iframe @javascript
+@core @core_contentbank @core_h5p @contenttype_h5p @_file_upload @_switch_iframe @javascript
Feature: H5P file upload to content bank for non admins
In order import new H5P content to content bank
As an admin
Then I should see "Content bank"
Scenario: Teachers can upload .h5p extension files to course content bank
- Given I am on "Course 1" course homepage with editing mode on
+ Given I log out
+ And I log in as "admin"
+ And I navigate to "H5P > Manage H5P content types" in site administration
+ And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "H5P content type" filemanager
+ And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
+ And I log out
+ And I log in as "teacher1"
+ And I am on "Course 1" course homepage with editing mode on
And I add the "Navigation" block if not present
When I expand "Site pages" node
And I click on "Content bank" "link"
And I should see "filltheblanks.h5p"
Scenario: Other teachers can see uploaded H5P contents
- Given I am on "Course 1" course homepage with editing mode on
+ Given I log out
+ And I log in as "admin"
+ And I navigate to "H5P > Manage H5P content types" in site administration
+ And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "H5P content type" filemanager
+ And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
+ And I log out
+ And I log in as "teacher1"
+ And I am on "Course 1" course homepage with editing mode on
And I add the "Navigation" block if not present
When I expand "Site pages" node
And I click on "Content bank" "link"
And I click on "Content bank" "link"
Then I should see "filltheblanks.h5p"
- Scenario: Teachers can not upload and deployed content types when libraries are not installed
+ Scenario: Teachers can not upload and deploy content types when libraries are not installed
Given I log out
And I log in as "admin"
And I navigate to "H5P > Manage H5P content types" in site administration
And I click on "filltheblanks.h5p" "link"
And I click on "Select this file" "button"
And I click on "Save changes" "button"
- And I switch to "h5p-player" class iframe
- Then I should not see "Of which countries"
- And I should see "missing-required-library"
- And I switch to the main frame
+ Then I should see "Sorry, this file is not valid."
+ And I should not see "filltheblanks.h5p"
And I log out
And I log in as "admin"
- And I navigate to "H5P > Manage H5P content types" in site administration
- And I should not see "Fill in the Blanks"
+ And I am on "Course 1" course homepage
+ And I expand "Site pages" node
+ And I click on "Content bank" "link"
+ And I should not see "filltheblanks.h5p"
Scenario: Teachers can not see existing contents when libraries are not installed
Given I log out
Given I am on "Course 1" course homepage
When I expand "Site pages" node
And I click on "Content bank" "link"
+ Then I should not see "filltheblanks.h5p"
+ And I log out
+ And I log in as "admin"
+ And I am on "Course 1" course homepage
+ And I expand "Site pages" node
+ And I click on "Content bank" "link"
And I should see "filltheblanks.h5p"
And I click on "filltheblanks.h5p" "link"
And I switch to "h5p-player" class iframe
- Then I should not see "Of which countries"
- Then I should see "missing-required-library"
+ And I should see "missing-required-library"
$this->assertInstanceOf(\stored_file::class, $file);
$this->assertEquals($filename, $file->get_filename());
}
+
+ /**
+ * Tests for is view allowed content.
+ *
+ * @covers ::is_view_allowed
+ * @dataProvider is_view_allowed_provider
+ *
+ * @param string $role User role to use for create and view contents.
+ * @param array $disabledlibraries Library names to disable.
+ * @param array $expected Array with the expected values for the contents in the following order:
+ * ['H5P.Blanks deployed', 'H5P.Accordion deployed', 'H5P.Accordion undeployed', 'Invalid content'].
+ */
+ public function test_is_view_allowed(string $role, array $disabledlibraries, array $expected): void {
+ global $CFG, $USER, $DB;
+
+ $this->resetAfterTest();
+
+ // Create a course.
+ $course = $this->getDataGenerator()->create_course();
+ $coursecontext = \context_course::instance($course->id);
+
+ // Set user.
+ if ($role == 'admin') {
+ $this->setAdminUser();
+ } else {
+ // Enrol user to the course.
+ $user = $this->getDataGenerator()->create_and_enrol($course, $role);
+ $this->setUser($user);
+ }
+
+ // Add contents to the content bank.
+ $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+ $filepath = $CFG->dirroot . '/h5p/tests/fixtures/filltheblanks.h5p';
+ $contents = $generator->generate_contentbank_data('contenttype_h5p', 1, $USER->id, $coursecontext, true, $filepath);
+ $filltheblanks = array_shift($contents);
+ $filepath = $CFG->dirroot . '/h5p/tests/fixtures/ipsums.h5p';
+ $contents = $generator->generate_contentbank_data('contenttype_h5p', 2, $USER->id, $coursecontext, true, $filepath);
+ $accordion1 = array_shift($contents);
+ $accordion2 = array_shift($contents);
+ $filepath = $CFG->dirroot . '/h5p/tests/fixtures/invalid.zip';
+ $contents = $generator->generate_contentbank_data('contenttype_h5p', 1, $USER->id, $coursecontext, true, $filepath);
+ $invalid = array_shift($contents);
+
+ // Load some of these H5P files though the player to create the H5P DB entries.
+ $h5pplayer = new \core_h5p\player($filltheblanks->get_file_url(), new \stdClass(), true);
+ $h5pplayer = new \core_h5p\player($accordion1->get_file_url(), new \stdClass(), true);
+
+ // Check the expected H5P content has been created.
+ $this->assertEquals(2, $DB->count_records('h5p'));
+ $this->assertEquals(4, $DB->count_records('contentbank_content'));
+
+ // Disable libraries.
+ foreach ($disabledlibraries as $libraryname) {
+ $libraryid = $DB->get_field('h5p_libraries', 'id', ['machinename' => $libraryname]);
+ \core_h5p\api::set_library_enabled((int) $libraryid, false);
+ }
+
+ $this->assertEquals($expected[0], $filltheblanks->is_view_allowed());
+ $this->assertEquals($expected[1], $accordion1->is_view_allowed());
+ $this->assertEquals($expected[2], $accordion2->is_view_allowed());
+ $this->assertEquals($expected[3], $invalid->is_view_allowed());
+
+ // Check that after enabling libraries again, all the content return true (but the invalid package).
+ foreach ($disabledlibraries as $libraryname) {
+ $libraryid = $DB->get_field('h5p_libraries', 'id', ['machinename' => $libraryname]);
+ \core_h5p\api::set_library_enabled((int) $libraryid, true);
+ }
+
+ $this->assertEquals(true, $filltheblanks->is_view_allowed());
+ $this->assertEquals(true, $accordion1->is_view_allowed());
+ $this->assertEquals(true, $accordion2->is_view_allowed()); // It will be deployed, so now it will always return true.
+ $this->assertEquals($expected[3], $invalid->is_view_allowed());
+ }
+
+ /**
+ * Data provider for test_is_view_allowed.
+ *
+ * @return array
+ */
+ public function is_view_allowed_provider(): array {
+ return [
+ 'Editing teacher with all libraries enabled' => [
+ 'role' => 'editingteacher',
+ 'disabledlibraries' => [],
+ 'expected' => [true, true, true, false],
+ ],
+ 'Manager with all libraries enabled' => [
+ 'role' => 'manager',
+ 'disabledlibraries' => [],
+ 'expected' => [true, true, true, true],
+ ],
+ 'Admin with all libraries enabled' => [
+ 'role' => 'admin',
+ 'disabledlibraries' => [],
+ 'expected' => [true, true, true, true],
+ ],
+ 'Editing teacher with H5P.Accordion disabled' => [
+ 'role' => 'editingteacher',
+ 'disabledlibraries' => ['H5P.Accordion'],
+ 'expected' => [true, false, false, false],
+ ],
+ 'Manager with H5P.Accordion disabled' => [
+ 'role' => 'manager',
+ 'disabledlibraries' => ['H5P.Accordion'],
+ 'expected' => [true, false, true, true],
+ ],
+ 'Admin with H5P.Accordion disabled' => [
+ 'role' => 'admin',
+ 'disabledlibraries' => ['H5P.Accordion'],
+ 'expected' => [true, false, true, true],
+ ],
+ 'Editing teacher with all libraries disabled' => [
+ 'role' => 'editingteacher',
+ 'disabledlibraries' => ['H5P.Accordion', 'H5P.Blanks'],
+ 'expected' => [false, false, false, false],
+ ],
+ 'Manager with all libraries disabled' => [
+ 'role' => 'manager',
+ 'disabledlibraries' => ['H5P.Accordion', 'H5P.Blanks'],
+ 'expected' => [false, false, true, true],
+ ],
+ 'Admin with all libraries disabled' => [
+ 'role' => 'admin',
+ 'disabledlibraries' => ['H5P.Accordion', 'H5P.Blanks'],
+ 'expected' => [false, false, true, true],
+ ],
+ ];
+ }
}
+++ /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/>.
-
-/**
- * Upload files to content bank form
- *
- * @package core_contentbank
- * @copyright 2020 Amaia Anabitarte <amaia@moodle.com>
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-defined('MOODLE_INTERNAL') || die();
-
-require_once("$CFG->libdir/formslib.php");
-
-/**
- * Class contentbank_files_form
- *
- * @package core_contentbank
- * @copyright 2020 Amaia Anabitarte <amaia@moodle.com>
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class contentbank_files_form extends moodleform {
-
- /**
- * Add elements to this form.
- */
- public function definition() {
- $mform = $this->_form;
-
- $mform->addElement('hidden', 'contextid', $this->_customdata['contextid']);
- $mform->setType('contextid', PARAM_INT);
-
- if (!empty($this->_customdata['id'])) {
- $mform->addElement('hidden', 'id', $this->_customdata['id']);
- $mform->setType('id', PARAM_INT);
- }
-
- $options = $this->_customdata['options'];
- $mform->addElement('filepicker', 'file', get_string('file', 'core_contentbank'), null, $options);
- $mform->addHelpButton('file', 'file', 'core_contentbank');
- $mform->addRule('file', null, 'required');
-
- $this->add_action_buttons(true, get_string('savechanges'));
-
- $data = $this->_customdata['data'];
- $this->set_data($data);
- }
-
- /**
- * Validate incoming data.
- *
- * @param array $data
- * @param array $files
- * @return array
- */
- public function validation($data, $files) {
- $errors = array();
- $draftitemid = $data['file'];
- if (file_is_draft_area_limit_reached($draftitemid, $this->_customdata['options']['areamaxbytes'])) {
- $errors['file'] = get_string('userquotalimit', 'error');
- }
- return $errors;
- }
-}
// Don' show upload button if there's no plugin to support any file extension.
$accepted = $cb->get_supported_extensions_as_string($context);
if (!empty($accepted)) {
- $importurl = new moodle_url('/contentbank/upload.php', ['contextid' => $contextid]);
+ $importurl = new moodle_url('/contentbank/index.php', ['contextid' => $contextid]);
$toolbar[] = [
'name' => get_string('upload', 'contentbank'),
- 'link' => $importurl,
+ 'link' => $importurl->out(false),
'icon' => 'i/upload',
'action' => 'upload'
];
+ $PAGE->requires->js_call_amd(
+ 'core_contentbank/upload',
+ 'initModal',
+ ['[data-action=upload]', \core_contentbank\form\upload_files::class, $contextid]
+ );
}
}
{{>core_contentbank/bankcontent/toolbar_dropdown}}
{{/dropdown}}
{{^dropdown}}
- <a href="{{{ link }}}" class="icon-no-margin btn btn-secondary" title="{{{ name }}}">
+ <a href="{{ link }}" class="icon-no-margin btn btn-secondary" title="{{ name }}" data-action="{{ action }}">
{{#pix}} {{{ icon }}} {{/pix}} {{{ name }}}
</a>
{{/dropdown}}
-@core @core_contentbank @contentbank_h5p @_file_upload @javascript
+@core @core_contentbank @core_h5p @contentbank_h5p @_file_upload @javascript
Feature: Delete H5P file from the content bank
In order remove H5P content from the content bank
As an admin
-@core @core_contentbank @contentbank_h5p @_file_upload @javascript
+@core @core_contentbank @core_h5p @contentbank_h5p @_file_upload @javascript
Feature: Download H5P content from the content bank
In order export H5P content from the content bank
As an admin
-@core @core_contentbank @contentbank_h5p @_file_upload @javascript
+@core @core_contentbank @core_h5p @contentbank_h5p @_file_upload @javascript
Feature: Content bank use editor feature
In order to add/edit content
As a user
Then the field "Title" matches value "New title"
Scenario: Teachers can edit their own content in the content bank
- Given the following "users" exist:
+ Given I navigate to "H5P > Manage H5P content types" in site administration
+ And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "H5P content type" filemanager
+ And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
+ And I upload "h5p/tests/fixtures/ipsums.h5p" file to "H5P content type" filemanager
+ And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
+ And the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "courses" exist:
And I click on "Content bank" "link"
And I follow "filltheblanks.h5p"
Then "Edit" "link" should not exist in the "region-main" "region"
+
+ Scenario: Teachers keep their content authoring in copied courses
+ Given the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@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 |
+ And the following "contentbank content" exist:
+ | contextlevel | reference | contenttype | user | contentname | filepath |
+ | Course | C1 | contenttype_h5p | admin | filltheblanks.h5p | /h5p/tests/fixtures/filltheblanks.h5p |
+ | Course | C1 | contenttype_h5p | teacher1 | ipsums.h5p | /h5p/tests/fixtures/ipsums.h5p |
+ And I am on "Course 1" course homepage
+ And I navigate to "Copy course" in current page administration
+ And I set the following fields to these values:
+ | Course full name | Copy |
+ | Course short name | Copy |
+ | Teacher | 1 |
+ When I press "Copy and view"
+ And I trigger cron
+ And I am on homepage
+ And I log out
+ And I log in as "teacher1"
+ And I am on "Copy" course homepage
+ And I expand "Site pages" node
+ And I click on "Content bank" "link"
+ And I follow "ipsums.h5p"
+ Then "Edit" "link" should exist in the "region-main" "region"
+ And I click on "Content bank" "link"
+ And I follow "filltheblanks.h5p"
+ Then "Edit" "link" should not exist in the "region-main" "region"
-@core @core_contentbank @_file_upload @_switch_iframe @javascript
+@core @core_contentbank @core_h5p @contentbank_h5p @_file_upload @_switch_iframe @javascript
Feature: Confirm content bank events are triggered
In order to log content bank actions
As an admin
I need to be able to check triggered events
Background:
+ Given I log in as "admin"
+ And I navigate to "H5P > Manage H5P content types" in site administration
+ And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "H5P content type" filemanager
+ And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "contentbank content" exist:
- | contextlevel | reference | contenttype | user | contentname |
- | Course | C1 | contenttype_h5p | admin | Existing |
- And I log in as "admin"
+ | contextlevel | reference | contenttype | user | contentname | filepath |
+ | Course | C1 | contenttype_h5p | admin | Existing | /h5p/tests/fixtures/filltheblanks.h5p |
+ And I follow "Dashboard" in the user menu
And I follow "Manage private files..."
And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "Files" filemanager
And I click on "Save changes" "button"
-@core @core_contentbank @contentbank_h5p @_file_upload @javascript
+@core @core_contentbank @core_h5p @contentbank_h5p @_file_upload @javascript
Feature: Search content in the content bank
In order to find easily content in the content bank
As an admin
I need to be able to search content in the content bank
Background:
- Given the following "contentbank content" exist:
- | contextlevel | reference | contenttype | user | contentname |
- | System | | contenttype_h5p | admin | santjordi.h5p |
- | System | | contenttype_h5p | admin | santjordi_rose.h5p |
- | System | | contenttype_h5p | admin | SantJordi_book |
- | System | | contenttype_h5p | admin | Dragon_santjordi.h5p |
- | System | | contenttype_h5p | admin | princess.h5p |
- | System | | contenttype_h5p | admin | mathsbook.h5p |
- | System | | contenttype_h5p | admin | historybook.h5p |
- | System | | contenttype_h5p | admin | santvicenc.h5p |
+ Given I log in as "admin"
+ And I navigate to "H5P > Manage H5P content types" in site administration
+ And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "H5P content type" filemanager
+ And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
+ And the following "contentbank content" exist:
+ | contextlevel | reference | contenttype | user | contentname | filepath |
+ | System | | contenttype_h5p | admin | santjordi.h5p | /h5p/tests/fixtures/filltheblanks.h5p |
+ | System | | contenttype_h5p | admin | santjordi_rose.h5p | /h5p/tests/fixtures/filltheblanks.h5p |
+ | System | | contenttype_h5p | admin | SantJordi_book | /h5p/tests/fixtures/filltheblanks.h5p |
+ | System | | contenttype_h5p | admin | Dragon_santjordi.h5p | /h5p/tests/fixtures/filltheblanks.h5p |
+ | System | | contenttype_h5p | admin | princess.h5p | /h5p/tests/fixtures/filltheblanks.h5p |
+ | System | | contenttype_h5p | admin | mathsbook.h5p | /h5p/tests/fixtures/filltheblanks.h5p |
+ | System | | contenttype_h5p | admin | historybook.h5p | /h5p/tests/fixtures/filltheblanks.h5p |
+ | System | | contenttype_h5p | admin | santvicenc.h5p | /h5p/tests/fixtures/filltheblanks.h5p |
Scenario: Admins can search content in the content bank
- Given I log in as "admin"
- And I am on site homepage
+ Given I am on site homepage
And I turn editing mode on
And I add the "Navigation" block if not present
And I expand "Site pages" node
-@core @core_contentbank @contentbank_h5p @javascript
+@core @core_contentbank @core_h5p @contentbank_h5p @_file_upload @javascript
Feature: Sort content in the content bank
In order to temporarily organise the content of the content bank
As an admin
I need to be able to sort the content bank in various ways
Background:
- Given the following "users" exist:
+ Given I log in as "admin"
+ And I navigate to "H5P > Manage H5P content types" in site administration
+ And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "H5P content type" filemanager
+ And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
+ And the following "users" exist:
| username | firstname | lastname | email |
| manager | Max | Manager | man@example.com |
And the following "role assigns" exist:
| user | role | contextlevel | reference |
| manager | manager | System | |
And the following "contentbank content" exist:
- | contextlevel | reference | contenttype | user | contentname |
- | System | | contenttype_h5p | admin | Dragon_santjordi.h5p |
- | System | | contenttype_h5p | admin | mathsbook.h5p |
- | System | | contenttype_h5p | manager | historybook.h5p |
- | System | | contenttype_h5p | admin | santjordi.h5p |
- | System | | contenttype_h5p | admin | santjordi_rose.h5p |
- | System | | contenttype_h5p | admin | SantJordi_book |
+ | contextlevel | reference | contenttype | user | contentname | filepath |
+ | System | | contenttype_h5p | admin | Dragon_santjordi.h5p | /h5p/tests/fixtures/filltheblanks.h5p |
+ | System | | contenttype_h5p | admin | mathsbook.h5p | /h5p/tests/fixtures/filltheblanks.h5p |
+ | System | | contenttype_h5p | manager | historybook.h5p | /h5p/tests/fixtures/filltheblanks.h5p |
+ | System | | contenttype_h5p | admin | santjordi.h5p | /h5p/tests/fixtures/filltheblanks.h5p |
+ | System | | contenttype_h5p | admin | santjordi_rose.h5p | /h5p/tests/fixtures/filltheblanks.h5p |
+ | System | | contenttype_h5p | admin | SantJordi_book | /h5p/tests/fixtures/filltheblanks.h5p |
Scenario: Admins can order content in the content bank
- Given I log in as "admin"
- And I am on site homepage
+ Given I am on site homepage
And I turn editing mode on
And I add the "Navigation" block if not present
And I expand "Site pages" node
Then "santjordi_rose.h5p" "text" should appear before "SantJordi_book" "text"
Scenario: Admins can order content depending on the author
- Given I log in as "admin"
- And I am on site homepage
+ Given I am on site homepage
And I turn editing mode on
And I add the "Navigation" block if not present
And I expand "Site pages" node