Merge branch 'wip-MDL-60926-master' of git://github.com/marinaglancy/moodle
authorJun Pataleta <jun@moodle.com>
Tue, 5 Dec 2017 06:03:45 +0000 (19:03 +1300)
committerJun Pataleta <jun@moodle.com>
Wed, 6 Dec 2017 06:53:35 +0000 (19:53 +1300)
29 files changed:
admin/registration/confirmregistration.php
admin/registration/renewregistration.php
admin/tool/uploaduser/index.php
admin/tool/uploaduser/tests/behat/upload_users.feature
badges/classes/observer.php
badges/criteria/award_criteria.php
badges/criteria/award_criteria_badge.php [new file with mode: 0644]
badges/tests/behat/award_badge.feature
blocks/lp/classes/output/summary.php
lang/en/badges.php
lang/en/form.php
lib/badgeslib.php
lib/completionlib.php
lib/dataformatlib.php
lib/db/events.php
lib/form/passwordunmask.php
lib/moodlelib.php
lib/outputlib.php
mod/lti/backup/moodle2/backup_lti_stepslib.php
mod/lti/backup/moodle2/restore_lti_stepslib.php
mod/lti/db/upgrade.php
mod/lti/tests/behat/addtype.feature
mod/lti/tests/behat/backup_restore.feature [new file with mode: 0644]
mod/lti/view.php
repository/lib.php
search/classes/engine.php
search/classes/manager.php
search/tests/fixtures/mock_search_area.php
search/tests/manager_test.php

index 6b20e8d..7c9d422 100644 (file)
@@ -46,6 +46,14 @@ $error = optional_param('error', '', PARAM_ALPHANUM);
 admin_externalpage_setup('registrationmoodleorg');
 
 if ($url !== HUB_MOODLEORGHUBURL) {
+    // Allow other plugins to confirm registration on hubs other than moodle.net . Plugins implementing this
+    // callback need to redirect or exit. See https://docs.moodle.org/en/Hub_registration .
+    $callbacks = get_plugins_with_function('hub_registration');
+    foreach ($callbacks as $plugintype => $plugins) {
+        foreach ($plugins as $plugin => $callback) {
+            $callback('confirm');
+        }
+    }
     throw new moodle_exception('errorotherhubsnotsupported', 'hub');
 }
 
index 7926e0f..84e727b 100644 (file)
@@ -40,6 +40,14 @@ $token = optional_param('token', '', PARAM_TEXT);
 admin_externalpage_setup('registrationmoodleorg');
 
 if ($url !== HUB_MOODLEORGHUBURL) {
+    // Allow other plugins to renew registration on hubs other than moodle.net . Plugins implementing this
+    // callback need to redirect or exit. See https://docs.moodle.org/en/Hub_registration .
+    $callbacks = get_plugins_with_function('hub_registration');
+    foreach ($callbacks as $plugintype => $plugins) {
+        foreach ($plugins as $plugin => $callback) {
+            $callback('renew');
+        }
+    }
     throw new moodle_exception('errorotherhubsnotsupported', 'hub');
 }
 
index d11332b..496b52d 100644 (file)
@@ -358,7 +358,7 @@ if ($formdata = $mform2->is_cancelled()) {
 
         // add default values for remaining fields
         $formdefaults = array();
-        if ($updatetype != UU_UPDATE_FILEOVERRIDE && $updatetype != UU_UPDATE_NOCHANGES) {
+        if (!$existinguser || ($updatetype != UU_UPDATE_FILEOVERRIDE && $updatetype != UU_UPDATE_NOCHANGES)) {
             foreach ($STD_FIELDS as $field) {
                 if (isset($user->$field)) {
                     continue;
index e217cf1..1ee26f4 100644 (file)
@@ -38,6 +38,31 @@ Feature: Upload users
     And I set the field "groups" to "Section 1 (1)"
     And the "members" select box should contain "Tom Jones"
 
+  @javascript
+  Scenario: Upload users enrolling them on courses and groups applying defaults
+    Given the following "courses" exist:
+      | fullname | shortname | category |
+      | Maths | math102 | 0 |
+    And the following "groups" exist:
+      | name | course | idnumber |
+      | Section 1 | math102 | S1 |
+      | Section 3 | math102 | S3 |
+    And I log in as "admin"
+    And I navigate to "Upload users" node in "Site administration > Users > Accounts"
+    When I upload "lib/tests/fixtures/upload_users.csv" file to "File" filemanager
+    And I press "Upload users"
+    And I set the following fields to these values:
+      | City/town  | Brighton   |
+      | Department | Purchasing |
+    And I press "Upload users"
+    And I press "Continue"
+    And I navigate to "Users > Accounts > Browse list of users" in site administration
+    And I should see "Tom Jones"
+    And I follow "Tom Jones"
+    And I follow "Edit profile"
+    And the field "City/town" matches value "Brighton"
+    And the field "Department" matches value "Purchasing"
+
   @javascript
   Scenario: Upload users with custom profile fields
     # Create user profile field.
index 34cf105..31be94e 100644 (file)
@@ -106,6 +106,37 @@ class core_badges_observer {
         }
     }
 
+    /**
+     * Triggered when 'badge_awarded' event happens.
+     *
+     * @param \core\event\badge_awarded $event event generated when a badge is awarded.
+     */
+    public static function badge_criteria_review(\core\event\badge_awarded $event) {
+        global $DB, $CFG;
+
+        if (!empty($CFG->enablebadges)) {
+            require_once($CFG->dirroot.'/lib/badgeslib.php');
+            $userid = $event->relateduserid;
+
+            if ($rs = $DB->get_records('badge_criteria', array('criteriatype' => BADGE_CRITERIA_TYPE_BADGE))) {
+                foreach ($rs as $r) {
+                    $badge = new badge($r->badgeid);
+                    if (!$badge->is_active() || $badge->is_issued($userid)) {
+                        continue;
+                    }
+
+                    if ($badge->criteria[BADGE_CRITERIA_TYPE_BADGE]->review($userid)) {
+                        $badge->criteria[BADGE_CRITERIA_TYPE_BADGE]->mark_complete($userid);
+
+                        if ($badge->criteria[BADGE_CRITERIA_TYPE_OVERALL]->review($userid)) {
+                            $badge->criteria[BADGE_CRITERIA_TYPE_OVERALL]->mark_complete($userid);
+                            $badge->issue($userid);
+                        }
+                    }
+                }
+            }
+        }
+    }
     /**
      * Triggered when 'user_updated' event happens.
      *
index ee13151..5427783 100644 (file)
@@ -68,6 +68,12 @@ define('BADGE_CRITERIA_TYPE_COURSESET', 5);
  */
 define('BADGE_CRITERIA_TYPE_PROFILE', 6);
 
+/*
+ * Badge completion criteria type
+ * Criteria type constant, primarily for storing criteria type in the database.
+ */
+define('BADGE_CRITERIA_TYPE_BADGE', 7);
+
 /*
  * Criteria type constant to class name mapping
  */
@@ -79,7 +85,8 @@ $BADGE_CRITERIA_TYPES = array(
     BADGE_CRITERIA_TYPE_SOCIAL    => 'social',
     BADGE_CRITERIA_TYPE_COURSE    => 'course',
     BADGE_CRITERIA_TYPE_COURSESET => 'courseset',
-    BADGE_CRITERIA_TYPE_PROFILE   => 'profile'
+    BADGE_CRITERIA_TYPE_PROFILE   => 'profile',
+    BADGE_CRITERIA_TYPE_BADGE     => 'badge',
 );
 
 /**
diff --git a/badges/criteria/award_criteria_badge.php b/badges/criteria/award_criteria_badge.php
new file mode 100644 (file)
index 0000000..d9bd795
--- /dev/null
@@ -0,0 +1,271 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This file contains the badge earned badge award criteria type class
+ *
+ * @package    core
+ * @subpackage badges
+ * @copyright  2017 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Badge award criteria -- award on badge completion
+ *
+ * @package    core
+ * @subpackage badges
+ * @copyright  2017 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class award_criteria_badge extends award_criteria {
+
+    /* @var int Criteria [BADGE_CRITERIA_TYPE_BADGE] */
+    public $criteriatype = BADGE_CRITERIA_TYPE_BADGE;
+
+    public $required_param = 'badge';
+    public $optional_params = array();
+
+    /**
+     * Get criteria details for displaying to users
+     * @param string $short Print short version of criteria
+     * @return string
+     */
+    public function get_details($short = '') {
+        global $DB, $OUTPUT;
+        $output = array();
+        foreach ($this->params as $p) {
+            $badgename = $DB->get_field('badge', 'name', array('id' => $p['badge']));
+            if (!$badgename) {
+                $str = $OUTPUT->error_text(get_string('error:nosuchbadge', 'badges'));
+            } else {
+                $str = html_writer::tag('b', '"' . $badgename . '"');
+            }
+            $output[] = $str;
+        }
+
+        if ($short) {
+            return implode(', ', $output);
+        } else {
+            return html_writer::alist($output, array(), 'ul');
+        }
+    }
+
+    /**
+     * Add appropriate new criteria options to the form
+     * @param object $mform moodle form
+     */
+    public function get_options(&$mform) {
+        global $DB;
+        $none = false;
+        $availablebadges = null;
+
+        $mform->addElement('header', 'first_header', $this->get_title());
+        $mform->addHelpButton('first_header', 'criteria_' . $this->criteriatype, 'badges');
+
+        // Determine if this badge is a course badge or a site badge.
+        $thisbadge = $DB->get_record('badge', array('id' => $this->badgeid));
+
+        if ($thisbadge->type == BADGE_TYPE_SITE) {
+            // Only list site badges that are enabled.
+            $select = " type = :site AND (status = :status1 OR status = :status2)";
+            $params = array('site' => BADGE_TYPE_SITE,
+                            'status1' => BADGE_STATUS_ACTIVE,
+                            'status2' => BADGE_STATUS_ACTIVE_LOCKED);
+            $availablebadges = $DB->get_records_select_menu('badge', $select, $params, 'name ASC', 'id, name');
+
+        } else if ($thisbadge->type == BADGE_TYPE_COURSE) {
+            // List both site badges and course badges belonging to this course.
+            $select = " (type = :site OR (type = :course AND courseid = :courseid)) AND (status = :status1 OR status = :status2)";
+            $params = array('site' => BADGE_TYPE_SITE,
+                            'course' => BADGE_TYPE_COURSE,
+                            'courseid' => $thisbadge->courseid,
+                            'status1' => BADGE_STATUS_ACTIVE,
+                            'status2' => BADGE_STATUS_ACTIVE_LOCKED);
+            $availablebadges = $DB->get_records_select_menu('badge', $select, $params, 'name ASC', 'id, name');
+        }
+        if (!empty($availablebadges)) {
+            $select = array();
+            $selected = array();
+            foreach ($availablebadges as $bid => $badgename) {
+                if ($bid != $this->badgeid) {
+                    // Do not let it use itself as criteria.
+                    $select[$bid] = format_string($badgename, true);
+                }
+            }
+
+            if ($this->id !== 0) {
+                $selected = array_keys($this->params);
+            }
+            $settings = array('multiple' => 'multiple', 'size' => 20, 'class' => 'selectbadge');
+            $mform->addElement('select', 'badge_badges', get_string('addbadge', 'badges'), $select, $settings);
+            $mform->addRule('badge_badges', get_string('requiredbadge', 'badges'), 'required');
+            $mform->addHelpButton('badge_badges', 'addbadge', 'badges');
+
+            if ($this->id !== 0) {
+                $mform->setDefault('badge_badges', $selected);
+            }
+        } else {
+            $mform->addElement('static', 'nobadges', '', get_string('error:nobadges', 'badges'));
+            $none = true;
+        }
+
+        // Add aggregation.
+        if (!$none) {
+            $mform->addElement('header', 'aggregation', get_string('method', 'badges'));
+            $agg = array();
+            $agg[] =& $mform->createElement('radio', 'agg', '', get_string('allmethodbadges', 'badges'), 1);
+            $agg[] =& $mform->createElement('radio', 'agg', '', get_string('anymethodbadges', 'badges'), 2);
+            $mform->addGroup($agg, 'methodgr', '', array('<br/>'), false);
+            if ($this->id !== 0) {
+                $mform->setDefault('agg', $this->method);
+            } else {
+                $mform->setDefault('agg', BADGE_CRITERIA_AGGREGATION_ANY);
+            }
+        }
+
+        return array($none, get_string('noparamstoadd', 'badges'));
+    }
+
+    /**
+     * Save criteria records
+     *
+     * @param array $params Values from the form or any other array.
+     */
+    public function save($params = array()) {
+        $badges = $params['badge_badges'];
+        unset($params['badge_badges']);
+        foreach ($badges as $badgeid) {
+            $params["badge_{$badgeid}"] = $badgeid;
+        }
+
+        parent::save($params);
+    }
+
+    /**
+     * Review this criteria and decide if it has been completed
+     *
+     * @param int $userid User whose criteria completion needs to be reviewed.
+     * @param bool $filtered An additional parameter indicating that user list
+     *        has been reduced and some expensive checks can be skipped.
+     *
+     * @return bool Whether criteria is complete.
+     */
+    public function review($userid, $filtered = false) {
+
+        global $DB;
+        $overall = false;
+
+        foreach ($this->params as $param) {
+            $badge = $DB->get_record('badge', array('id' => $param['badge']));
+            // See if the user has earned this badge.
+            $awarded = $DB->get_record('badge_issued', array('badgeid' => $param['badge'], 'userid' => $userid));
+
+            // Extra check in case a badge was deleted while this badge is still active.
+            if (!$badge) {
+                if ($this->method == BADGE_CRITERIA_AGGREGATION_ALL) {
+                    return false;
+                } else {
+                    continue;
+                }
+            }
+
+            if ($this->method == BADGE_CRITERIA_AGGREGATION_ALL) {
+
+                if ($awarded) {
+                    $overall = true;
+                    continue;
+                } else {
+                    return false;
+                }
+            } else if ($this->method == BADGE_CRITERIA_AGGREGATION_ANY) {
+                if ($awarded) {
+                    return true;
+                } else {
+                    $overall = false;
+                    continue;
+                }
+            }
+        }
+
+        return $overall;
+    }
+
+    /**
+     * Checks criteria for any major problems.
+     *
+     * @return array A list containing status and an error message (if any).
+     */
+    public function validate() {
+        global $DB;
+        $params = array_keys($this->params);
+        $method = ($this->method == BADGE_CRITERIA_AGGREGATION_ALL);
+        $singleparam = (count($params) == 1);
+
+        foreach ($params as $param) {
+            // Perform check if there only one parameter with any type of aggregation,
+            // Or there are more than one parameter with aggregation ALL.
+
+            if (($singleparam || $method) && !$DB->record_exists('badge', array('id' => $param))) {
+                return array(false, get_string('error:invalidparambadge', 'badges'));
+            }
+        }
+
+        return array(true, '');
+    }
+
+    /**
+     * Returns array with sql code and parameters returning all ids
+     * of users who meet this particular criterion.
+     *
+     * @return array list($join, $where, $params)
+     */
+    public function get_completed_criteria_sql() {
+        $join = '';
+        $where = '';
+        $params = array();
+
+        if ($this->method == BADGE_CRITERIA_AGGREGATION_ANY) {
+            // User has received ANY of the required badges.
+            $join = " LEFT JOIN {badge_issued} bi2 ON bi2.userid = u.id";
+            $where = "AND (";
+            $i = 0;
+            foreach ($this->params as $param) {
+                if ($i == 0) {
+                    $where .= ' bi2.badgeid = :badgeid'.$i;
+                } else {
+                    $where .= ' OR bi2.badgeid = :badgeid'.$i;
+                }
+                $params['badgeid'.$i] = $param['badge'];
+                $i++;
+            }
+            $where .= ") ";
+            return array($join, $where, $params);
+        } else {
+            // User has received ALL of the required badges.
+            $join = " LEFT JOIN {badge_issued} bi2 ON bi2.userid = u.id";
+            $i = 0;
+            foreach ($this->params as $param) {
+                $i++;
+                $where = ' AND bi2.badgeid = :badgeid'.$i;
+                $params['badgeid'.$i] = $param['badge'];
+            }
+            return array($join, $where, $params);
+        }
+    }
+}
index 13c040a..cd64b32 100644 (file)
@@ -4,6 +4,71 @@ Feature: Award badges
   As an admin
   I need to add criteria to badges in the system
 
+  @javascript
+  Scenario: Award badge on other badges as criteria
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+      | student1 | Student | 1 | student1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category | groupmode |
+      | Course 1 | C1 | 0 | 1 |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    # Create course badge 1.
+    And I navigate to "Add a new badge" node in "Course administration > Badges"
+    And I follow "Add a new badge"
+    And I set the following fields to these values:
+      | Name | Course Badge 1 |
+      | Description | Course badge 1 description |
+      | issuername | Tester of course badge |
+    And I upload "badges/tests/behat/badge.png" file to "Image" filemanager
+    And I press "Create badge"
+    And I set the field "type" to "Manual issue by role"
+    And I expand all fieldsets
+    # Set to ANY of the roles awards badge.
+    And I set the field "Teacher" to "1"
+    And I set the field "Any of the selected roles awards the badge" to "1"
+    And I press "Save"
+    And I press "Enable access"
+    And I press "Continue"
+    # Badge #2
+    And I navigate to "Add a new badge" node in "Course administration > Badges"
+    And I follow "Add a new badge"
+    And I set the following fields to these values:
+      | Name | Course Badge 2 |
+      | Description | Course badge 2 description |
+      | issuername | Tester of course badge |
+    And I upload "badges/tests/behat/badge.png" file to "Image" filemanager
+    And I press "Create badge"
+    # Set "course badge 1" as criteria
+    And I set the field "type" to "Awarded badges"
+    And I set the field "id_badge_badges" to "Course Badge 1"
+    And I press "Save"
+    And I press "Enable access"
+    And I press "Continue"
+    And I follow "Manage badges"
+    And I follow "Course Badge 1"
+    And I follow "Recipients (0)"
+    And I press "Award badge"
+    # Award course badge 1 to student 1.
+    And I set the field "potentialrecipients[]" to "Student 1 (student1@example.com)"
+    When I press "Award badge"
+    And I follow "Course Badge 1"
+    And I follow "Recipients (1)"
+    Then I should see "Recipients (1)"
+    And I log out
+    # Student 1 should have both badges.
+    And I log in as "student1"
+    And I follow "Profile" in the user menu
+    When I click on "Course 1" "link" in the "region-main" "region"
+    Then I should see "Course Badge 1"
+    And I should see "Course Badge 2"
+
   @javascript
   Scenario: Award profile badge
     Given I log in as "admin"
index e5cee22..995f6d4 100644 (file)
@@ -35,6 +35,7 @@ use core_competency\url;
 use renderable;
 use renderer_base;
 use templatable;
+use required_capability_exception;
 
 /**
  * Summary renderable class.
@@ -68,7 +69,11 @@ class summary implements renderable, templatable {
         $this->user = $user;
 
         // Get the plans.
-        $this->plans = api::list_user_plans($this->user->id);
+        try {
+            $this->plans = api::list_user_plans($this->user->id);
+        } catch (required_capability_exception $e) {
+            $this->plans = [];
+        }
 
         // Get the competencies to review.
         $this->compstoreview = api::list_user_competencies_to_review(0, 3);
index 59833af..2364312 100644 (file)
@@ -27,6 +27,8 @@
 $string['actions'] = 'Actions';
 $string['activate'] = 'Enable access';
 $string['activatesuccess'] = 'Access to the badges was successfully enabled.';
+$string['addbadge'] = 'Add a badge as criteria';
+$string['addbadge_help'] = 'Select all badges that should be added to this badge requirement. Hold CTRL key to select multiple items.';
 $string['addbadgecriteria'] = 'Add badge criteria';
 $string['addcriteria'] = 'Add criteria';
 $string['addcriteriatext'] = 'To start adding criteria, please select one of the options from the drop-down menu.';
@@ -39,6 +41,7 @@ $string['aggregationmethod'] = 'Aggregation method';
 $string['all'] = 'All';
 $string['allmethod'] = 'All of the selected conditions are met';
 $string['allmethodactivity'] = 'All of the selected activities are complete';
+$string['allmethodbadges'] = 'All of the selected badges have been earned';
 $string['allmethodcourseset'] = 'All of the selected courses are complete';
 $string['allmethodmanual'] = 'All of the selected roles award the badge';
 $string['allmethodprofile'] = 'All of the selected profile fields have been completed';
@@ -51,6 +54,7 @@ Note: It is recommended to leave this option disabled if the website cannot be a
 $string['any'] = 'Any';
 $string['anymethod'] = 'Any of the selected conditions is met';
 $string['anymethodactivity'] = 'Any of the selected activities is complete';
+$string['anymethodbadges'] = 'Any of the selected badges have been earned';
 $string['anymethodcourseset'] = 'Any of the selected courses is complete';
 $string['anymethodmanual'] = 'Any of the selected roles awards the badge';
 $string['anymethodprofile'] = 'Any of the selected profile fields has been completed';
@@ -176,22 +180,26 @@ $string['criteria_descr_short2'] = 'Awarded by <strong>{$a}</strong> of: ';
 $string['criteria_descr_short4'] = 'Complete the course ';
 $string['criteria_descr_short5'] = 'Complete <strong>{$a}</strong> of: ';
 $string['criteria_descr_short6'] = 'Complete <strong>{$a}</strong> of: ';
+$string['criteria_descr_short7'] = 'Complete <strong>{$a}</strong> of: ';
 $string['criteria_descr_single_short1'] = 'Complete: ';
 $string['criteria_descr_single_short2'] = 'Awarded by: ';
 $string['criteria_descr_single_short4'] = 'Complete the course ';
 $string['criteria_descr_single_short5'] = 'Complete: ';
 $string['criteria_descr_single_short6'] = 'Complete: ';
+$string['criteria_descr_single_short7'] = 'Complete: ';
 $string['criteria_descr_single_1'] = 'The following activity has to be completed:';
 $string['criteria_descr_single_2'] = 'This badge has to be awarded by a user with the following role:';
 $string['criteria_descr_single_4'] = 'Users must complete the course';
 $string['criteria_descr_single_5'] = 'The following course has to be completed:';
 $string['criteria_descr_single_6'] = 'The following user profile field has to be completed:';
+$string['criteria_descr_single_7'] = 'The following badge has to be earned:';
 $string['criteria_descr_0'] = 'Users are awarded this badge when they complete <strong>{$a}</strong> of the listed requirements.';
 $string['criteria_descr_1'] = '<strong>{$a}</strong> of the following activities are completed:';
 $string['criteria_descr_2'] = 'This badge has to be awarded by the users with <strong>{$a}</strong> of the following roles:';
 $string['criteria_descr_4'] = 'Users must complete the course';
 $string['criteria_descr_5'] = '<strong>{$a}</strong> of the following courses have to be completed:';
 $string['criteria_descr_6'] = '<strong>{$a}</strong> of the following user profile fields have to be completed:';
+$string['criteria_descr_7'] = '<strong>{$a}</strong> of the following badges have to be earned:';
 $string['criteria_0'] = 'This badge is awarded when...';
 $string['criteria_1'] = 'Activity completion';
 $string['criteria_1_help'] = 'Allows a badge to be awarded to users based on the completion of a set of activities within a course.';
@@ -205,6 +213,8 @@ $string['criteria_5'] = 'Completing a set of courses';
 $string['criteria_5_help'] = 'Allows a badge to be awarded to users who have completed a set of courses. Each course can have additional parameters such as minimum grade and date of course completion. ';
 $string['criteria_6'] = 'Profile completion';
 $string['criteria_6_help'] = 'Allows a badge to be awarded to users for completing certain fields in their profile. You can select from default and custom profile fields that are available to users. ';
+$string['criteria_7'] = 'Awarded badges';
+$string['criteria_7_help'] = 'Allows a badge to be awarded to users based on the other badges thay have earned.';
 $string['criterror'] = 'Current parameters issues';
 $string['criterror_help'] = 'This fieldset shows all parameters that were initially added to this badge requirement but are no longer available. It is recommended that you un-check such parameters to make sure that users can earn this badge in the future.';
 $string['currentimage'] = 'Current image';
@@ -248,7 +258,9 @@ $string['error:invalidbadgeurl'] = 'Invalid badge issuer URL format.';
 $string['error:invalidcriteriatype'] = 'Invalid criteria type.';
 $string['error:invalidexpiredate'] = 'Expiry date has to be in the future.';
 $string['error:invalidexpireperiod'] = 'Expiry period cannot be negative or equal 0.';
+$string['error:invalidparambadge'] = 'Badge does not exist. ';
 $string['error:noactivities'] = 'There are no activities with completion criteria enabled in this course.';
+$string['error:nobadges'] = 'There are no course or site badges available to be added as criteria.  Make sure that your other badges are enabled ';
 $string['error:nocourses'] = 'Course completion is not enabled for any of the courses in this site, so none can be displayed. Course completion may be enabled in the course settings.';
 $string['error:nogroups'] = '<p>There are no public collections of badges available in your backpack. </p>
 <p>Only public collections are shown, <a href="http://backpack.openbadges.org">visit your backpack</a> to create some public collections.</p>';
@@ -369,6 +381,7 @@ $string['recipientvalidationproblem'] = 'Current user cannot be verified as a re
 $string['relative'] = 'Relative date';
 $string['revoke'] = 'Revoke badge';
 $string['requiredcourse'] = 'At least one course should be added to the courseset criterion.';
+$string['requiredbadge'] = 'At least one badge should be added to the badge criterion.';
 $string['reviewbadge'] = 'Changes in badge access';
 $string['reviewconfirm'] = '<p>This will make your badge visible to users and allow them to start earning it.</p>
 
index 288c4b6..743a749 100644 (file)
@@ -41,6 +41,7 @@ $string['err_nopunctuation'] = 'You must enter no punctuation characters here.';
 $string['err_numeric'] = 'You must enter a number here.';
 $string['err_rangelength'] = 'You must enter between {$a->format[0]} and {$a->format[1]} characters here.';
 $string['err_required'] = 'You must supply a value here.';
+$string['err_wrappingwhitespace'] = 'The value must not start or end with whitespace.';
 $string['err_wrongfileextension'] = 'Some files ({$a->wrongfiles}) cannot be uploaded. Only file types {$a->whitelist} are allowed.';
 $string['filesofthesetypes'] = 'Accepted file types:';
 $string['filetypesany'] = 'All file types';
index b1adc3f..c9ac3ff 100644 (file)
@@ -191,6 +191,7 @@ class badge {
                     BADGE_CRITERIA_TYPE_OVERALL,
                     BADGE_CRITERIA_TYPE_MANUAL,
                     BADGE_CRITERIA_TYPE_COURSE,
+                    BADGE_CRITERIA_TYPE_BADGE,
                     BADGE_CRITERIA_TYPE_ACTIVITY
             );
         } else if ($this->type == BADGE_TYPE_SITE) {
@@ -198,6 +199,7 @@ class badge {
                     BADGE_CRITERIA_TYPE_OVERALL,
                     BADGE_CRITERIA_TYPE_MANUAL,
                     BADGE_CRITERIA_TYPE_COURSESET,
+                    BADGE_CRITERIA_TYPE_BADGE,
                     BADGE_CRITERIA_TYPE_PROFILE,
             );
         }
index 89827fb..3d8a5e7 100644 (file)
@@ -356,19 +356,19 @@ class completion_info {
      * @return array
      */
     public function get_completions($user_id, $criteriatype = null) {
-        $criterion = $this->get_criteria($criteriatype);
+        $criteria = $this->get_criteria($criteriatype);
 
         $completions = array();
 
-        foreach ($criterion as $criteria) {
+        foreach ($criteria as $criterion) {
             $params = array(
                 'course'        => $this->course_id,
                 'userid'        => $user_id,
-                'criteriaid'    => $criteria->id
+                'criteriaid'    => $criterion->id
             );
 
             $completion = new completion_criteria_completion($params);
-            $completion->attach_criteria($criteria);
+            $completion->attach_criteria($criterion);
 
             $completions[] = $completion;
         }
index fad18d6..4412e96 100644 (file)
@@ -44,7 +44,7 @@ function download_as_dataformat($filename, $dataformat, $columns, $iterator, $ca
 
     $classname = 'dataformat_' . $dataformat . '\writer';
     if (!class_exists($classname)) {
-        throw new coding_exception("Unable to locate dataformat/$type/classes/writer.php");
+        throw new coding_exception("Unable to locate dataformat/$dataformat/classes/writer.php");
     }
     $format = new $classname;
 
index 8045f8e..de2be51 100644 (file)
@@ -46,6 +46,10 @@ $observers = array(
         'eventname'   => '\core\event\course_module_completion_updated',
         'callback'    => 'core_badges_observer::course_module_criteria_review',
     ),
+    array(
+        'eventname'   => '\core\event\badge_awarded',
+        'callback'    => 'core_badges_observer::badge_criteria_review',
+    ),
     array(
         'eventname'   => '\core\event\course_completed',
         'callback'    => 'core_badges_observer::course_criteria_review',
index db18e88..e90ee4d 100644 (file)
@@ -90,4 +90,21 @@ class MoodleQuickForm_passwordunmask extends MoodleQuickForm_password {
 
         return $context;
     }
+
+    /**
+     * Check that there is no whitespace at the beginning and end of the password.
+     *
+     * It turned out that wrapping whitespace can easily be pasted by accident when copying the text from elsewhere.
+     * Such a mistake is very hard to debug as the whitespace is not displayed.
+     *
+     * @param array $value Submitted value.
+     * @return string|null Validation error message or null.
+     */
+    public function validateSubmitValue($value) {
+        if ($value !== null && $value !== trim($value)) {
+            return get_string('err_wrappingwhitespace', 'core_form');
+        }
+
+        return;
+    }
 }
index 30f34e1..9ba9f39 100644 (file)
@@ -2111,7 +2111,7 @@ function make_timestamp($year, $month=1, $day=1, $hour=0, $minute=0, $second=0,
  * Format a date/time (seconds) as weeks, days, hours etc as needed
  *
  * Given an amount of time in seconds, returns string
- * formatted nicely as weeks, days, hours etc as needed
+ * formatted nicely as years, days, hours etc as needed
  *
  * @package core
  * @category time
index 6d101b3..7a8beff 100644 (file)
@@ -201,10 +201,13 @@ function theme_build_css_for_themes($themeconfigs = [], $directions = ['rtl', 'l
 
         // First generate all the new css.
         foreach ($directions as $direction) {
+            // Lock it on. Technically we should build all themes for SVG and no SVG - but ie9 is out of support.
+            $themeconfig->force_svg_use(true);
             $themeconfig->set_rtl_mode(($direction === 'rtl'));
 
             $themecss[$direction] = $themeconfig->get_css_content();
             if ($cache) {
+                $themeconfig->set_css_content_cache($themecss[$direction]);
                 $filename = theme_get_css_filename($themeconfig->name, $themerev, $newrevision, $direction);
                 css_store_css($themeconfig, $filename, $themecss[$direction]);
             }
index e6ad077..7b4b457 100644 (file)
@@ -53,9 +53,12 @@ defined('MOODLE_INTERNAL') || die;
  */
 class backup_lti_activity_structure_step extends backup_activity_structure_step {
 
+    /**
+     * Defines structure of activity backup
+     * @return backup_nested_element
+     */
     protected function define_structure() {
-
-        // TODO: MDL-34161 - Fix restore to support course/site tools & submissions.
+        global $DB;
 
         // To know if we are including userinfo.
         $userinfo = $this->get_setting_value('userinfo');
@@ -89,14 +92,109 @@ class backup_lti_activity_structure_step extends backup_activity_structure_step
             )
         );
 
+        $ltitype = new backup_nested_element('ltitype', array('id'), array(
+            'name',
+            'baseurl',
+            'tooldomain',
+            'state',
+            'course',
+            'coursevisible',
+            'toolproxyid',
+            'enabledcapability',
+            'parameter',
+            'icon',
+            'secureicon',
+            'createdby',
+            'timecreated',
+            'timemodified',
+            'description'
+            )
+        );
+
+        $ltitypesconfigs = new backup_nested_element('ltitypesconfigs');
+        $ltitypesconfig  = new backup_nested_element('ltitypesconfig', array('id'), array(
+                'name',
+                'value',
+            )
+        );
+        $ltitypesconfigencrypted  = new backup_nested_element('ltitypesconfigencrypted', array('id'), array(
+                'name',
+                new encrypted_final_element('value'),
+            )
+        );
+
+        $ltitoolproxy = new backup_nested_element('ltitoolproxy', array('id'));
+
+        $ltitoolsettings = new backup_nested_element('ltitoolsettings');
+        $ltitoolsetting  = new backup_nested_element('ltitoolsetting', array('id'), array(
+                'settings',
+                'timecreated',
+                'timemodified',
+            )
+        );
+
+        $ltisubmissions = new backup_nested_element('ltisubmissions');
+        $ltisubmission = new backup_nested_element('ltisubmission', array('id'), array(
+            'userid',
+            'datesubmitted',
+            'dateupdated',
+            'gradepercent',
+            'originalgrade',
+            'launchid',
+            'state'
+        ));
+
         // Build the tree
-        // (none).
+        $lti->add_child($ltitype);
+        $ltitype->add_child($ltitypesconfigs);
+        $ltitypesconfigs->add_child($ltitypesconfig);
+        $ltitypesconfigs->add_child($ltitypesconfigencrypted);
+        $ltitype->add_child($ltitoolproxy);
+        $ltitoolproxy->add_child($ltitoolsettings);
+        $ltitoolsettings->add_child($ltitoolsetting);
+        $lti->add_child($ltisubmissions);
+        $ltisubmissions->add_child($ltisubmission);
 
         // Define sources.
-        $lti->set_source_table('lti', array('id' => backup::VAR_ACTIVITYID));
+        $ltirecord = $DB->get_record('lti', ['id' => $this->task->get_activityid()]);
+        $lti->set_source_array([$ltirecord]);
+
+        $ltitypedata = $this->retrieve_lti_type($ltirecord);
+        $ltitype->set_source_array($ltitypedata ? [$ltitypedata] : []);
+
+        if (isset($ltitypedata->baseurl)) {
+            // Add type config values only if the type was backed up. Encrypt password and resourcekey.
+            $params = [backup_helper::is_sqlparam($ltitypedata->id),
+                backup_helper::is_sqlparam('password'),
+                backup_helper::is_sqlparam('resourcekey')];
+            $ltitypesconfig->set_source_sql("SELECT id, name, value
+                FROM {lti_types_config}
+                WHERE typeid = ? AND name <> ? AND name <> ?", $params);
+            $ltitypesconfigencrypted->set_source_sql("SELECT id, name, value
+                FROM {lti_types_config}
+                WHERE typeid = ? AND (name = ? OR name = ?)", $params);
+        }
+
+        if (!empty($ltitypedata->toolproxyid)) {
+            // If this is LTI 2 tool add settings for the current activity.
+            $ltitoolproxy->set_source_array([['id' => $ltitypedata->toolproxyid]]);
+            $ltitoolsetting->set_source_sql("SELECT *
+                FROM {lti_tool_settings}
+                WHERE toolproxyid = ? AND course = ? AND coursemoduleid = ?",
+                [backup_helper::is_sqlparam($ltitypedata->toolproxyid), backup::VAR_COURSEID, backup::VAR_MODID]);
+        } else {
+            $ltitoolproxy->set_source_array([]);
+        }
+
+        // All the rest of elements only happen if we are including user info.
+        if ($userinfo) {
+            $ltisubmission->set_source_table('lti_submission', array('ltiid' => backup::VAR_ACTIVITYID));
+        }
 
         // Define id annotations
-        // (none).
+        $ltitype->annotate_ids('user', 'createdby');
+        $ltitype->annotate_ids('course', 'course');
+        $ltisubmission->annotate_ids('user', 'userid');
 
         // Define file annotations.
         $lti->annotate_files('mod_lti', 'intro', null); // This file areas haven't itemid.
@@ -108,4 +206,34 @@ class backup_lti_activity_structure_step extends backup_activity_structure_step
         // Return the root element (lti), wrapped into standard activity structure.
         return $this->prepare_activity_structure($lti);
     }
+
+    /**
+     * Retrieves a record from {lti_type} table associated with the current activity
+     *
+     * Information about site tools is not returned because it is insecure to back it up,
+     * only fields necessary for same-site tool matching are left in the record
+     *
+     * @param stdClass $ltirecord record from {lti} table
+     * @return stdClass|null
+     */
+    protected function retrieve_lti_type($ltirecord) {
+        global $DB;
+        if (!$ltirecord->typeid) {
+            return null;
+        }
+
+        $record = $DB->get_record('lti_types', ['id' => $ltirecord->typeid]);
+        if ($record && $record->course == SITEID) {
+            // Site LTI types or registrations are not backed up except for their name (which is visible).
+            // Predefined course types can be backed up.
+            $allowedkeys = ['id', 'course', 'name', 'toolproxyid'];
+            foreach ($record as $key => $value) {
+                if (!in_array($key, $allowedkeys)) {
+                    $record->$key = null;
+                }
+            }
+        }
+
+        return $record;
+    }
 }
index f57186a..c6bbf3f 100644 (file)
@@ -53,11 +53,28 @@ defined('MOODLE_INTERNAL') || die;
  */
 class restore_lti_activity_structure_step extends restore_activity_structure_step {
 
+    /** @var bool */
+    protected $newltitype = false;
+
     protected function define_structure() {
 
         $paths = array();
+        // To know if we are including userinfo.
+        $userinfo = $this->get_setting_value('userinfo');
+
         $lti = new restore_path_element('lti', '/activity/lti');
         $paths[] = $lti;
+        $paths[] = new restore_path_element('ltitype', '/activity/lti/ltitype');
+        $paths[] = new restore_path_element('ltitypesconfig', '/activity/lti/ltitype/ltitypesconfigs/ltitypesconfig');
+        $paths[] = new restore_path_element('ltitypesconfigencrypted',
+            '/activity/lti/ltitype/ltitypesconfigs/ltitypesconfigencrypted');
+        $paths[] = new restore_path_element('ltitoolproxy', '/activity/lti/ltitype/ltitoolproxy');
+        $paths[] = new restore_path_element('ltitoolsetting', '/activity/lti/ltitype/ltitoolproxy/ltitoolsettings/ltitoolsetting');
+
+        if ($userinfo) {
+            $submission = new restore_path_element('ltisubmission', '/activity/lti/ltisubmissions/ltisubmission');
+            $paths[] = $submission;
+        }
 
         // Add support for subplugin structures.
         $this->add_subplugin_structure('ltisource', $lti);
@@ -81,10 +98,6 @@ class restore_lti_activity_structure_step extends restore_activity_structure_ste
          // Grade used to be a float (whole numbers only), restore as int.
         $data->grade = (int) $data->grade;
 
-        // Clean any course or site typeid. All modules
-        // are restored as self-contained. Note this is
-        // an interim solution until the issue below is implemented.
-        // TODO: MDL-34161 - Fix restore to support course/site tools & submissions.
         $data->typeid = 0;
 
         // Try to decrypt resourcekey and password. Null if not possible (DB default).
@@ -98,6 +111,172 @@ class restore_lti_activity_structure_step extends restore_activity_structure_ste
         $this->apply_activity_instance($newitemid);
     }
 
+    /**
+     * Process an lti type restore
+     * @param mixed $data The data from backup XML file
+     * @return void
+     */
+    protected function process_ltitype($data) {
+        global $DB, $USER;
+
+        $data = (object)$data;
+        $oldid = $data->id;
+        if (!empty($data->createdby)) {
+            $data->createdby = $this->get_mappingid('user', $data->createdby) ?: $USER->id;
+        }
+
+        $courseid = $this->get_courseid();
+        $data->course = ($this->get_mappingid('course', $data->course) == $courseid) ? $courseid : SITEID;
+
+        // Try to find existing lti type with the same properties.
+        $ltitypeid = $this->find_existing_lti_type($data);
+
+        $this->newltitype = false;
+        if (!$ltitypeid && $data->course == $courseid) {
+            unset($data->toolproxyid); // Course tools can not use LTI2.
+            $ltitypeid = $DB->insert_record('lti_types', $data);
+            $this->newltitype = true;
+            $this->set_mapping('ltitype', $oldid, $ltitypeid);
+        }
+
+        // Add the typeid entry back to LTI module.
+        $DB->update_record('lti', ['id' => $this->get_new_parentid('lti'), 'typeid' => $ltitypeid]);
+    }
+
+    /**
+     * Attempts to find existing record in lti_type
+     * @param stdClass $data
+     * @return int|null field lti_types.id or null if tool is not found
+     */
+    protected function find_existing_lti_type($data) {
+        global $DB;
+        if ($ltitypeid = $this->get_mappingid('ltitype', $data->id)) {
+            return $ltitypeid;
+        }
+
+        $ltitype = null;
+        $params = (array)$data;
+        if ($this->task->is_samesite()) {
+            // If we are restoring on the same site try to find lti type with the same id.
+            $sql = 'id = :id AND course = :course';
+            $sql .= ($data->toolproxyid) ? ' AND toolproxyid = :toolproxyid' : ' AND toolproxyid IS NULL';
+            if ($DB->record_exists_select('lti_types', $sql, $params)) {
+                $this->set_mapping('ltitype', $data->id, $data->id);
+                if ($data->toolproxyid) {
+                    $this->set_mapping('ltitoolproxy', $data->toolproxyid, $data->toolproxyid);
+                }
+                return $data->id;
+            }
+        }
+
+        if ($data->course != $this->get_courseid()) {
+            // Site tools are not backed up and are not restored.
+            return null;
+        }
+
+        // Now try to find the same type on the current site available in this course.
+        // Compare only fields baseurl, course and name, if they are the same we assume it is the same tool.
+        // LTI2 is not possible in the course so we add "lt.toolproxyid IS NULL" to the query.
+        $sql = 'SELECT id
+            FROM {lti_types}
+            WHERE baseurl = :baseurl AND course = :course AND name = :name AND toolproxyid IS NULL';
+        if ($ltitype = $DB->get_record_sql($sql, $params, IGNORE_MULTIPLE)) {
+            $this->set_mapping('ltitype', $data->id, $ltitype->id);
+            return $ltitype->id;
+        }
+
+        return null;
+    }
+
+    /**
+     * Process an lti config restore
+     * @param mixed $data The data from backup XML file
+     */
+    protected function process_ltitypesconfig($data) {
+        global $DB;
+
+        $data = (object)$data;
+        $data->typeid = $this->get_new_parentid('ltitype');
+
+        // Only add configuration if the new lti_type was created.
+        if ($data->typeid && $this->newltitype) {
+            if ($data->name == 'servicesalt') {
+                $data->value = uniqid('', true);
+            }
+            $DB->insert_record('lti_types_config', $data);
+        }
+    }
+
+    /**
+     * Process an lti config restore
+     * @param mixed $data The data from backup XML file
+     */
+    protected function process_ltitypesconfigencrypted($data) {
+        global $DB;
+
+        $data = (object)$data;
+        $data->typeid = $this->get_new_parentid('ltitype');
+
+        // Only add configuration if the new lti_type was created.
+        if ($data->typeid && $this->newltitype) {
+            $data->value = $this->decrypt($data->value);
+            if (!is_null($data->value)) {
+                $DB->insert_record('lti_types_config', $data);
+            }
+        }
+    }
+
+    /**
+     * Process a restore of LTI tool registration
+     * This method is empty because we actually process registration as part of process_ltitype()
+     * @param mixed $data The data from backup XML file
+     */
+    protected function process_ltitoolproxy($data) {
+
+    }
+
+    /**
+     * Process an lti tool registration settings restore (only settings for the current activity)
+     * @param mixed $data The data from backup XML file
+     */
+    protected function process_ltitoolsetting($data) {
+        global $DB;
+
+        $data = (object)$data;
+        $data->toolproxyid = $this->get_new_parentid('ltitoolproxy');
+
+        if (!$data->toolproxyid) {
+            return;
+        }
+
+        $data->course = $this->get_courseid();
+        $data->coursemoduleid = $this->task->get_moduleid();
+        $DB->insert_record('lti_tool_settings', $data);
+    }
+
+    /**
+     * Process a submission restore
+     * @param mixed $data The data from backup XML file
+     */
+    protected function process_ltisubmission($data) {
+        global $DB;
+
+        $data = (object)$data;
+        $oldid = $data->id;
+
+        $data->ltiid = $this->get_new_parentid('lti');
+
+        $data->datesubmitted = $this->apply_date_offset($data->datesubmitted);
+        $data->dateupdated = $this->apply_date_offset($data->dateupdated);
+        if ($data->userid > 0) {
+            $data->userid = $this->get_mappingid('user', $data->userid);
+        }
+
+        $newitemid = $DB->insert_record('lti_submission', $data);
+
+        $this->set_mapping('ltisubmission', $oldid, $newitemid);
+    }
+
     protected function after_execute() {
         // Add lti related files, no need to match by itemname (just internally handled context).
         $this->add_related_files('mod_lti', 'intro', null);
index 9f7bd5f..e3452a0 100644 (file)
@@ -143,6 +143,8 @@ function xmldb_lti_upgrade($oldversion) {
             }
 
             if (!$acceptgrades) {
+                // Required when doing CLI upgrade.
+                require_once($CFG->libdir . '/gradelib.php');
                 grade_update('mod/lti', $lti->course, 'mod', 'lti', $lti->id, 0, null, array('deleted' => 1));
             }
 
index 2381230..aefff91 100644 (file)
@@ -55,6 +55,10 @@ Feature: Add preconfigured tools via teacher interface
     And the field "Secure icon URL" matches value "https://download.moodle.org/unittest/test.jpg"
     And I press "Cancel"
     And I switch to the main window
+    And I press "Save and return to course"
+    And I open "Test tool activity 1" actions menu
+    And I choose "Edit settings" in the open action menu
+    And the field "Preconfigured tool" matches value "Placeholder"
 
   @javascript @_switch_window
   Scenario: Add and use a preconfigured tool
diff --git a/mod/lti/tests/behat/backup_restore.feature b/mod/lti/tests/behat/backup_restore.feature
new file mode 100644 (file)
index 0000000..dea1105
--- /dev/null
@@ -0,0 +1,84 @@
+@mod @mod_lti @core_backup @javascript
+Feature: Restoring Moodle 2 backup restores LTI configuration
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Terry1    | Teacher1 | teacher1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+      | Course 2 | C2 | 0 |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | teacher1 | C2 | editingteacher |
+
+  Scenario: Backup and restore course with preconfigured site LTI tool on the same site
+    When I log in as "admin"
+    And I navigate to "Manage tools" node in "Site administration > Plugins > Activity modules > External tool"
+    And I follow "Manage preconfigured tools"
+    And I follow "Add preconfigured tool"
+    And I set the following fields to these values:
+      | Tool name | My site tool |
+      | Tool URL | https://www.moodle.org |
+      | lti_coursevisible | 1 |
+    And I press "Save changes"
+    And I navigate to "Manage tools" node in "Site administration > Plugins > Activity modules > External tool"
+    And "This tool has not yet been used" "text" should exist in the "//div[contains(@id,'tool-card-container') and contains(., 'My site tool')]" "xpath_element"
+    And I am on site homepage
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add a "External tool" to section "1" and I fill the form with:
+        | Activity name | My LTI module |
+        | Preconfigured tool | My site tool |
+        | Launch container | Embed |
+    And I follow "Course 1"
+    And I should see "My LTI module"
+    And I backup "Course 1" course using this options:
+      | Confirmation | Filename | test_backup.mbz |
+    And I restore "test_backup.mbz" backup into a new course using this options:
+    And I am on site homepage
+    And I follow "Course 1 copy 1"
+    And I open "My LTI module" actions menu
+    And I choose "Edit settings" in the open action menu
+    Then the field "Preconfigured tool" matches value "My site tool"
+    And I navigate to "Manage tools" node in "Site administration > Plugins > Activity modules > External tool"
+    And "This tool is being used 2 times" "text" should exist in the "//div[contains(@id,'tool-card-container') and contains(., 'My site tool')]" "xpath_element"
+
+  @javascript @_switch_window
+  Scenario: Backup and restore course with preconfigured course LTI tool on the same site
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    # In the first course create an LTI module that uses a course preconfigured tool—é
+    And I add a "External tool" to section "1"
+    And I set the following fields to these values:
+      | Activity name | Test tool activity 2 |
+    And I follow "Add preconfigured tool"
+    And I switch to "add_tool" window
+    And I set the field "Tool name" to "My course tool"
+    And I set the field "Tool URL" to "http://www.example.com/lti/provider.php"
+    And I set the field "Consumer key" to "my key"
+    And I set the field "Shared secret" to "my secret"
+    And I set the field "Default launch container" to "Existing window"
+    And I press "Save changes"
+    And I switch to the main window
+    And I press "Save and return to course"
+    # Backup course and restore into another course
+    And I backup "Course 1" course using this options:
+      | Confirmation | Filename | test_backup.mbz |
+    And I restore "test_backup.mbz" backup into "Course 2" course using this options:
+    And I am on site homepage
+    And I follow "Course 2"
+    # Make sure the copy of the preconfigured tool was created in the second course with both encrtypted and non-encrypted properties.
+    And I open "Test tool activity 2" actions menu
+    And I choose "Edit settings" in the open action menu
+    Then the field "Preconfigured tool" matches value "My course tool"
+    And I follow "Edit preconfigured tool"
+    And I switch to "edit_tool" window
+    Then the field "Tool URL" matches value "http://www.example.com/lti/provider.php"
+    And the field "Consumer key" matches value "my key"
+    And the field "Shared secret" matches value "my secret"
+    And the field "Default launch container" matches value "Existing window"
+    And I press "Cancel"
+    And I switch to the main window
index 2288acf..0bbf63d 100644 (file)
@@ -87,14 +87,14 @@ $PAGE->set_url($url);
 $launchcontainer = lti_get_launch_container($lti, $toolconfig);
 
 if ($launchcontainer == LTI_LAUNCH_CONTAINER_EMBED_NO_BLOCKS) {
-    $PAGE->set_pagelayout('frametop'); // Most frametops don't include footer, and pre-post blocks.
+    $PAGE->set_pagelayout('incourse');
     $PAGE->blocks->show_only_fake_blocks(); // Disable blocks for layouts which do include pre-post blocks.
 } else if ($launchcontainer == LTI_LAUNCH_CONTAINER_REPLACE_MOODLE_WINDOW) {
     if (!$forceview) {
         $url = new moodle_url('/mod/lti/launch.php', array('id' => $cm->id));
         redirect($url);
     }
-} else {
+} else { // Handles LTI_LAUNCH_CONTAINER_DEFAULT, LTI_LAUNCH_CONTAINER_EMBED, LTI_LAUNCH_CONTAINER_WINDOW.
     $PAGE->set_pagelayout('incourse');
 }
 
index 6f6759c..9597665 100644 (file)
@@ -2004,6 +2004,8 @@ abstract class repository implements cacheable_object {
         global $DB;
         if ($downloadcontents) {
             $this->convert_references_to_local();
+        } else {
+            $this->remove_files();
         }
         cache::make('core', 'repositories')->purge();
         try {
@@ -2668,6 +2670,17 @@ abstract class repository implements cacheable_object {
         }
     }
 
+    /**
+     * Find all external files linked to this repository and delete them.
+     */
+    public function remove_files() {
+        $fs = get_file_storage();
+        $files = $fs->get_external_files($this->id);
+        foreach ($files as $storedfile) {
+            $storedfile->delete();
+        }
+    }
+
     /**
      * Function repository::reset_caches() is deprecated, cache is handled by MUC now.
      * @deprecated since Moodle 2.6 MDL-42016 - please do not use this function any more.
index a3eed8d..52a7ab8 100644 (file)
@@ -215,6 +215,7 @@ abstract class engine {
         $lastindexeddoc = 0;
         $firstindexeddoc = 0;
         $partial = false;
+        $lastprogress = manager::get_current_time();
 
         foreach ($iterator as $document) {
             // Stop if we have exceeded the time limit (and there are still more items). Always
@@ -250,6 +251,17 @@ abstract class engine {
                 $firstindexeddoc = $lastindexeddoc;
             }
             $numrecords++;
+
+            // If indexing the area takes a long time, periodically output progress information.
+            if (isset($options['progress'])) {
+                $now = manager::get_current_time();
+                if ($now - $lastprogress >= manager::DISPLAY_INDEXING_PROGRESS_EVERY) {
+                    $lastprogress = $now;
+                    // The first date format is the same used in cron_trace_time_and_memory().
+                    $options['progress']->output(date('H:i:s', $now) . ': Done to ' . userdate(
+                            $lastindexeddoc, get_string('strftimedatetimeshort', 'langconfig')), 1);
+                }
+            }
         }
 
         return array($numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial);
index fc5a718..0e25dec 100644 (file)
@@ -77,6 +77,16 @@ class manager {
      */
     const NO_OWNER_ID = 0;
 
+    /**
+     * @var float If initial query takes longer than N seconds, this will be shown in cron log.
+     */
+    const DISPLAY_LONG_QUERY_TIME = 5.0;
+
+    /**
+     * @var float Adds indexing progress within one search area to cron log every N seconds.
+     */
+    const DISPLAY_INDEXING_PROGRESS_EVERY = 30.0;
+
     /**
      * @var \core_search\base[] Enabled search areas.
      */
@@ -715,6 +725,11 @@ class manager {
 
             // Getting the recordset from the area.
             $recordset = $searcharea->get_recordset_by_timestamp($referencestarttime);
+            $initialquerytime = self::get_current_time() - $elapsed;
+            if ($initialquerytime > self::DISPLAY_LONG_QUERY_TIME) {
+                $progress->output('Initial query took ' . round($initialquerytime, 1) .
+                        ' seconds.', 1);
+            }
 
             // Pass get_document as callback.
             $fileindexing = $this->engine->file_indexing_enabled() && $searcharea->uses_file_indexing();
@@ -722,6 +737,7 @@ class manager {
             if ($timelimit) {
                 $options['stopat'] = $stopat;
             }
+            $options['progress'] = $progress;
             $iterator = new skip_future_documents_iterator(new \core\dml\recordset_walk(
                     $recordset, array($searcharea, 'get_document'), $options));
             $result = $this->engine->add_documents($iterator, $searcharea, $options);
@@ -737,10 +753,16 @@ class manager {
             }
 
             if ($numdocs > 0) {
-                $elapsed = round((self::get_current_time() - $elapsed), 3);
+                $elapsed = round((self::get_current_time() - $elapsed), 1);
+
+                $partialtext = '';
+                if ($partial) {
+                    $partialtext = ' (not complete; done to ' . userdate($lastindexeddoc,
+                            get_string('strftimedatetimeshort', 'langconfig')) . ')';
+                }
+
                 $progress->output('Processed ' . $numrecords . ' records containing ' . $numdocs .
-                        ' documents, in ' . $elapsed . ' seconds' .
-                        ($partial ? ' (not complete)' : '') . '.', 1);
+                        ' documents, in ' . $elapsed . ' seconds' . $partialtext . '.', 1);
             } else {
                 $progress->output('No new documents to index.', 1);
             }
index 1ed17d3..778fe35 100644 (file)
@@ -29,6 +29,9 @@ defined('MOODLE_INTERNAL') || die;
 
 class mock_search_area extends \core_search\base {
 
+    /** @var float If set, waits when doing the indexing query (seconds) */
+    protected $indexingdelay = 0;
+
     /**
      * Multiple context level so we can test get_areas_user_accesses.
      * @var int[]
@@ -47,6 +50,11 @@ class mock_search_area extends \core_search\base {
     public function get_recordset_by_timestamp($modifiedfrom = 0) {
         global $DB;
 
+        if ($this->indexingdelay) {
+            \testable_core_search::fake_current_time(
+                    \core_search\manager::get_current_time() + $this->indexingdelay);
+        }
+
         $sql = "SELECT * FROM {temp_mock_search_area} WHERE timemodified >= ? ORDER BY timemodified ASC";
         return $DB->get_recordset_sql($sql, array($modifiedfrom));
     }
@@ -126,4 +134,14 @@ class mock_search_area extends \core_search\base {
     public function get_visible_name($lazyload = false) {
         return 'Mock search area';
     }
+
+    /**
+     * Sets a fake delay to simulate time taken doing the indexing query.
+     *
+     * @param float $seconds Delay in seconds for each time indexing query is called
+     */
+    public function set_indexing_delay($seconds) {
+        $this->indexingdelay = $seconds;
+    }
+
 }
index 95fa82c..36113dd 100644 (file)
@@ -280,6 +280,90 @@ class search_manager_testcase extends advanced_testcase {
         $this->assertFalse(get_config($componentname, $varname . '_partial'));
     }
 
+    /**
+     * Tests the progress display while indexing.
+     *
+     * This tests the different logic about displaying progress for slow/fast and
+     * complete/incomplete processing.
+     */
+    public function test_index_progress() {
+        $this->resetAfterTest();
+        $generator = $this->getDataGenerator();
+
+        // Set up the fake search area.
+        $search = testable_core_search::instance();
+        $area = new \core_mocksearch\search\mock_search_area();
+        $search->add_search_area('whatever', $area);
+        $searchgenerator = $generator->get_plugin_generator('core_search');
+        $searchgenerator->setUp();
+
+        // Add records with specific time modified values.
+        $time = strtotime('2017-11-01 01:00');
+        for ($i = 0; $i < 8; $i ++) {
+            $searchgenerator->create_record((object)['timemodified' => $time]);
+            $time += 60;
+        }
+
+        // Simulate slow progress on indexing and initial query.
+        $now = strtotime('2017-11-11 01:00');
+        \testable_core_search::fake_current_time($now);
+        $area->set_indexing_delay(10.123);
+        $search->get_engine()->set_add_delay(15.789);
+
+        // Run search indexing and check output.
+        $progress = new progress_trace_buffer(new text_progress_trace(), false);
+        $search->index(false, 75, $progress);
+        $out = $progress->get_buffer();
+        $progress->reset_buffer();
+
+        // Check for the standard text.
+        $this->assertContains('Processing area: Mock search area', $out);
+        $this->assertContains('Stopping indexing due to time limit', $out);
+
+        // Check for initial query performance indication.
+        $this->assertContains('Initial query took 10.1 seconds.', $out);
+
+        // Check for the two (approximately) every-30-seconds messages.
+        $this->assertContains('01:00:41: Done to 1/11/17, 01:01', $out);
+        $this->assertContains('01:01:13: Done to 1/11/17, 01:03', $out);
+
+        // Check for the 'not complete' indicator showing when it was done until.
+        $this->assertContains('Processed 5 records containing 5 documents, in 89.1 seconds ' .
+                '(not complete; done to 1/11/17, 01:04)', $out);
+
+        // Make the initial query delay less than 5 seconds, so it won't appear. Make the documents
+        // quicker, so that the 30-second delay won't be needed.
+        $area->set_indexing_delay(4.9);
+        $search->get_engine()->set_add_delay(1);
+
+        // Run search indexing (still partial) and check output.
+        $progress = new progress_trace_buffer(new text_progress_trace(), false);
+        $search->index(false, 5, $progress);
+        $out = $progress->get_buffer();
+        $progress->reset_buffer();
+
+        $this->assertContains('Processing area: Mock search area', $out);
+        $this->assertContains('Stopping indexing due to time limit', $out);
+        $this->assertNotContains('Initial query took', $out);
+        $this->assertNotContains(': Done to', $out);
+        $this->assertContains('Processed 2 records containing 2 documents, in 6.9 seconds ' .
+                '(not complete; done to 1/11/17, 01:05).', $out);
+
+        // Run the remaining items to complete it.
+        $progress = new progress_trace_buffer(new text_progress_trace(), false);
+        $search->index(false, 100, $progress);
+        $out = $progress->get_buffer();
+        $progress->reset_buffer();
+
+        $this->assertContains('Processing area: Mock search area', $out);
+        $this->assertNotContains('Stopping indexing due to time limit', $out);
+        $this->assertNotContains('Initial query took', $out);
+        $this->assertNotContains(': Done to', $out);
+        $this->assertContains('Processed 3 records containing 3 documents, in 7.9 seconds.', $out);
+
+        $searchgenerator->tearDown();
+    }
+
     /**
      * Tests that documents with modified time in the future are NOT indexed (as this would cause
      * a problem by preventing it from indexing other documents modified between now and the future