Merge branch 'MDL-55114-master' of git://github.com/andrewnicols/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 5 Jul 2016 10:44:52 +0000 (12:44 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 5 Jul 2016 10:44:52 +0000 (12:44 +0200)
111 files changed:
.jshintrc
.travis.yml
Gruntfile.js
admin/cli/install.php
admin/cli/install_database.php
admin/environment.xml
admin/index.php
admin/roles/ajax.php
admin/tool/lp/coursecompetencies.php
admin/tool/lp/lib.php
admin/tool/monitor/classes/eventobservers.php
admin/tool/monitor/classes/subscription.php
admin/tool/monitor/classes/subscription_manager.php
admin/tool/monitor/classes/task/check_subscriptions.php [new file with mode: 0644]
admin/tool/monitor/db/install.xml
admin/tool/monitor/db/tasks.php
admin/tool/monitor/db/upgrade.php
admin/tool/monitor/lang/en/tool_monitor.php
admin/tool/monitor/tests/subscription_test.php [new file with mode: 0644]
admin/tool/monitor/tests/task_check_subscriptions_test.php [new file with mode: 0644]
admin/tool/monitor/version.php
admin/tool/xmldb/actions/edit_field/edit_field.class.php
admin/tool/xmldb/actions/edit_index/edit_index.class.php
admin/tool/xmldb/actions/edit_key/edit_key.class.php
admin/tool/xmldb/actions/edit_table/edit_table.class.php
competency/classes/course_competency_settings.php
composer.json
course/externallib.php
course/tests/externallib_test.php
install.php
lang/en/search.php
lib/amd/build/permissionmanager.min.js
lib/amd/src/permissionmanager.js
lib/badgeslib.php
lib/classes/update/code_manager.php
lib/ddl/tests/ddl_test.php
lib/dml/pgsql_native_moodle_database.php
lib/dml/pgsql_native_moodle_recordset.php
lib/dml/tests/dml_test.php
lib/externallib.php
lib/phpmailer/moodle_phpmailer.php
lib/setup.php
lib/tests/externallib_test.php
lib/tests/fixtures/update_validator/zips/multidir.zip [new file with mode: 0644]
lib/tests/text_test.php
lib/tests/update_code_manager_test.php
lib/xmldb/xmldb_table.php
message/output/airnotifier/message_output_airnotifier.php
mod/assign/db/services.php
mod/assign/externallib.php
mod/assign/lang/en/assign.php
mod/assign/locallib.php
mod/assign/submission/file/locallib.php
mod/assign/submission/file/tests/locallib_test.php [new file with mode: 0644]
mod/assign/submission/onlinetext/locallib.php
mod/assign/submission/onlinetext/tests/locallib_test.php [new file with mode: 0644]
mod/assign/submission_form.php
mod/assign/submissionplugin.php
mod/assign/tests/externallib_test.php
mod/assign/tests/locallib_test.php
mod/assign/upgrade.txt
mod/assign/version.php
mod/book/classes/external.php
mod/book/tests/externallib_test.php
mod/chat/classes/external.php
mod/chat/gui_header_js/jsupdate.php
mod/chat/gui_header_js/jsupdated.php
mod/chat/tests/externallib_test.php
mod/choice/classes/external.php
mod/choice/renderer.php
mod/data/classes/external.php
mod/data/tests/externallib_test.php
mod/forum/externallib.php
mod/forum/tests/externallib_test.php
mod/glossary/classes/external.php
mod/imscp/classes/external.php
mod/lti/classes/external.php
mod/lti/locallib.php
mod/lti/tests/externallib_test.php
mod/quiz/classes/external.php
mod/quiz/tests/external_test.php
mod/scorm/classes/external.php
mod/scorm/datamodels/scormlib.php
mod/scorm/lang/en/scorm.php
mod/scorm/mod_form.php
mod/scorm/tests/externallib_test.php
mod/survey/backup/moodle2/backup_survey_stepslib.php
mod/survey/classes/external.php
mod/survey/db/install.xml
mod/survey/db/upgrade.php
mod/survey/lang/en/survey.php
mod/survey/lib.php
mod/survey/mod_form.php
mod/survey/tests/behat/survey_completion.feature [new file with mode: 0644]
mod/survey/tests/externallib_test.php
mod/survey/version.php
mod/wiki/classes/external.php
mod/wiki/tests/externallib_test.php
mod/workshop/renderer.php
my/lib.php
npm-shrinkwrap.json
package.json
question/type/multichoice/lang/en/qtype_multichoice.php
question/type/multichoice/renderer.php
question/type/multichoice/tests/behat/preview.feature
search/classes/output/form/search.php
theme/clean/classes/core_renderer.php
user/classes/search/user.php [new file with mode: 0644]
user/messageselect.php
user/tests/search_test.php [new file with mode: 0644]
webservice/upload.php

index ee94a05..b93ac60 100644 (file)
--- a/.jshintrc
+++ b/.jshintrc
@@ -1,3 +1,6 @@
+// NOTE: We use eslint now. This file is used only by shifter. We keep the configuration
+// here because shifter uses jshint after modules have been concating. Eslint can't
+// currently do this.
 {
     "asi":          false,
     "bitwise":      true,
index d1438f3..5a32a60 100644 (file)
@@ -14,15 +14,13 @@ language: php
 php:
     # We only run the highest and lowest supported versions to reduce the load on travis-ci.org.
     - 7.0
-    # - 5.6
-    # - 5.5
-    - 5.4
+    - 5.6
 
 env:
     # Although we want to run these jobs and see failures as quickly as possible, we also want to get the slowest job to
     # start first so that the total run time is not too high.
     #
-    # We only run MySQL on PHP 5.6, so run that first.
+    # We only run MySQL on PHP 7.0, so run that first.
     # CI Tests should be second-highest in priority as these only take <= 60 seconds to run under normal circumstances.
     # Postgres is significantly is pretty reasonable in its run-time.
 
@@ -50,17 +48,13 @@ matrix:
     exclude:
         # MySQL - it's just too slow.
         # Exclude it on all versions except for 7.0
-        # - env: DB=mysqli   TASK=PHPUNIT
-        #   php: 5.6
-        #
-        # - env: DB=mysqli   TASK=PHPUNIT
-        #   php: 5.5
 
         - env: DB=mysqli   TASK=PHPUNIT
-          php: 5.4
+          php: 5.6
 
+       # One grunt execution is enough.
         - env: DB=none     TASK=GRUNT
-          php: 5.4
+          php: 5.6
 
         # Moodle 2.7 is not compatible with PHP 7 for the upgrade test.
         - env: DB=pgsql    TASK=UPGRADE
index b728341..0b52cc8 100644 (file)
@@ -101,10 +101,6 @@ module.exports = function(grunt) {
 
     // Project configuration.
     grunt.initConfig({
-        jshint: {
-            options: {jshintrc: '.jshintrc'},
-            amd: { src: amdSrc }
-        },
         eslint: {
             // Even though warnings dont stop the build we don't display warnings by default because
             // at this moment we've got too many core warnings.
@@ -286,7 +282,8 @@ module.exports = function(grunt) {
     var changedFiles = Object.create(null);
     var onChange = grunt.util._.debounce(function() {
           var files = Object.keys(changedFiles);
-          grunt.config('jshint.amd.src', files);
+          grunt.config('eslint.amd.src', files);
+          grunt.config('eslint.yui.src', files);
           grunt.config('uglify.amd.files', [{ expand: true, src: files, rename: uglifyRename }]);
           grunt.config('shifter.options.paths', files);
           changedFiles = Object.create(null);
@@ -299,7 +296,6 @@ module.exports = function(grunt) {
 
     // Register NPM tasks.
     grunt.loadNpmTasks('grunt-contrib-uglify');
-    grunt.loadNpmTasks('grunt-contrib-jshint');
     grunt.loadNpmTasks('grunt-contrib-less');
     grunt.loadNpmTasks('grunt-contrib-watch');
     grunt.loadNpmTasks('grunt-eslint');
@@ -308,7 +304,7 @@ module.exports = function(grunt) {
     grunt.registerTask('shifter', 'Run Shifter against the current directory', tasks.shifter);
     grunt.registerTask('ignorefiles', 'Generate ignore files for linters', tasks.ignorefiles);
     grunt.registerTask('yui', ['eslint:yui', 'shifter']);
-    grunt.registerTask('amd', ['eslint:amd', 'jshint', 'uglify']);
+    grunt.registerTask('amd', ['eslint:amd', 'uglify']);
     grunt.registerTask('js', ['amd', 'yui']);
 
     // Register CSS taks.
index 9f3c0df..0b6c5ca 100644 (file)
@@ -147,10 +147,10 @@ define('PHPUNIT_TEST', false);
 define('IGNORE_COMPONENT_CACHE', true);
 
 // Check that PHP is of a sufficient version
-if (version_compare(phpversion(), "5.4.4") < 0) {
+if (version_compare(phpversion(), "5.6.5") < 0) {
     $phpversion = phpversion();
     // do NOT localise - lang strings would not work here and we CAN NOT move it after installib
-    fwrite(STDERR, "Moodle 2.7 or later requires at least PHP 5.4.4 (currently using version $phpversion).\n");
+    fwrite(STDERR, "Moodle 3.2 or later requires at least PHP 5.6.5 (currently using version $phpversion).\n");
     fwrite(STDERR, "Please upgrade your server software or install older Moodle version.\n");
     exit(1);
 }
index b66805e..f41263e 100644 (file)
@@ -63,10 +63,10 @@ Example:
 ";
 
 // Check that PHP is of a sufficient version
-if (version_compare(phpversion(), "5.4.4") < 0) {
+if (version_compare(phpversion(), "5.6.5") < 0) {
     $phpversion = phpversion();
     // do NOT localise - lang strings would not work here and we CAN NOT move it after installib
-    fwrite(STDERR, "Moodle 2.7 or later requires at least PHP 5.4.4 (currently using version $phpversion).\n");
+    fwrite(STDERR, "Moodle 3.2 or later requires at least PHP 5.6.5 (currently using version $phpversion).\n");
     fwrite(STDERR, "Please upgrade your server software or install older Moodle version.\n");
     exit(1);
 }
index f281b45..7bef8fc 100644 (file)
       </CUSTOM_CHECK>
     </CUSTOM_CHECKS>
   </MOODLE>
+  <MOODLE version="3.2" requires="2.7">
+    <UNICODE level="required">
+      <FEEDBACK>
+        <ON_ERROR message="unicoderequired" />
+      </FEEDBACK>
+    </UNICODE>
+    <DATABASE level="required">
+      <VENDOR name="mariadb" version="5.5.31" />
+      <VENDOR name="mysql" version="5.5.31" />
+      <VENDOR name="postgres" version="9.1" />
+      <VENDOR name="mssql" version="10.0" />
+      <VENDOR name="oracle" version="10.2" />
+    </DATABASE>
+    <PHP version="5.6.5" level="required">
+    </PHP>
+    <PCREUNICODE level="optional">
+      <FEEDBACK>
+        <ON_CHECK message="pcreunicodewarning" />
+      </FEEDBACK>
+    </PCREUNICODE>
+    <PHP_EXTENSIONS>
+      <PHP_EXTENSION name="iconv" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="iconvrequired" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="mbstring" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="mbstringrecommended" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="curl" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="curlrequired" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="openssl" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="opensslrecommended" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="tokenizer" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="tokenizerrecommended" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="xmlrpc" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="xmlrpcrecommended" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="soap" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="soaprecommended" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="ctype" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="ctyperequired" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="zip" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="ziprequired" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="zlib" level="required">
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="gd" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="gdrequired" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="simplexml" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="simplexmlrequired" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="spl" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="splrequired" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="pcre" level="required">
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="dom" level="required">
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="xml" level="required">
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="xmlreader" level="required">
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="intl" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="intlrecommended" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="json" level="required">
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="hash" level="required"/>
+    </PHP_EXTENSIONS>
+    <PHP_SETTINGS>
+      <PHP_SETTING name="memory_limit" value="96M" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="settingmemorylimit" />
+        </FEEDBACK>
+      </PHP_SETTING>
+      <PHP_SETTING name="file_uploads" value="1" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="settingfileuploads" />
+        </FEEDBACK>
+      </PHP_SETTING>
+      <PHP_SETTING name="opcache.enable" value="1" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="opcacherecommended" />
+        </FEEDBACK>
+      </PHP_SETTING>
+    </PHP_SETTINGS>
+    <CUSTOM_CHECKS>
+      <CUSTOM_CHECK file="lib/upgradelib.php" function="check_database_storage_engine" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="unsupporteddbstorageengine" />
+        </FEEDBACK>
+      </CUSTOM_CHECK>
+      <CUSTOM_CHECK file="question/engine/upgrade/upgradelib.php" function="quiz_attempts_upgraded" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="quizattemptsupgradedmessage" />
+        </FEEDBACK>
+      </CUSTOM_CHECK>
+      <CUSTOM_CHECK file="lib/upgradelib.php" function="check_slasharguments" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="slashargumentswarning" />
+        </FEEDBACK>
+      </CUSTOM_CHECK>
+      <CUSTOM_CHECK file="lib/upgradelib.php" function="check_database_tables_row_format" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="unsupporteddbtablerowformat" />
+        </FEEDBACK>
+      </CUSTOM_CHECK>
+      <CUSTOM_CHECK file="lib/upgradelib.php" function="check_unoconv_version" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="unoconvwarning" />
+        </FEEDBACK>
+      </CUSTOM_CHECK>
+    </CUSTOM_CHECKS>
+  </MOODLE>
 </COMPATIBILITY_MATRIX>
index 1000f93..382c252 100644 (file)
@@ -30,10 +30,10 @@ if (!file_exists('../config.php')) {
 }
 
 // Check that PHP is of a sufficient version as soon as possible
-if (version_compare(phpversion(), '5.4.4') < 0) {
+if (version_compare(phpversion(), '5.6.5') < 0) {
     $phpversion = phpversion();
     // do NOT localise - lang strings would not work here and we CAN NOT move it to later place
-    echo "Moodle 2.7 or later requires at least PHP 5.4.4 (currently using version $phpversion).<br />";
+    echo "Moodle 3.2 or later requires at least PHP 5.6.5 (currently using version $phpversion).<br />";
     echo "Please upgrade your server software or install older Moodle version.";
     die();
 }
index 717b8f2..2b66c9f 100644 (file)
@@ -36,6 +36,8 @@ require_login($course, false, $cm);
 require_capability('moodle/role:review', $context);
 require_sesskey();
 
+$OUTPUT->header();
+
 list($overridableroles, $overridecounts, $nameswithcounts) = get_overridable_roles($context,
         ROLENAME_BOTH, true);
 
index 4beab69..e700a17 100644 (file)
@@ -40,11 +40,11 @@ $url = new moodle_url('/admin/tool/lp/coursecompetencies.php', $urlparams);
 list($title, $subtitle) = \tool_lp\page_helper::setup_for_course($url, $course);
 
 $output = $PAGE->get_renderer('tool_lp');
+$page = new \tool_lp\output\course_competencies_page($course->id);
+
 echo $output->header();
 echo $output->heading($title);
 
-
-$page = new \tool_lp\output\course_competencies_page($course->id);
 echo $output->render($page);
 
 echo $output->footer();
index 870104e..62b6892 100644 (file)
@@ -36,6 +36,13 @@ function tool_lp_extend_navigation_course($navigation, $course, $coursecontext)
         return;
     }
 
+    // Check access to the course and competencies page.
+    $capabilities = array('moodle/competency:coursecompetencyview', 'moodle/competency:coursecompetencymanage');
+    $context = context_course::instance($course->id);
+    if (!has_any_capability($capabilities, $context) || !can_access_course($course)) {
+        return;
+    }
+
     // Just a link to course competency.
     $title = get_string('competencies', 'core_competency');
     $path = new moodle_url("/admin/tool/lp/coursecompetencies.php", array('courseid' => $course->id));
index c11665e..793b11f 100644 (file)
@@ -140,6 +140,10 @@ class eventobservers {
             $subscriptions = subscription_manager::get_subscriptions_by_event($eventobj);
             $idstosend = array();
             foreach ($subscriptions as $subscription) {
+                // Only proceed to fire events and notifications if the subscription is active.
+                if (!subscription_manager::subscription_is_active($subscription)) {
+                    continue;
+                }
                 $starttime = $now - $subscription->timewindow;
                 $starttime = ($starttime > $subscription->lastnotificationsent) ? $starttime : $subscription->lastnotificationsent;
                 if ($subscription->courseid == 0) {
index 6e25f6e..98518e9 100644 (file)
@@ -55,17 +55,26 @@ class subscription {
      * Magic get method.
      *
      * @param string $prop property to get.
-     *
      * @return mixed
      * @throws \coding_exception
      */
     public function __get($prop) {
-        if (property_exists($this->subscription, $prop)) {
+        if (isset($this->subscription->$prop)) {
             return $this->subscription->$prop;
         }
         throw new \coding_exception('Property "' . $prop . '" doesn\'t exist');
     }
 
+    /**
+     * Magic isset method.
+     *
+     * @param string $prop the property to get.
+     * @return bool true if the property is set, false otherwise.
+     */
+    public function __isset($prop) {
+        return property_exists($this->subscription, $prop);
+    }
+
     /**
      * Get a human readable name for instances associated with this subscription.
      *
index 421984c..c4382e7 100644 (file)
@@ -35,6 +35,10 @@ defined('MOODLE_INTERNAL') || die();
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class subscription_manager {
+
+    /** @const Period of time, in days, after which an inactive subscription will be removed completely.*/
+    const INACTIVE_SUBSCRIPTION_LIFESPAN_IN_DAYS = 30;
+
     /**
      * Subscribe a user to a given rule.
      *
@@ -456,4 +460,78 @@ class subscription_manager {
 
         return false;
     }
+
+    /**
+     * Activates a group of subscriptions based on an input array of ids.
+     *
+     * @since 3.2.0
+     * @param array $ids of subscription ids.
+     * @return bool true if the operation was successful, false otherwise.
+     */
+    public static function activate_subscriptions(array $ids) {
+        global $DB;
+        if (!empty($ids)) {
+            list($sql, $params) = $DB->get_in_or_equal($ids);
+            $success = $DB->set_field_select('tool_monitor_subscriptions', 'inactivedate', '0', 'id ' . $sql, $params);
+            return $success;
+        }
+        return false;
+    }
+
+    /**
+     * Deactivates a group of subscriptions based on an input array of ids.
+     *
+     * @since 3.2.0
+     * @param array $ids of subscription ids.
+     * @return bool true if the operation was successful, false otherwise.
+     */
+    public static function deactivate_subscriptions(array $ids) {
+        global $DB;
+        if (!empty($ids)) {
+            $inactivedate = time();
+            list($sql, $params) = $DB->get_in_or_equal($ids);
+            $success = $DB->set_field_select('tool_monitor_subscriptions', 'inactivedate', $inactivedate, 'id ' . $sql,
+                                             $params);
+            return $success;
+        }
+        return false;
+    }
+
+    /**
+     * Deletes subscriptions which have been inactive for a period of time.
+     *
+     * @since 3.2.0
+     * @param int $userid if provided, only this user's stale subscriptions will be deleted.
+     * @return bool true if the operation was successful, false otherwise.
+     */
+    public static function delete_stale_subscriptions($userid = 0) {
+        global $DB;
+        // Get the expiry duration, in days.
+        $cutofftime = strtotime("-" . self::INACTIVE_SUBSCRIPTION_LIFESPAN_IN_DAYS . " days", time());
+
+        if (!empty($userid)) {
+            // Remove any stale subscriptions for the desired user only.
+            $success = $DB->delete_records_select('tool_monitor_subscriptions',
+                                                  'userid = ? AND inactivedate < ? AND inactivedate <> 0',
+                                                  array($userid, $cutofftime));
+
+        } else {
+            // Remove all stale subscriptions.
+            $success = $DB->delete_records_select('tool_monitor_subscriptions',
+                                                  'inactivedate < ? AND inactivedate <> 0',
+                                                  array($cutofftime));
+        }
+        return $success;
+    }
+
+    /**
+     * Check whether a subscription is active.
+     *
+     * @since 3.2.0
+     * @param \tool_monitor\subscription $subscription instance.
+     * @return bool true if the subscription is active, false otherwise.
+     */
+    public static function subscription_is_active(subscription $subscription) {
+        return empty($subscription->inactivedate);
+    }
 }
diff --git a/admin/tool/monitor/classes/task/check_subscriptions.php b/admin/tool/monitor/classes/task/check_subscriptions.php
new file mode 100644 (file)
index 0000000..8262f10
--- /dev/null
@@ -0,0 +1,274 @@
+<?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_monitor\task;
+use tool_monitor\subscription;
+use tool_monitor\subscription_manager;
+
+/**
+ * Simple task class responsible for activating, deactivating and removing subscriptions.
+ *
+ * Activation/deactivation is managed by looking at the same access rules used to determine whether a user can
+ * subscribe to the rule in the first place.
+ *
+ * Removal occurs when a subscription has been inactive for a period of time exceeding the lifespan, as set by
+ * subscription_manager::get_inactive_subscription_lifespan().
+ *
+ * I.e.
+ *  - Activation:   If a user can subscribe currently, then an existing subscription should be made active.
+ *  - Deactivation: If a user cannot subscribe currently, then an existing subscription should be made inactive.
+ *  - Removal:      If a user has a subscription that has been inactive for longer than the prescribed period, then
+ *                  delete the subscription entirely.
+ *
+ * @since      3.2.0
+ * @package    tool_monitor
+ * @copyright  2016 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class check_subscriptions extends \core\task\scheduled_task {
+
+    /** @var array 1d static cache, indexed by userid, storing whether or not the user has been fully set up.*/
+    protected $userssetupcache = array();
+
+    /** @var array 2d static cache, indexed by courseid and userid, storing whether a user can access the course with
+     *  the 'tool/monitor:subscribe' capability.
+     */
+    protected $courseaccesscache = array();
+
+    /**
+     * Get a descriptive name for this task.
+     *
+     * @since 3.2.0
+     * @return string name of the task.
+     */
+    public function get_name() {
+        return get_string('taskchecksubscriptions', 'tool_monitor');
+    }
+
+    /**
+     * Checks all course-level rule subscriptions and activates/deactivates based on current course access.
+     *
+     * The ordering of checks within the task is important for optimisation purposes. The aim is to be able to make a decision
+     * about whether to activate/deactivate each subscription without making unnecessary checks. The ordering roughly follows the
+     * context model, starting with system and user checks and moving down to course and course-module only when necessary.
+     *
+     * For example, if the user is suspended, then any active subscription is made inactive right away. I.e. there is no need to
+     * check site-level, course-level or course-module-level permissions. Likewise, if a subscriptions is site-level, there is no
+     * need to check course-level and course-module-level permissions.
+     *
+     * The task performs the following checks, in this order:
+     * 1. Check for a suspended user, breaking if suspended.
+     * 2. Check for an incomplete (not set up) user, breaking if not fully set up.
+     * 3. Check for the required capability in the relevant context, breaking if the capability is not found.
+     * 4. Check whether the subscription is site-context, breaking if true.
+     * 5. Check whether the user has course access, breaking only if the subscription is not also course-module-level.
+     * 6. Check whether the user has course-module access.
+     *
+     * @since 3.2.0
+     */
+    public function execute() {
+        global $DB;
+
+        if (!get_config('tool_monitor', 'enablemonitor')) {
+            return; // The tool is disabled. Nothing to do.
+        }
+
+        $toactivate   = array(); // Store the ids of subscriptions to be activated upon completion.
+        $todeactivate = array(); // Store the ids of subscriptions to be deactivated upon completion.
+
+        // Resultset rows are ordered by userid and courseid to work nicely with get_fast_modinfo() caching.
+        $sql = "SELECT u.id AS userid, u.firstname AS userfirstname, u.lastname AS userlastname, u.suspended AS usersuspended,
+                       u.email AS useremail, c.visible as coursevisible, c.cacherev as coursecacherev, s.courseid AS subcourseid,
+                       s.userid AS subuserid, s.cmid AS subcmid, s.inactivedate AS subinactivedate, s.id AS subid
+                  FROM {user} u
+                  JOIN {tool_monitor_subscriptions} s ON (s.userid = u.id)
+             LEFT JOIN {course} c ON (c.id = s.courseid)
+                 WHERE u.id = s.userid
+              ORDER BY s.userid, s.courseid";
+        $rs = $DB->get_recordset_sql($sql);
+
+        foreach ($rs as $row) {
+            // Create skeleton records from the result. This should be enough to use in subsequent access calls and avoids DB hits.
+            $sub = $this->get_subscription_from_rowdata($row);
+            $sub = new subscription($sub);
+            if (!isset($user) || $user->id != $sub->userid) {
+                $user= $this->get_user_from_rowdata($row);
+            }
+            if ((!isset($course) || $course->id != $sub->courseid) && !empty($sub->courseid)) {
+                $course = $this->get_course_from_rowdata($row);
+            }
+
+            // The user is suspended at site level, so deactivate any active subscriptions.
+            if ($user->suspended) {
+                if (subscription_manager::subscription_is_active($sub)) {
+                    $todeactivate[] = $sub->id;
+                }
+                continue;
+            }
+
+            // Is the user fully set up? As per require_login on the subscriptions page.
+            if (!$this->is_user_setup($user)) {
+                if (subscription_manager::subscription_is_active($sub)) {
+                    $todeactivate[] = $sub->id;
+                }
+                continue;
+            }
+
+            // Determine the context, based on the subscription course id.
+            $sitelevelsubscription = false;
+            if (empty($sub->courseid)) {
+                $context = \context_system::instance();
+                $sitelevelsubscription = true;
+            } else {
+                $context = \context_course::instance($sub->courseid);
+            }
+
+            // Check capability in the context.
+            if (!has_capability('tool/monitor:subscribe', $context, $user)) {
+                if (subscription_manager::subscription_is_active($sub)) {
+                    $todeactivate[] = $sub->id;
+                }
+                continue;
+            }
+
+            // If the subscription is site-level, then we've run all the checks required to make an access decision.
+            if ($sitelevelsubscription) {
+                if (!subscription_manager::subscription_is_active($sub)) {
+                    $toactivate[] = $sub->id;
+                }
+                continue;
+            }
+
+            // Check course access.
+            if (!$this->user_can_access_course($user, $course, 'tool/monitor:subscribe')) {
+                if (subscription_manager::subscription_is_active($sub)) {
+                    $todeactivate[] = $sub->id;
+                }
+                continue;
+            }
+
+            // If the subscription has no course module relationship.
+            if (empty($sub->cmid)) {
+                if (!subscription_manager::subscription_is_active($sub)) {
+                    $toactivate[] = $sub->id;
+                }
+                continue;
+            }
+
+            // Otherwise, check the course module info. We use the same checks as on the subscription page.
+            $modinfo = get_fast_modinfo($course, $sub->userid);
+            $cm = $modinfo->get_cm($sub->cmid);
+            if (!$cm || !$cm->uservisible || !$cm->available) {
+                if (subscription_manager::subscription_is_active($sub)) {
+                    $todeactivate[] = $sub->id;
+                }
+                continue;
+            }
+
+            // The course module is available and visible, so make a decision.
+            if (!subscription_manager::subscription_is_active($sub)) {
+                $toactivate[] = $sub->id;
+            }
+        }
+        $rs->close();
+
+        // Activate/deactivate/delete relevant subscriptions.
+        subscription_manager::activate_subscriptions($toactivate);
+        subscription_manager::deactivate_subscriptions($todeactivate);
+        subscription_manager::delete_stale_subscriptions();
+    }
+
+    /**
+     * Determines whether a user is fully set up, using cached results where possible.
+     *
+     * @since 3.2.0
+     * @param \stdClass $user the user record.
+     * @return bool true if the user is fully set up, false otherwise.
+     */
+    protected function is_user_setup($user) {
+        if (!isset($this->userssetupcache[$user->id])) {
+            $this->userssetupcache[$user->id] = !user_not_fully_set_up($user);
+        }
+        return $this->userssetupcache[$user->id];
+    }
+
+    /**
+     * Determines a user's access to a course with a given capability, using cached results where possible.
+     *
+     * @since 3.2.0
+     * @param \stdClass $user the user record.
+     * @param \stdClass $course the course record.
+     * @param string $capability the capability to check.
+     * @return bool true if the user can access the course with the specified capability, false otherwise.
+     */
+    protected function user_can_access_course($user, $course, $capability) {
+        if (!isset($this->courseaccesscache[$course->id][$user->id][$capability])) {
+            $this->courseaccesscache[$course->id][$user->id][$capability] = can_access_course($course, $user, $capability, true);
+        }
+        return $this->courseaccesscache[$course->id][$user->id][$capability];
+    }
+
+    /**
+     * Returns a partial subscription record, created from properties of the supplied recordset row object.
+     * Intended to return a minimal record for specific use within this class and in subsequent access control calls only.
+     *
+     * @since 3.2.0
+     * @param \stdClass $rowdata the row object.
+     * @return \stdClass a partial subscription record.
+     */
+    protected function get_subscription_from_rowdata($rowdata) {
+        $sub = new \stdClass();
+        $sub->id = $rowdata->subid;
+        $sub->userid = $rowdata->subuserid;
+        $sub->courseid = $rowdata->subcourseid;
+        $sub->cmid = $rowdata->subcmid;
+        $sub->inactivedate = $rowdata->subinactivedate;
+        return $sub;
+    }
+
+    /**
+     * Returns a partial course record, created from properties of the supplied recordset row object.
+     * Intended to return a minimal record for specific use within this class and in subsequent access control calls only.
+     *
+     * @since 3.2.0
+     * @param \stdClass $rowdata the row object.
+     * @return \stdClass a partial course record.
+     */
+    protected function get_course_from_rowdata($rowdata) {
+        $course = new \stdClass();
+        $course->id = $rowdata->subcourseid;
+        $course->visible = $rowdata->coursevisible;
+        $course->cacherev = $rowdata->coursecacherev;
+        return $course;
+    }
+
+    /**
+     * Returns a partial user record, created from properties of the supplied recordset row object.
+     * Intended to return a minimal record for specific use within this class and in subsequent access control calls only.
+     *
+     * @since 3.2.0
+     * @param \stdClass $rowdata the row object.
+     * @return \stdClass a partial user record.
+     */
+    protected function get_user_from_rowdata($rowdata) {
+        $user = new \stdClass();
+        $user->id = $rowdata->userid;
+        $user->firstname = $rowdata->userfirstname;
+        $user->lastname = $rowdata->userlastname;
+        $user->email = $rowdata->useremail;
+        $user->suspended = $rowdata->usersuspended;
+        return $user;
+    }
+}
index 7729199..9a3f1a1 100644 (file)
@@ -38,6 +38,7 @@
         <FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="User id of the subscriber"/>
         <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Timestamp of when this subscription was created"/>
         <FIELD NAME="lastnotificationsent" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Timestamp of the time when a notification was last sent for this subscription."/>
+        <FIELD NAME="inactivedate" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
index e70c344..20f324d 100644 (file)
@@ -32,5 +32,14 @@ $tasks = array(
         'day' => '*',
         'dayofweek' => '*',
         'month' => '*'
+    ),
+    array(
+        'classname' => 'tool_monitor\task\check_subscriptions',
+        'blocking' => 0,
+        'minute' => 'R',
+        'hour' => 'R',
+        'day' => '*',
+        'dayofweek' => '*',
+        'month' => '*'
     )
 );
index 9f8e53b..aaea29a 100644 (file)
@@ -62,5 +62,20 @@ function xmldb_tool_monitor_upgrade($oldversion) {
     // Moodle v3.1.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2016052305) {
+
+        // Define field inactivedate to be added to tool_monitor_subscriptions.
+        $table = new xmldb_table('tool_monitor_subscriptions');
+        $field = new xmldb_field('inactivedate', XMLDB_TYPE_INTEGER, '10', null, true, null, 0, 'lastnotificationsent');
+
+        // Conditionally launch add field inactivedate.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Monitor savepoint reached.
+        upgrade_plugin_savepoint(true, 2016052305, 'tool', 'monitor');
+    }
+
     return true;
 }
index c0fbf7f..fb47717 100644 (file)
@@ -99,4 +99,5 @@ $string['subhelp'] = 'Subscription details';
 $string['subhelp_help'] = 'This subscription listens for when the event \'{$a->eventname}\' has been triggered in \'{$a->moduleinstance}\' {$a->frequency} time(s) in {$a->minutes} minute(s).';
 $string['subscribeto'] = 'Subscribe to rule "{$a}"';
 $string['taskcleanevents'] = 'Removes any unnecessary event monitor events';
+$string['taskchecksubscriptions'] = 'Activate/deactivate invalid rule subscriptions';
 $string['unsubscribe'] = 'Unsubscribe';
diff --git a/admin/tool/monitor/tests/subscription_test.php b/admin/tool/monitor/tests/subscription_test.php
new file mode 100644 (file)
index 0000000..3548c7f
--- /dev/null
@@ -0,0 +1,65 @@
+<?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/>.
+defined('MOODLE_INTERNAL') || exit();
+
+/**
+ * Unit tests for the subscription class.
+ * @since 3.2.0
+ *
+ * @package    tool_monitor
+ * @category   test
+ * @copyright  2016 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_monitor_subscription_testcase extends advanced_testcase {
+
+    /**
+     * @var \tool_monitor\subscription $subscription object.
+     */
+    private $subscription;
+
+    /**
+     * Test set up.
+     */
+    public function setUp() {
+        $this->resetAfterTest(true);
+
+        // Create the mock subscription.
+        $sub = new stdClass();
+        $sub->id = 100;
+        $sub->name = 'My test rule';
+        $sub->courseid = 20;
+        $this->subscription = $this->getMock('\tool_monitor\subscription',null, array($sub));
+    }
+
+    /**
+     * Test for the magic __isset method.
+     */
+    public function test_magic_isset() {
+        $this->assertEquals(true, isset($this->subscription->name));
+        $this->assertEquals(true, isset($this->subscription->courseid));
+        $this->assertEquals(false, isset($this->subscription->ruleid));
+    }
+
+    /**
+     * Test for the magic __get method.
+     */
+    public function test_magic_get() {
+        $this->assertEquals(20, $this->subscription->courseid);
+        $this->setExpectedException('coding_exception');
+        $this->subscription->ruleid;
+    }
+}
diff --git a/admin/tool/monitor/tests/task_check_subscriptions_test.php b/admin/tool/monitor/tests/task_check_subscriptions_test.php
new file mode 100644 (file)
index 0000000..b526784
--- /dev/null
@@ -0,0 +1,364 @@
+<?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/>.
+defined('MOODLE_INTERNAL') || exit();
+
+/**
+ * Unit tests for the tool_monitor clean events task.
+ * @since 3.2.0
+ *
+ * @package    tool_monitor
+ * @category   test
+ * @copyright  2016 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_monitor_task_check_subscriptions_testcase extends advanced_testcase {
+
+    private $course;
+    private $user;
+    private $rule;
+    private $subscription;
+    private $teacherrole;
+    private $studentrole;
+
+    /**
+     * Test set up.
+     */
+    public function setUp() {
+        global $DB;
+        set_config('enablemonitor', 1, 'tool_monitor');
+        $this->resetAfterTest(true);
+
+        // All tests defined herein need a user, course, rule and subscription, so set these up.
+        $this->user = $this->getDataGenerator()->create_user();
+        $this->course = $this->getDataGenerator()->create_course();
+
+        $rule = new stdClass();
+        $rule->userid = 2; // Rule created by admin.
+        $rule->courseid = $this->course->id;
+        $rule->plugin = 'mod_book';
+        $rule->eventname = '\mod_book\event\course_module_viewed';
+        $rule->timewindow = 500;
+        $monitorgenerator = $this->getDataGenerator()->get_plugin_generator('tool_monitor');
+        $this->rule = $monitorgenerator->create_rule($rule);
+
+        $sub = new stdClass();
+        $sub->courseid = $this->course->id;
+        $sub->userid = $this->user->id;
+        $sub->ruleid = $this->rule->id;
+        $this->subscription = $monitorgenerator->create_subscription($sub);
+
+        // Also set up a student and a teacher role for use in some tests.
+        $this->teacherrole = $DB->get_record('role', array('shortname' => 'teacher'));
+        $this->studentrole = $DB->get_record('role', array('shortname' => 'student'));
+    }
+
+    /**
+     * Reloads the subscription object from the DB.
+     *
+     * @return void.
+     */
+    private function reload_subscription() {
+        global $DB;
+        $sub = $DB->get_record('tool_monitor_subscriptions', array('id' => $this->subscription->id));
+        $this->subscription = new \tool_monitor\subscription($sub);
+    }
+
+    /**
+     * Test to confirm the task is named correctly.
+     */
+    public function test_task_name() {
+        $task = new \tool_monitor\task\check_subscriptions();
+        $this->assertEquals(get_string('taskchecksubscriptions', 'tool_monitor'), $task->get_name());
+    }
+
+    /**
+     * Test to confirm that site level subscriptions are activated and deactivated according to system capabilities.
+     */
+    public function test_site_level_subscription() {
+        // Create a site level subscription.
+        $monitorgenerator = $this->getDataGenerator()->get_plugin_generator('tool_monitor');
+        $sub = new stdClass();
+        $sub->userid = $this->user->id;
+        $sub->ruleid = $this->rule->id;
+        $this->subscription = $monitorgenerator->create_subscription($sub);
+
+        // Run the task.
+        $task = new \tool_monitor\task\check_subscriptions();
+        $task->execute();
+
+        // The subscription should be inactive as the user doesn't have the capability. Pass in the id only to refetch the data.
+        $this->reload_subscription();
+        $this->assertEquals(false, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+
+        // Now, assign the user as a teacher role at system context.
+        $this->getDataGenerator()->role_assign($this->teacherrole->id, $this->user->id, context_system::instance());
+
+        // Run the task.
+        $task = new \tool_monitor\task\check_subscriptions();
+        $task->execute();
+
+        // The subscription should be active now. Pass in the id only to refetch the data.
+        $this->reload_subscription();
+        $this->assertEquals(true, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+    }
+
+    /**
+     * Test to confirm that if the module is disabled, no changes are made to active subscriptions.
+     */
+    public function test_module_disabled() {
+        set_config('enablemonitor', 0, 'tool_monitor');
+
+        // Subscription should be active to start with.
+        $this->assertEquals(true, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+
+        // Run the task. Note, we never enrolled the user.
+        $task = new \tool_monitor\task\check_subscriptions();
+        $task->execute();
+
+        // The subscription should still be active. Pass in the id only to refetch the data.
+        $this->reload_subscription();
+        $this->assertEquals(true, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+    }
+
+    /**
+     * Test to confirm an active, valid subscription stays active once the scheduled task is run.
+     */
+    public function test_active_unaffected() {
+        // Enrol the user as a teacher. This role should have the required capability.
+        $this->getDataGenerator()->enrol_user($this->user->id, $this->course->id, $this->teacherrole->id);
+
+        // Subscription should be active to start with.
+        $this->assertEquals(true, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+
+        // Run the task.
+        $task = new \tool_monitor\task\check_subscriptions();
+        $task->execute();
+
+        // The subscription should still be active. Pass in the id only to refetch the data.
+        $this->reload_subscription();
+        $this->assertEquals(true, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+    }
+
+    /**
+     * Test to confirm that a subscription for a user without an enrolment to the course is made inactive.
+     */
+    public function test_course_enrolment() {
+        // Subscription should be active until deactivated by the scheduled task. Remember, by default the test setup
+        // doesn't enrol the user, so the first run of the task should deactivate it.
+        $this->assertEquals(true, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+
+        // Run the task.
+        $task = new \tool_monitor\task\check_subscriptions();
+        $task->execute();
+
+        // The subscription should NOT be active. Pass in the id only to refetch the data.
+        $this->reload_subscription();
+        $this->assertEquals(false, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+
+        // Enrol the user.
+        $this->getDataGenerator()->enrol_user($this->user->id, $this->course->id, $this->teacherrole->id);
+
+        // Run the task.
+        $task = new \tool_monitor\task\check_subscriptions();
+        $task->execute();
+
+        // Subscription should now be active again.
+        $this->reload_subscription();
+        $this->assertEquals(true, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+    }
+
+    /**
+     * Test to confirm that subscriptions for enrolled users without the required capability are made inactive.
+     */
+    public function test_enrolled_user_with_no_capability() {
+        // Enrol the user. By default, students won't have the required capability.
+        $this->getDataGenerator()->enrol_user($this->user->id, $this->course->id, $this->studentrole->id);
+
+        // The subscription should be active to start with. Pass in the id only to refetch the data.
+        $this->assertEquals(true, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+
+        // Run the task.
+        $task = new \tool_monitor\task\check_subscriptions();
+        $task->execute();
+
+        // The subscription should NOT be active. Pass in the id only to refetch the data.
+        $this->reload_subscription();
+        $this->assertEquals(false, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+    }
+
+    /**
+     * Test to confirm that subscriptions for users who fail can_access_course(), are deactivated.
+     */
+    public function test_can_access_course() {
+        // Enrol the user as a teacher. This role should have the required capability.
+        $this->getDataGenerator()->enrol_user($this->user->id, $this->course->id, $this->teacherrole->id);
+
+        // Strip the ability to see hidden courses, so we'll fail the check_subscriptions->user_can_access_course call.
+        $context = \context_course::instance($this->course->id);
+        assign_capability('moodle/course:viewhiddencourses', CAP_PROHIBIT, $this->teacherrole->id, $context);
+
+        // Subscription should be active to start with.
+        $this->assertEquals(true, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+
+        // Hide the course.
+        course_change_visibility($this->course->id, false);
+
+        // Run the task.
+        $task = new \tool_monitor\task\check_subscriptions();
+        $task->execute();
+
+        // The subscription should be inactive. Pass in the id only to refetch the data.
+        $this->reload_subscription();
+        $this->assertEquals(false, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+    }
+
+    /**
+     * Test to confirm that subscriptions for enrolled users who don't have CM access, are deactivated.
+     */
+    public function test_cm_access() {
+        // Enrol the user as a student but grant to ability to subscribe. Students cannot view hidden activities.
+        $context = \context_course::instance($this->course->id);
+        assign_capability('tool/monitor:subscribe', CAP_ALLOW, $this->studentrole->id, $context);
+        $this->getDataGenerator()->enrol_user($this->user->id, $this->course->id, $this->studentrole->id);
+
+        // Generate a course module.
+        $book = $this->getDataGenerator()->create_module('book', array('course' => $this->course->id));
+
+        // And add a subscription to it.
+        $sub = new stdClass();
+        $sub->courseid = $this->course->id;
+        $sub->userid = $this->user->id;
+        $sub->ruleid = $this->rule->id;
+        $sub->cmid = $book->cmid;
+        $monitorgenerator = $this->getDataGenerator()->get_plugin_generator('tool_monitor');
+        $this->subscription = $monitorgenerator->create_subscription($sub);
+
+        // The subscription should be active to start with. Pass in the id only to refetch the data.
+        $this->assertEquals(true, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+
+        // Run the task.
+        $task = new \tool_monitor\task\check_subscriptions();
+        $task->execute();
+
+        // The subscription should still be active. Pass in the id only to refetch the data.
+        $this->reload_subscription();
+        $this->assertEquals(true, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+
+        // Make the course module invisible, which should in turn make the subscription inactive.
+        set_coursemodule_visible($book->cmid, false);
+
+        // Run the task.
+        $task = new \tool_monitor\task\check_subscriptions();
+        $task->execute();
+
+        // The subscription should NOT be active. Pass in the id only to refetch the data.
+        $this->reload_subscription();
+        $this->assertEquals(false, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+
+        // Make the course module visible again.
+        set_coursemodule_visible($book->cmid, true);
+
+        // Run the task.
+        $task = new \tool_monitor\task\check_subscriptions();
+        $task->execute();
+
+        // The subscription should be active. Pass in the id only to refetch the data.
+        $this->reload_subscription();
+        $this->assertEquals(true, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+    }
+
+    /**
+     * Test to confirm that long term inactive subscriptions are removed entirely.
+     */
+    public function test_stale_subscription_removal() {
+        global $DB;
+        // Manually set the inactivedate to 1 day older than the limit allowed.
+        $daysold = 1 + \tool_monitor\subscription_manager::INACTIVE_SUBSCRIPTION_LIFESPAN_IN_DAYS;
+
+        $inactivedate = strtotime("-$daysold days", time());
+        $DB->set_field('tool_monitor_subscriptions', 'inactivedate', $inactivedate, array('id' => $this->subscription->id));
+
+        // Subscription should be inactive to start with.
+        $this->reload_subscription();
+        $this->assertEquals(false, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+
+        // Run the task.
+        $task = new \tool_monitor\task\check_subscriptions();
+        $task->execute();
+
+        // Subscription should now not exist at all.
+        $this->assertEquals(false, $DB->record_exists('tool_monitor_subscriptions', array('id' => $this->subscription->id)));
+    }
+
+    /**
+     * Test to confirm that subscriptions for a partially set up user are deactivated.
+     */
+    public function test_user_not_fully_set_up() {
+        global $DB;
+
+        // Enrol the user as a teacher.
+        $this->getDataGenerator()->enrol_user($this->user->id, $this->course->id, $this->teacherrole->id);
+
+        // The subscription should be active to start.
+        $this->assertEquals(true, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+
+        // Unset the user's email address, so we fail the check_subscriptions->is_user_setup() call.
+        $DB->set_field('user', 'email', '', array('id' => $this->user->id));
+
+        // Run the task.
+        $task = new \tool_monitor\task\check_subscriptions();
+        $task->execute();
+
+        // The subscription should now be inactive.
+        $this->reload_subscription();
+        $this->assertEquals(false, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+    }
+
+    /**
+     * Test to confirm that a suspended user's subscriptions are deactivated properly.
+     */
+    public function test_suspended_user() {
+        global $DB;
+
+        // Enrol the user as a teacher. This role should have the required capability.
+        $this->getDataGenerator()->enrol_user($this->user->id, $this->course->id, $this->teacherrole->id);
+
+        // Subscription should be active to start with.
+        $this->assertEquals(true, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+
+        // Suspend the user.
+        $DB->set_field('user', 'suspended', '1', array('id' => $this->user->id));
+
+        // Run the task.
+        $task = new \tool_monitor\task\check_subscriptions();
+        $task->execute();
+
+        // The subscription should now be inactive.
+        $this->reload_subscription();
+        $this->assertEquals(false, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+
+        // Unsuspend the user.
+        $DB->set_field('user', 'suspended', '0', array('id' => $this->user->id));
+
+        // Run the task.
+        $task = new \tool_monitor\task\check_subscriptions();
+        $task->execute();
+
+        // The subscription should now be active again.
+        $this->reload_subscription();
+        $this->assertEquals(true, \tool_monitor\subscription_manager::subscription_is_active($this->subscription));
+    }
+}
index c078165..9e5a5a3 100644 (file)
@@ -26,6 +26,6 @@
 
 defined('MOODLE_INTERNAL') || die;
 
-$plugin->version   = 2016052300;     // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version   = 2016052305;     // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires  = 2016051900;     // Requires this Moodle version.
 $plugin->component = 'tool_monitor'; // Full name of the plugin (used for diagnostics).
index 5900d32..99d0ce5 100644 (file)
@@ -122,7 +122,7 @@ class edit_field extends XMLDBAction {
             $o.= '      <input type="hidden" name ="name" value="' .  s($field->getName()) .'" />';
             $o.= '      <tr valign="top"><td>Name:</td><td colspan="2">' . s($field->getName()) . '</td></tr>';
         } else {
-            $o.= '      <tr valign="top"><td><label for="name" accesskey="n">Name:</label></td><td colspan="2"><input name="name" type="text" size="30" maxlength="30" id="name" value="' . s($field->getName()) . '" /></td></tr>';
+            $o.= '      <tr valign="top"><td><label for="name" accesskey="n">Name:</label></td><td colspan="2"><input name="name" type="text" size="'.xmldb_field::NAME_MAX_LENGTH.'" maxlength="'.xmldb_field::NAME_MAX_LENGTH.'" id="name" value="' . s($field->getName()) . '" /></td></tr>';
         }
         // XMLDB field comment
         $o.= '      <tr valign="top"><td><label for="comment" accesskey="c">Comment:</label></td><td colspan="2"><textarea name="comment" rows="3" cols="80" id="comment">' . s($field->getComment()) . '</textarea></td></tr>';
index 7725c9e..a72b1c5 100644 (file)
@@ -113,7 +113,7 @@ class edit_index extends XMLDBAction {
         if ($structure->getIndexUses($table->getName(), $index->getName())) {
             $disabled = ' disabled="disabled " ';
         }
-        $o.= '      <tr valign="top"><td><label for="name" accesskey="n">Name:</label></td><td colspan="2"><input name="name" type="text" size="30" id="name"' . $disabled . ' value="' . s($index->getName()) . '" /></td></tr>';
+        $o.= '      <tr valign="top"><td><label for="name" accesskey="n">Name:</label></td><td colspan="2"><input name="name" type="text" size="'.xmldb_field::NAME_MAX_LENGTH.'" id="name"' . $disabled . ' value="' . s($index->getName()) . '" /></td></tr>';
         // XMLDB key comment
         $o.= '      <tr valign="top"><td><label for="comment" accesskey="c">Comment:</label></td><td colspan="2"><textarea name="comment" rows="3" cols="80" id="comment">' . s($index->getComment()) . '</textarea></td></tr>';
         // xmldb_index Type
index c1d1c3b..6e32acf 100644 (file)
@@ -113,7 +113,7 @@ class edit_key extends XMLDBAction {
         if ($structure->getKeyUses($table->getName(), $key->getName())) {
             $disabled = ' disabled="disabled " ';
         }
-        $o.= '      <tr valign="top"><td><label for="name" accesskey="n">Name:</label></td><td colspan="2"><input name="name" type="text" size="30" id="name"' . $disabled . ' value="' . s($key->getName()) . '" /></td></tr>';
+        $o.= '      <tr valign="top"><td><label for="name" accesskey="n">Name:</label></td><td colspan="2"><input name="name" type="text" size="'.xmldb_field::NAME_MAX_LENGTH.'" id="name"' . $disabled . ' value="' . s($key->getName()) . '" /></td></tr>';
         // XMLDB key comment
         $o.= '      <tr valign="top"><td><label for="comment" accesskey="c">Comment:</label></td><td colspan="2"><textarea name="comment" rows="3" cols="80" id="comment">' . s($key->getComment()) . '</textarea></td></tr>';
         // xmldb_key Type
index 4199a6c..5c2e7b9 100644 (file)
@@ -129,7 +129,7 @@ class edit_table extends XMLDBAction {
         if ($structure->getTableUses($table->getName())) {
             $o.= '      <tr valign="top"><td>Name:</td><td><input type="hidden" name ="name" value="' . s($table->getName()) . '" />' . s($table->getName()) .'</td></tr>';
         } else {
-            $o.= '      <tr valign="top"><td><label for="name" accesskey="p">Name:</label></td><td><input name="name" type="text" size="28" maxlength="28" id="name" value="' . s($table->getName()) . '" /></td></tr>';
+            $o.= '      <tr valign="top"><td><label for="name" accesskey="p">Name:</label></td><td><input name="name" type="text" size="'.xmldb_table::NAME_MAX_LENGTH.'" maxlength="'.xmldb_table::NAME_MAX_LENGTH.'" id="name" value="' . s($table->getName()) . '" /></td></tr>';
         }
         $o.= '      <tr valign="top"><td><label for="comment" accesskey="c">Comment:</label></td><td><textarea name="comment" rows="3" cols="80" id="comment">' . s($table->getComment()) . '</textarea></td></tr>';
         $o.= '      <tr valign="top"><td>&nbsp;</td><td><input type="submit" value="' .$this->str['change'] . '" /></td></tr>';
index 79a8cc5..1e12a36 100644 (file)
@@ -88,7 +88,7 @@ class course_competency_settings extends persistent {
     public static function can_read($courseid) {
         $context = context_course::instance($courseid);
 
-        $capabilities = array('moodle/competency:coursecompetencyview');
+        $capabilities = array('moodle/competency:coursecompetencyview', 'moodle/competency:coursecompetencymanage');
 
         return has_any_capability($capabilities, $context);
     }
index ee734d8..73e59f1 100644 (file)
@@ -1,4 +1,9 @@
 {
+    "name": "moodle/moodle",
+    "license": "GPL-3.0",
+    "description": "Moodle - the world's open source learning platform",
+    "type": "project",
+    "homepage": "https://moodle.org",
     "require-dev": {
         "phpunit/phpunit": "4.8.*",
         "phpunit/dbUnit": "1.4.*",
index e868841..c43f1cd 100644 (file)
@@ -195,9 +195,11 @@ class core_course_external extends external_api {
                 $sectionvalues['id'] = $section->id;
                 $sectionvalues['name'] = get_section_name($course, $section);
                 $sectionvalues['visible'] = $section->visible;
+
+                $options = (object) array('noclean' => true);
                 list($sectionvalues['summary'], $sectionvalues['summaryformat']) =
                         external_format_text($section->summary, $section->summaryformat,
-                                $context->id, 'course', 'section', $section->id);
+                                $context->id, 'course', 'section', $section->id, $options);
                 $sectionvalues['section'] = $section->section;
                 $sectioncontents = array();
 
index 549b054..8ec184b 100644 (file)
@@ -686,6 +686,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
      * @return array A list with the course object and course modules objects
      */
     private function prepare_get_course_contents_test() {
+        global $DB;
         $course  = self::getDataGenerator()->create_course();
         $forumdescription = 'This is the forum description';
         $forum = $this->getDataGenerator()->create_module('forum',
@@ -710,6 +711,10 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $roleid = $this->assignUserCapability('moodle/course:view', $context->id);
         $this->assignUserCapability('moodle/course:update', $context->id, $roleid);
 
+        $conditions = array('course' => $course->id, 'section' => 2);
+        $DB->set_field('course_sections', 'summary', 'Text with iframe <iframe src="https://moodle.org"></iframe>', $conditions);
+        rebuild_course_cache($course->id, true);
+
         return array($course, $forumcm, $datacm, $pagecm, $labelcm, $urlcm);
     }
 
@@ -755,6 +760,8 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $this->assertCount(4, $firstsection['modules']);
         $this->assertCount(1, $lastsection['modules']);
         $this->assertEquals(2, $lastsection['section']);
+        $this->assertContains('<iframe', $lastsection['summary']);
+        $this->assertContains('</iframe>', $lastsection['summary']);
 
         try {
             $sections = core_course_external::get_course_contents($course->id,
index 6a7209d..4bdcf41 100644 (file)
@@ -62,10 +62,10 @@ date_default_timezone_set(@date_default_timezone_get());
 @ini_set('display_errors', '1');
 
 // Check that PHP is of a sufficient version.
-if (version_compare(phpversion(), '5.4.4') < 0) {
+if (version_compare(phpversion(), '5.6.5') < 0) {
     $phpversion = phpversion();
     // do NOT localise - lang strings would not work here and we CAN not move it after installib
-    echo "Moodle 2.7 or later requires at least PHP 5.4.4 (currently using version $phpversion).<br />";
+    echo "Moodle 3.2 or later requires at least PHP 5.6.5 (currently using version $phpversion).<br />";
     echo "Please upgrade your server software or install older Moodle version.";
     die;
 }
index c7219ec..bfe296c 100644 (file)
@@ -81,6 +81,7 @@ $string['runindexertest'] = 'Run indexer test';
 $string['score'] = 'Score';
 $string['search'] = 'Search';
 $string['search:mycourse'] = 'My courses';
+$string['search:user'] = 'Users';
 $string['searcharea'] = 'Search area';
 $string['searching'] = 'Searching in ...';
 $string['searchnotpermitted'] = 'You are not allowed to do a search';
index d479d28..4d5abac 100644 (file)
Binary files a/lib/amd/build/permissionmanager.min.js and b/lib/amd/build/permissionmanager.min.js differ
index cb77bca..3cb6034 100644 (file)
@@ -54,7 +54,8 @@ define(['jquery', 'core/config', 'core/notification', 'core/templates'], functio
             sesskey: config.sesskey
         };
 
-        $.post(adminurl + 'roles/ajax.php', params)
+        // Need to tell jQuery to expect JSON as the content type may not be correct (MDL-55041).
+        $.post(adminurl + 'roles/ajax.php', params, null, 'json')
             .done(function(data) {
               try {
                   overideableroles = data;
@@ -88,7 +89,7 @@ define(['jquery', 'core/config', 'core/notification', 'core/templates'], functio
             action: action,
             capability: row.data('name')
         };
-        $.post(adminurl + 'roles/ajax.php', params)
+        $.post(adminurl + 'roles/ajax.php', params, null, 'json')
         .done(function(data) {
             var action = data;
             try {
index 4db12f8..9103fcd 100644 (file)
@@ -1089,7 +1089,7 @@ function get_backpack_settings($userid, $refresh = false) {
                 $badges = $backpack->get_badges($collection->collectionid);
                 if (isset($badges->badges)) {
                     $out->badges = array_merge($out->badges, $badges->badges);
-                    $out->totalbadges += count($out->badges);
+                    $out->totalbadges += count($badges->badges);
                 } else {
                     $out->badges = array_merge($out->badges, array());
                 }
index 12719cb..9eac43b 100644 (file)
@@ -26,6 +26,7 @@ namespace core\update;
 
 use core_component;
 use coding_exception;
+use moodle_exception;
 use SplFileInfo;
 use RecursiveDirectoryIterator;
 use RecursiveIteratorIterator;
@@ -159,15 +160,18 @@ class code_manager {
      */
     public function unzip_plugin_file($zipfilepath, $targetdir, $rootdir = '') {
 
+        // Extract the package into a temporary location.
         $fp = get_file_packer('application/zip');
-        $files = $fp->extract_to_pathname($zipfilepath, $targetdir);
+        $tempdir = make_request_directory();
+        $files = $fp->extract_to_pathname($zipfilepath, $tempdir);
 
         if (!$files) {
             return array();
         }
 
+        // If requested, rename the root directory of the plugin.
         if (!empty($rootdir)) {
-            $files = $this->rename_extracted_rootdir($targetdir, $rootdir, $files);
+            $files = $this->rename_extracted_rootdir($tempdir, $rootdir, $files);
         }
 
         // Sometimes zip may not contain all parent directories, add them to make it consistent.
@@ -187,6 +191,9 @@ class code_manager {
             }
         }
 
+        // Move the extracted files into the target location.
+        $this->move_extracted_plugin_files($tempdir, $targetdir, $files);
+
         // Set the permissions of extracted subdirs and files.
         $this->set_plugin_files_permissions($targetdir, $files);
 
@@ -443,12 +450,10 @@ class code_manager {
     /**
      * Renames the root directory of the extracted ZIP package.
      *
-     * This method does not validate the presence of the single root directory
-     * (it is the validator's duty). It just searches for the first directory
-     * under the given location and renames it.
-     *
-     * The method will not rename the root if the requested location already
-     * exists.
+     * This internal helper method assumes that the plugin ZIP package has been
+     * extracted into a temporary empty directory so the plugin folder is the
+     * only folder there. The ZIP package is supposed to be validated so that
+     * it contains just a single root folder.
      *
      * @param string $dirname fullpath location of the extracted ZIP package
      * @param string $rootdir the requested name of the root directory
@@ -473,8 +478,11 @@ class code_manager {
                 continue;
             }
             if (is_dir($dirname.'/'.$item)) {
+                if ($found !== null and $found !== $item) {
+                    // Multiple directories found.
+                    throw new moodle_exception('unexpected_archive_structure', 'core_plugin');
+                }
                 $found = $item;
-                break;
             }
         }
 
@@ -520,4 +528,34 @@ class code_manager {
             }
         }
     }
+
+    /**
+     * Moves the extracted contents of the plugin ZIP into the target location.
+     *
+     * @param string $sourcedir full path to the directory the ZIP file was extracted to
+     * @param mixed $targetdir full path to the directory where the files should be moved to
+     * @param array $files list of extracted files
+     */
+    protected function move_extracted_plugin_files($sourcedir, $targetdir, array $files) {
+        global $CFG;
+
+        foreach ($files as $file => $status) {
+            if ($status !== true) {
+                throw new moodle_exception('corrupted_archive_structure', 'core_plugin', '', $file, $status);
+            }
+
+            $source = $sourcedir.'/'.$file;
+            $target = $targetdir.'/'.$file;
+
+            if (is_dir($source)) {
+                continue;
+
+            } else {
+                if (!is_dir(dirname($target))) {
+                    mkdir(dirname($target), $CFG->directorypermissions, true);
+                }
+                rename($source, $target);
+            }
+        }
+    }
 }
index e18732b..e317c5f 100644 (file)
@@ -199,6 +199,7 @@ class core_ddl_testcase extends database_driver_testcase {
      * Test behaviour of create_table()
      */
     public function test_create_table() {
+
         $DB = $this->tdb; // Do not use global $DB!
         $dbman = $this->tdb->get_manager();
 
@@ -289,8 +290,9 @@ class core_ddl_testcase extends database_driver_testcase {
             $this->assertInstanceOf('ddl_exception', $e);
         }
 
-        // Long table name names - the largest allowed.
-        $table = new xmldb_table('test_table0123456789_____xyz');
+        // Long table name names - the largest allowed by the configuration which exclude the prefix to ensure it's created.
+        $tablechars = str_repeat('a', xmldb_table::NAME_MAX_LENGTH);
+        $table = new xmldb_table($tablechars);
         $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
         $table->add_field('course', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '2');
         $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
@@ -302,8 +304,9 @@ class core_ddl_testcase extends database_driver_testcase {
         $this->assertTrue($dbman->table_exists($table));
         $dbman->drop_table($table);
 
-        // Table name is too long.
-        $table = new xmldb_table('test_table0123456789_____xyz9');
+        // Table name is too long, ignoring any prefix size set.
+        $tablechars = str_repeat('a', xmldb_table::NAME_MAX_LENGTH + 1);
+        $table = new xmldb_table($tablechars);
         $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
         $table->add_field('course', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '2');
         $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
index efc5043..381e889 100644 (file)
@@ -39,7 +39,6 @@ class pgsql_native_moodle_database extends moodle_database {
 
     /** @var resource $pgsql database resource */
     protected $pgsql     = null;
-    protected $bytea_oid = null;
 
     protected $last_error_reporting; // To handle pgsql driver default verbosity
 
@@ -154,6 +153,15 @@ class pgsql_native_moodle_database extends moodle_database {
             $connection = "host='$this->dbhost' $port user='$this->dbuser' password='$pass' dbname='$this->dbname'";
         }
 
+        // ALTER USER and ALTER DATABASE are overridden by these settings.
+        $options = array('--client_encoding=utf8', '--standard_conforming_strings=on');
+        // Select schema if specified, otherwise the first one wins.
+        if (!empty($this->dboptions['dbschema'])) {
+            $options[] = "-c search_path=" . addcslashes($this->dboptions['dbschema'], "'\\");
+        }
+
+        $connection .= " options='".implode(' ', $options)."'";
+
         ob_start();
         if (empty($this->dboptions['dbpersist'])) {
             $this->pgsql = pg_connect($connection, PGSQL_CONNECT_FORCE_NEW);
@@ -170,34 +178,6 @@ class pgsql_native_moodle_database extends moodle_database {
             throw new dml_connection_exception($dberr);
         }
 
-        $this->query_start("--pg_set_client_encoding()", null, SQL_QUERY_AUX);
-        pg_set_client_encoding($this->pgsql, 'utf8');
-        $this->query_end(true);
-
-        $sql = '';
-        // Only for 9.0 and upwards, set bytea encoding to old format.
-        if ($this->is_min_version('9.0')) {
-            $sql = "SET bytea_output = 'escape'; ";
-        }
-
-        // Select schema if specified, otherwise the first one wins.
-        if (!empty($this->dboptions['dbschema'])) {
-            $sql .= "SET search_path = '".$this->dboptions['dbschema']."'; ";
-        }
-
-        // Find out the bytea oid.
-        $sql .= "SELECT oid FROM pg_type WHERE typname = 'bytea'";
-        $this->query_start($sql, null, SQL_QUERY_AUX);
-        $result = pg_query($this->pgsql, $sql);
-        $this->query_end($result);
-
-        $this->bytea_oid = pg_fetch_result($result, 0, 0);
-        pg_free_result($result);
-        if ($this->bytea_oid === false) {
-            $this->pgsql = null;
-            throw new dml_connection_exception('Can not read bytea type.');
-        }
-
         // Connection stabilised and configured, going to instantiate the temptables controller
         $this->temptables = new pgsql_native_moodle_temptables($this);
 
@@ -273,18 +253,6 @@ class pgsql_native_moodle_database extends moodle_database {
         return array('description'=>$info['server'], 'version'=>$info['server']);
     }
 
-    /**
-     * Returns if the RDBMS server fulfills the required version
-     *
-     * @param string $version version to check against
-     * @return bool returns if the version is fulfilled (true) or no (false)
-     */
-    private function is_min_version($version) {
-        $server = $this->get_server_info();
-        $server = $server['version'];
-        return version_compare($server, $version, '>=');
-    }
-
     /**
      * Returns supported query parameter types
      * @return int bitmask of accepted SQL_PARAMS_*
@@ -623,9 +591,11 @@ class pgsql_native_moodle_database extends moodle_database {
         if (is_bool($value)) { // Always, convert boolean to int
             $value = (int)$value;
 
-        } else if ($column->meta_type === 'B') { // BLOB detected, we return 'blob' array instead of raw value to allow
-            if (!is_null($value)) {             // binding/executing code later to know about its nature
-                $value = array('blob' => $value);
+        } else if ($column->meta_type === 'B') {
+            if (!is_null($value)) {
+                // standard_conforming_strings must be enabled, otherwise pg_escape_bytea() will double escape
+                // \ and produce data errors.  This is set on the connection.
+                $value = pg_escape_bytea($this->pgsql, $value);
             }
 
         } else if ($value === '') {
@@ -756,7 +726,7 @@ class pgsql_native_moodle_database extends moodle_database {
     }
 
     protected function create_recordset($result) {
-        return new pgsql_native_moodle_recordset($result, $this->bytea_oid);
+        return new pgsql_native_moodle_recordset($result);
     }
 
     /**
@@ -794,11 +764,11 @@ class pgsql_native_moodle_database extends moodle_database {
         $this->query_end($result);
 
         // find out if there are any blobs
-        $numrows = pg_num_fields($result);
+        $numfields = pg_num_fields($result);
         $blobs = array();
-        for($i=0; $i<$numrows; $i++) {
-            $type_oid = pg_field_type_oid($result, $i);
-            if ($type_oid == $this->bytea_oid) {
+        for ($i = 0; $i < $numfields; $i++) {
+            $type = pg_field_type($result, $i);
+            if ($type == 'bytea') {
                 $blobs[] = pg_field_name($result, $i);
             }
         }
@@ -812,8 +782,7 @@ class pgsql_native_moodle_database extends moodle_database {
                 $id = reset($row);
                 if ($blobs) {
                     foreach ($blobs as $blob) {
-                        // note: in PostgreSQL 9.0 the returned blobs are hexencoded by default - see http://www.postgresql.org/docs/9.0/static/runtime-config-client.html#GUC-BYTEA-OUTPUT
-                        $row[$blob] = $row[$blob] !== null ? pg_unescape_bytea($row[$blob]) : null;
+                        $row[$blob] = ($row[$blob] !== null ? pg_unescape_bytea($row[$blob]) : null);
                     }
                 }
                 if (isset($return[$id])) {
@@ -843,6 +812,13 @@ class pgsql_native_moodle_database extends moodle_database {
         $this->query_end($result);
 
         $return = pg_fetch_all_columns($result, 0);
+
+        if (pg_field_type($result, 0) == 'bytea') {
+            foreach ($return as $key => $value) {
+                $return[$key] = ($value === null ? $value : pg_unescape_bytea($value));
+            }
+        }
+
         pg_free_result($result);
 
         return $return;
@@ -931,7 +907,6 @@ class pgsql_native_moodle_database extends moodle_database {
         }
 
         $cleaned = array();
-        $blobs   = array();
 
         foreach ($dataobject as $field=>$value) {
             if ($field === 'id') {
@@ -941,33 +916,10 @@ class pgsql_native_moodle_database extends moodle_database {
                 continue;
             }
             $column = $columns[$field];
-            $normalised_value = $this->normalise_value($column, $value);
-            if (is_array($normalised_value) && array_key_exists('blob', $normalised_value)) {
-                $cleaned[$field] = '@#BLOB#@';
-                $blobs[$field] = $normalised_value['blob'];
-            } else {
-                $cleaned[$field] = $normalised_value;
-            }
-        }
-
-        if (empty($blobs)) {
-            return $this->insert_record_raw($table, $cleaned, $returnid, $bulk);
+            $cleaned[$field] = $this->normalise_value($column, $value);
         }
 
-        $id = $this->insert_record_raw($table, $cleaned, true, $bulk);
-
-        foreach ($blobs as $key=>$value) {
-            $value = pg_escape_bytea($this->pgsql, $value);
-            $sql = "UPDATE {$this->prefix}$table SET $key = '$value'::bytea WHERE id = $id";
-            $this->query_start($sql, NULL, SQL_QUERY_UPDATE);
-            $result = pg_query($this->pgsql, $sql);
-            $this->query_end($result);
-            if ($result !== false) {
-                pg_free_result($result);
-            }
-        }
-
-        return ($returnid ? $id : true);
+        return $this->insert_record_raw($table, $cleaned, $returnid, $bulk);
 
     }
 
@@ -1002,14 +954,6 @@ class pgsql_native_moodle_database extends moodle_database {
 
         $columns = $this->get_columns($table, true);
 
-        // Make sure there are no nasty blobs!
-        foreach ($columns as $column) {
-            if ($column->binary) {
-                parent::insert_records($table, $dataobjects);
-                return;
-            }
-        }
-
         $fields = null;
         $count = 0;
         $chunk = array();
@@ -1042,7 +986,7 @@ class pgsql_native_moodle_database extends moodle_database {
     }
 
     /**
-     * Insert records in chunks, no binary support, strict param types...
+     * Insert records in chunks, strict param types...
      *
      * Note: can be used only from insert_records().
      *
@@ -1087,39 +1031,17 @@ class pgsql_native_moodle_database extends moodle_database {
 
         $columns = $this->get_columns($table);
         $cleaned = array();
-        $blobs   = array();
 
         foreach ($dataobject as $field=>$value) {
             $this->detect_objects($value);
             if (!isset($columns[$field])) {
                 continue;
             }
-            if ($columns[$field]->meta_type === 'B') {
-                if (!is_null($value)) {
-                    $cleaned[$field] = '@#BLOB#@';
-                    $blobs[$field] = $value;
-                    continue;
-                }
-            }
-
-            $cleaned[$field] = $value;
-        }
-
-        $this->insert_record_raw($table, $cleaned, false, true, true);
-        $id = $dataobject['id'];
-
-        foreach ($blobs as $key=>$value) {
-            $value = pg_escape_bytea($this->pgsql, $value);
-            $sql = "UPDATE {$this->prefix}$table SET $key = '$value'::bytea WHERE id = $id";
-            $this->query_start($sql, NULL, SQL_QUERY_UPDATE);
-            $result = pg_query($this->pgsql, $sql);
-            $this->query_end($result);
-            if ($result !== false) {
-                pg_free_result($result);
-            }
+            $column = $columns[$field];
+            $cleaned[$field] = $this->normalise_value($column, $value);
         }
 
-        return true;
+        return $this->insert_record_raw($table, $cleaned, false, true, true);
     }
 
     /**
@@ -1182,40 +1104,17 @@ class pgsql_native_moodle_database extends moodle_database {
 
         $columns = $this->get_columns($table);
         $cleaned = array();
-        $blobs   = array();
 
         foreach ($dataobject as $field=>$value) {
             if (!isset($columns[$field])) {
                 continue;
             }
             $column = $columns[$field];
-            $normalised_value = $this->normalise_value($column, $value);
-            if (is_array($normalised_value) && array_key_exists('blob', $normalised_value)) {
-                $cleaned[$field] = '@#BLOB#@';
-                $blobs[$field] = $normalised_value['blob'];
-            } else {
-                $cleaned[$field] = $normalised_value;
-            }
+            $cleaned[$field] = $this->normalise_value($column, $value);
         }
 
         $this->update_record_raw($table, $cleaned, $bulk);
 
-        if (empty($blobs)) {
-            return true;
-        }
-
-        $id = (int)$dataobject['id'];
-
-        foreach ($blobs as $key=>$value) {
-            $value = pg_escape_bytea($this->pgsql, $value);
-            $sql = "UPDATE {$this->prefix}$table SET $key = '$value'::bytea WHERE id = $id";
-            $this->query_start($sql, NULL, SQL_QUERY_UPDATE);
-            $result = pg_query($this->pgsql, $sql);
-            $this->query_end($result);
-
-            pg_free_result($result);
-        }
-
         return true;
     }
 
@@ -1245,24 +1144,10 @@ class pgsql_native_moodle_database extends moodle_database {
         $columns = $this->get_columns($table);
         $column = $columns[$newfield];
 
-        $normalised_value = $this->normalise_value($column, $newvalue);
-        if (is_array($normalised_value) && array_key_exists('blob', $normalised_value)) {
-            // Update BYTEA and return
-            $normalised_value = pg_escape_bytea($this->pgsql, $normalised_value['blob']);
-            $sql = "UPDATE {$this->prefix}$table SET $newfield = '$normalised_value'::bytea $select";
-            $this->query_start($sql, NULL, SQL_QUERY_UPDATE);
-            $result = pg_query_params($this->pgsql, $sql, $params);
-            $this->query_end($result);
-            pg_free_result($result);
-            return true;
-        }
+        $normalisedvalue = $this->normalise_value($column, $newvalue);
 
-        if (is_null($normalised_value)) {
-            $newfield = "$newfield = NULL";
-        } else {
-            $newfield = "$newfield = \$".$i;
-            $params[] = $normalised_value;
-        }
+        $newfield = "$newfield = \$" . $i;
+        $params[] = $normalisedvalue;
         $sql = "UPDATE {$this->prefix}$table SET $newfield $select";
 
         $this->query_start($sql, $params, SQL_QUERY_UPDATE);
@@ -1275,7 +1160,7 @@ class pgsql_native_moodle_database extends moodle_database {
     }
 
     /**
-     * Delete one or more records from a table which match a particular WHERE clause.
+     * Delete one or more records from a table which match a particular WHERE clause, lobs not supported.
      *
      * @param string $table The database table to be checked against.
      * @param string $select A fragment of SQL to be used in a where clause in the SQL call (used to define the selection criteria).
@@ -1315,11 +1200,6 @@ class pgsql_native_moodle_database extends moodle_database {
         if (strpos($param, '%') !== false) {
             debugging('Potential SQL injection detected, sql_like() expects bound parameters (? or :named)');
         }
-        if ($escapechar === '\\') {
-            // Prevents problems with C-style escapes of enclosing '\',
-            // E'... bellow prevents compatibility warnings.
-            $escapechar = '\\\\';
-        }
 
         // postgresql does not support accent insensitive text comparisons, sorry
         if ($casesensitive) {
@@ -1327,7 +1207,7 @@ class pgsql_native_moodle_database extends moodle_database {
         } else {
             $LIKE = $notlike ? 'NOT ILIKE' : 'ILIKE';
         }
-        return "$fieldname $LIKE $param ESCAPE E'$escapechar'";
+        return "$fieldname $LIKE $param ESCAPE '$escapechar'";
     }
 
     public function sql_bitxor($int1, $int2) {
index fd1f905..dc99f5b 100644 (file)
@@ -38,18 +38,21 @@ class pgsql_native_moodle_recordset extends moodle_recordset {
     protected $result;
     /** @var current row as array.*/
     protected $current;
-    protected $bytea_oid;
     protected $blobs = array();
 
-    public function __construct($result, $bytea_oid) {
-        $this->result    = $result;
-        $this->bytea_oid = $bytea_oid;
-
-        // find out if there are any blobs
-        $numrows = pg_num_fields($result);
-        for($i=0; $i<$numrows; $i++) {
-            $type_oid = pg_field_type_oid($result, $i);
-            if ($type_oid == $this->bytea_oid) {
+    /**
+     * Build a new recordset to iterate over.
+     *
+     * @param resource $result A pg_query() result object to create a recordset from.
+     */
+    public function __construct($result) {
+        $this->result = $result;
+
+        // Find out if there are any blobs.
+        $numfields = pg_num_fields($result);
+        for ($i = 0; $i < $numfields; $i++) {
+            $type = pg_field_type($result, $i);
+            if ($type == 'bytea') {
                 $this->blobs[] = pg_field_name($result, $i);
             }
         }
index 7df8222..7851e2e 100644 (file)
@@ -1919,13 +1919,16 @@ class core_dml_testcase extends database_driver_testcase {
 
         $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
         $table->add_field('course', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
+        $table->add_field('onebinary', XMLDB_TYPE_BINARY, 'big', null, null, null);
         $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
         $dbman->create_table($table);
 
-        $DB->insert_record($tablename, array('course' => 1));
-        $DB->insert_record($tablename, array('course' => 3));
-        $DB->insert_record($tablename, array('course' => 2));
-        $DB->insert_record($tablename, array('course' => 6));
+        $binarydata = '\\'.chr(241);
+
+        $DB->insert_record($tablename, array('course' => 1, 'onebinary' => $binarydata));
+        $DB->insert_record($tablename, array('course' => 3, 'onebinary' => $binarydata));
+        $DB->insert_record($tablename, array('course' => 2, 'onebinary' => $binarydata));
+        $DB->insert_record($tablename, array('course' => 6, 'onebinary' => $binarydata));
 
         $fieldset = $DB->get_fieldset_sql("SELECT * FROM {{$tablename}} WHERE course > ?", array(1));
         $this->assertInternalType('array', $fieldset);
@@ -1934,6 +1937,14 @@ class core_dml_testcase extends database_driver_testcase {
         $this->assertEquals(2, $fieldset[0]);
         $this->assertEquals(3, $fieldset[1]);
         $this->assertEquals(4, $fieldset[2]);
+
+        $fieldset = $DB->get_fieldset_sql("SELECT onebinary FROM {{$tablename}} WHERE course > ?", array(1));
+        $this->assertInternalType('array', $fieldset);
+
+        $this->assertCount(3, $fieldset);
+        $this->assertEquals($binarydata, $fieldset[0]);
+        $this->assertEquals($binarydata, $fieldset[1]);
+        $this->assertEquals($binarydata, $fieldset[2]);
     }
 
     public function test_insert_record_raw() {
@@ -3016,6 +3027,10 @@ class core_dml_testcase extends database_driver_testcase {
         $this->assertEquals($clob, $DB->get_field($tablename, 'onetext', array('id' => 1)), 'Test CLOB set_field (full contents output disabled)');
         $this->assertEquals($blob, $DB->get_field($tablename, 'onebinary', array('id' => 1)), 'Test BLOB set_field (full contents output disabled)');
 
+        // Empty data in binary columns works.
+        $DB->set_field_select($tablename, 'onebinary', '', 'id = ?', array(1));
+        $this->assertEquals('', $DB->get_field($tablename, 'onebinary', array('id' => 1)), 'Blobs need to accept empty values.');
+
         // And "small" LOBs too, just in case.
         $newclob = substr($clob, 0, 500);
         $newblob = substr($blob, 0, 250);
index befa4e6..a92d6f7 100644 (file)
@@ -1128,4 +1128,70 @@ class external_util {
         return array($courses, $warnings);
     }
 
+    /**
+     * Returns all area files (optionally limited by itemid).
+     *
+     * @param int $contextid context ID
+     * @param string $component component
+     * @param string $filearea file area
+     * @param int $itemid item ID or all files if not specified
+     * @param bool $useitemidinurl wether to use the item id in the file URL (modules intro don't use it)
+     * @return array of files, compatible with the external_files structure.
+     * @since Moodle 3.2
+     */
+    public static function get_area_files($contextid, $component, $filearea, $itemid = false, $useitemidinurl = true) {
+        $files = array();
+        $fs = get_file_storage();
+
+        if ($areafiles = $fs->get_area_files($contextid, $component, $filearea, $itemid, 'itemid, filepath, filename', false)) {
+            foreach ($areafiles as $areafile) {
+                $file = array();
+                $file['filename'] = $areafile->get_filename();
+                $file['filepath'] = $areafile->get_filepath();
+                $file['mimetype'] = $areafile->get_mimetype();
+                $file['filesize'] = $areafile->get_filesize();
+                $file['timemodified'] = $areafile->get_timemodified();
+                $fileitemid = $useitemidinurl ? $areafile->get_itemid() : null;
+                $file['fileurl'] = moodle_url::make_webservice_pluginfile_url($contextid, $component, $filearea,
+                                    $fileitemid, $areafile->get_filepath(), $areafile->get_filename())->out(false);
+                $files[] = $file;
+            }
+        }
+        return $files;
+    }
+}
+
+/**
+ * External structure representing a set of files.
+ *
+ * @package    core_webservice
+ * @copyright  2016 Juan Leyva
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      Moodle 3.2
+ */
+class external_files extends external_multiple_structure {
+
+    /**
+     * Constructor
+     * @param string $desc Description for the multiple structure.
+     * @param int $required The type of value (VALUE_REQUIRED OR VALUE_OPTIONAL).
+     */
+    public function __construct($desc = 'List of files.', $required = VALUE_REQUIRED) {
+
+        parent::__construct(
+            new external_single_structure(
+                array(
+                    'filename' => new external_value(PARAM_FILE, 'File name.', VALUE_OPTIONAL),
+                    'filepath' => new external_value(PARAM_PATH, 'File path.', VALUE_OPTIONAL),
+                    'filesize' => new external_value(PARAM_INT, 'File size.', VALUE_OPTIONAL),
+                    'fileurl' => new external_value(PARAM_URL, 'Downloadable file url.', VALUE_OPTIONAL),
+                    'timemodified' => new external_value(PARAM_INT, 'Time modified.', VALUE_OPTIONAL),
+                    'mimetype' => new external_value(PARAM_RAW, 'File mime type.', VALUE_OPTIONAL),
+                ),
+                'File.'
+            ),
+            $desc,
+            $required
+        );
+    }
 }
index b3955ae..a25b992 100644 (file)
@@ -91,11 +91,15 @@ class moodle_phpmailer extends PHPMailer {
     public function encodeHeader($str, $position = 'text') {
         $encoded = core_text::encode_mimeheader($str, $this->CharSet);
         if ($encoded !== false) {
-            $encoded = str_replace("\n", $this->LE, $encoded);
             if ($position === 'phrase') {
-                return ("\"$encoded\"");
+                // Escape special symbols in each line in the encoded string, join back together and enclose in quotes.
+                $chunks = preg_split("/\\n/", $encoded);
+                $chunks = array_map(function($chunk) {
+                    return addcslashes($chunk, "\0..\37\177\\\"");
+                }, $chunks);
+                return '"' . join($this->LE, $chunks) . '"';
             }
-            return $encoded;
+            return str_replace("\n", $this->LE, $encoded);
         }
 
         return parent::encodeHeader($str, $position);
index b9f51c1..2972b6b 100644 (file)
@@ -337,10 +337,10 @@ if (file_exists("$CFG->dataroot/climaintenance.html")) {
 
 if (CLI_SCRIPT) {
     // sometimes people use different PHP binary for web and CLI, make 100% sure they have the supported PHP version
-    if (version_compare(phpversion(), '5.4.4') < 0) {
+    if (version_compare(phpversion(), '5.6.5') < 0) {
         $phpversion = phpversion();
         // do NOT localise - lang strings would not work here and we CAN NOT move it to later place
-        echo "Moodle 2.7 or later requires at least PHP 5.4.4 (currently using version $phpversion).\n";
+        echo "Moodle 3.2 or later requires at least PHP 5.6.5 (currently using version $phpversion).\n";
         echo "Some servers may have multiple PHP versions installed, are you using the correct executable?\n";
         exit(1);
     }
index 39796cc..cda9bd3 100644 (file)
@@ -30,6 +30,19 @@ require_once($CFG->libdir . '/externallib.php');
 
 
 class core_externallib_testcase extends advanced_testcase {
+    protected $DB;
+
+    public function setUp() {
+        $this->DB = null;
+    }
+
+    public function tearDown() {
+        global $DB;
+        if ($this->DB !== null) {
+            $DB = $this->DB;
+        }
+    }
+
     public function test_validate_params() {
         $params = array('text'=>'aaa', 'someid'=>'6');
         $description = new external_function_parameters(array('someid' => new external_value(PARAM_INT, 'Some int value'),
@@ -459,6 +472,73 @@ class core_externallib_testcase extends advanced_testcase {
         $this->assertSame($beforecourse, $COURSE);
     }
 
+    /**
+     * Text external_util::get_area_files
+     */
+    public function test_external_util_get_area_files() {
+        global $CFG, $DB;
+
+        $this->DB = $DB;
+        $DB = $this->getMockBuilder('moodle_database')->getMock();
+
+        $content = base64_encode("Let us create a nice simple file.");
+        $timemodified = 102030405;
+        $itemid = 42;
+        $filesize = strlen($content);
+
+        $DB->method('get_records_sql')->willReturn([
+            (object) [
+                'filename'      => 'example.txt',
+                'filepath'      => '/',
+                'mimetype'      => 'text/plain',
+                'filesize'      => $filesize,
+                'timemodified'  => $timemodified,
+                'itemid'        => $itemid,
+                'pathnamehash'  => sha1('/example.txt'),
+            ],
+        ]);
+
+        $component = 'mod_foo';
+        $filearea = 'area';
+        $context = 12345;
+
+        $expectedfiles[] = array(
+            'filename' => 'example.txt',
+            'filepath' => '/',
+            'fileurl' => "{$CFG->wwwroot}/webservice/pluginfile.php/{$context}/{$component}/{$filearea}/{$itemid}/example.txt",
+            'timemodified' => $timemodified,
+            'filesize' => $filesize,
+            'mimetype' => 'text/plain',
+        );
+        // Get all the files for the area.
+        $files = external_util::get_area_files($context, $component, $filearea, false);
+        $this->assertEquals($expectedfiles, $files);
+
+        // Get just the file indicated by $itemid.
+        $files = external_util::get_area_files($context, $component, $filearea, $itemid);
+        $this->assertEquals($expectedfiles, $files);
+
+    }
+
+    /**
+     * Text external files structure.
+     */
+    public function test_external_files() {
+
+        $description = new external_files();
+
+        // First check that the expected default values and keys are returned.
+        $expectedkeys = array_flip(array('filename', 'filepath', 'filesize', 'fileurl', 'timemodified', 'mimetype'));
+        $returnedkeys = array_flip(array_keys($description->content->keys));
+        $this->assertEquals($expectedkeys, $returnedkeys);
+        $this->assertEquals('List of files.', $description->desc);
+        $this->assertEquals(VALUE_REQUIRED, $description->required);
+        foreach ($description->content->keys as $key) {
+            $this->assertEquals(VALUE_OPTIONAL, $key->required);
+        }
+
+    }
+
 }
 
 /*
diff --git a/lib/tests/fixtures/update_validator/zips/multidir.zip b/lib/tests/fixtures/update_validator/zips/multidir.zip
new file mode 100644 (file)
index 0000000..a350015
Binary files /dev/null and b/lib/tests/fixtures/update_validator/zips/multidir.zip differ
index 53e9e57..e0f867e 100644 (file)
@@ -345,10 +345,41 @@ class core_text_testcase extends advanced_testcase {
 
     /**
      * Tests the static encode_mimeheader method.
+     * This also tests method moodle_phpmailer::encodeHeader that calls core_text::encode_mimeheader
      */
     public function test_encode_mimeheader() {
+        global $CFG;
+        require_once($CFG->libdir.'/phpmailer/moodle_phpmailer.php');
+        $mailer = new moodle_phpmailer();
+
+        // Encode short string with non-latin characters.
         $str = "Žluťoučký koníček";
-        $this->assertSame('=?utf-8?B?xb1sdcWlb3XEjWvDvSBrb27DrcSNZWs=?=', core_text::encode_mimeheader($str));
+        $encodedstr = '=?utf-8?B?xb1sdcWlb3XEjWvDvSBrb27DrcSNZWs=?=';
+        $this->assertSame($encodedstr, core_text::encode_mimeheader($str));
+        $this->assertSame($encodedstr, $mailer->encodeHeader($str));
+        $this->assertSame('"' . $encodedstr . '"', $mailer->encodeHeader($str, 'phrase'));
+
+        // Encode short string without non-latin characters. Make sure the quotes are escaped in quoted email headers.
+        $latinstr = 'text"with quotes';
+        $this->assertSame($latinstr, core_text::encode_mimeheader($latinstr));
+        $this->assertSame($latinstr, $mailer->encodeHeader($latinstr));
+        $this->assertSame('"text\\"with quotes"', $mailer->encodeHeader($latinstr, 'phrase'));
+
+        // Encode long string without non-latin characters.
+        $longlatinstr = 'This is a very long text that still should not be split into several lines in the email headers because '.
+            'it does not have any non-latin characters. The "quotes" and \\backslashes should be escaped only if it\'s a part of email address';
+        $this->assertSame($longlatinstr, core_text::encode_mimeheader($longlatinstr));
+        $this->assertSame($longlatinstr, $mailer->encodeHeader($longlatinstr));
+        $longlatinstrwithslash = preg_replace(['/\\\\/', "/\"/"], ['\\\\\\', '\\"'], $longlatinstr);
+        $this->assertSame('"' . $longlatinstrwithslash . '"', $mailer->encodeHeader($longlatinstr, 'phrase'));
+
+        // Encode long string with non-latin characters.
+        $longstr = "Неопознанная ошибка в файле C:\\tmp\\: \"Не пользуйтесь виндоуз\"";
+        $encodedlongstr = "=?utf-8?B?0J3QtdC+0L/QvtC30L3QsNC90L3QsNGPINC+0YjQuNCx0LrQsCDQsiDRhNCw?=
+ =?utf-8?B?0LnQu9C1IEM6XHRtcFw6ICLQndC1INC/0L7Qu9GM0LfRg9C50YLQtdGB?=
+ =?utf-8?B?0Ywg0LLQuNC90LTQvtGD0Lci?=";
+        $this->assertSame($encodedlongstr, $mailer->encodeHeader($longstr));
+        $this->assertSame('"' . $encodedlongstr . '"', $mailer->encodeHeader($longstr, 'phrase'));
     }
 
     /**
index 6166152..cbf4717 100644 (file)
@@ -73,6 +73,7 @@ class core_update_code_manager_testcase extends advanced_testcase {
         $codeman = new \core\update\testable_code_manager();
         $zipfilepath = __DIR__.'/fixtures/update_validator/zips/invalidroot.zip';
         $targetdir = make_request_directory();
+        mkdir($targetdir.'/aaa_another');
 
         $files = $codeman->unzip_plugin_file($zipfilepath, $targetdir);
 
@@ -110,6 +111,15 @@ class core_update_code_manager_testcase extends advanced_testcase {
         $files = $codeman->unzip_plugin_file($zipfilepath, $targetdir, 'bar');
     }
 
+    public function test_unzip_plugin_file_multidir() {
+        $codeman = new \core\update\testable_code_manager();
+        $zipfilepath = __DIR__.'/fixtures/update_validator/zips/multidir.zip';
+        $targetdir = make_request_directory();
+        // Attempting to rename the root folder if there are multiple ones should lead to exception.
+        $this->setExpectedException('moodle_exception');
+        $files = $codeman->unzip_plugin_file($zipfilepath, $targetdir, 'foo');
+    }
+
     public function test_get_plugin_zip_root_dir() {
         $codeman = new \core\update\testable_code_manager();
 
@@ -118,6 +128,9 @@ class core_update_code_manager_testcase extends advanced_testcase {
 
         $zipfilepath = __DIR__.'/fixtures/update_validator/zips/bar.zip';
         $this->assertEquals('bar', $codeman->get_plugin_zip_root_dir($zipfilepath));
+
+        $zipfilepath = __DIR__.'/fixtures/update_validator/zips/multidir.zip';
+        $this->assertSame(false, $codeman->get_plugin_zip_root_dir($zipfilepath));
     }
 
     public function test_list_plugin_folder_files() {
index e005e4b..b380fa5 100644 (file)
@@ -714,7 +714,7 @@ class xmldb_table extends xmldb_object {
         // table parameter is ignored
         $name = $this->getName();
         if (strlen($name) > self::NAME_MAX_LENGTH) {
-            return 'Invalid table name {'.$name.'}: name is too long. Limit is 28 chars.';
+            return 'Invalid table name {'.$name.'}: name is too long. Limit is '.self::NAME_MAX_LENGTH.' chars.';
         }
         if (!preg_match('/^[a-z][a-z0-9_]*$/', $name)) {
             return 'Invalid table name {'.$name.'}: name includes invalid characters.';
index c4f3e9a..709ed96 100644 (file)
@@ -43,7 +43,7 @@ class message_output_airnotifier extends message_output {
      * @return true if ok, false if error
      */
     public function send_message($eventdata) {
-        global $CFG;
+        global $CFG, $DB;
         require_once($CFG->libdir . '/filelib.php');
 
         if (!empty($CFG->noemailever)) {
@@ -59,6 +59,11 @@ class message_output_airnotifier extends message_output {
             return true;
         }
 
+        // If username is empty we try to retrieve it, since it's required to generate the siteid.
+        if (empty($eventdata->userto->username)) {
+            $eventdata->userto->username = $DB->get_field('user', 'username', array('id' => $eventdata->userto->id));
+        }
+
         // Site id, to map with Moodle Mobile stored sites.
         $siteid = md5($CFG->wwwroot . $eventdata->userto->username);
 
index 79217a8..068e80d 100644 (file)
@@ -198,7 +198,8 @@ $functions = array(
                 'description'   => 'List the participants for a single assignment, with some summary info about their submissions.',
                 'type'          => 'read',
                 'ajax'          => true,
-                'capabilities'  => 'mod/assign:view, mod/assign:viewgrades'
+                'capabilities'  => 'mod/assign:view, mod/assign:viewgrades',
+                'services'      => array(MOODLE_OFFICIAL_MOBILE_SERVICE)
         ),
 
         'mod_assign_submit_grading_form' => array(
index f2eb881..5c71156 100644 (file)
@@ -451,6 +451,8 @@ class mod_assign_external extends external_api {
 
                         list($assignment['intro'], $assignment['introformat']) = external_format_text($module->intro,
                             $module->introformat, $context->id, 'mod_assign', 'intro', null);
+                        $assignment['introfiles'] = external_util::get_area_files($context->id, 'mod_assign', 'intro', false,
+                                                                                    false);
 
                         $fs = get_file_storage();
                         if ($files = $fs->get_area_files($context->id, 'mod_assign', ASSIGN_INTROATTACHMENT_FILEAREA,
@@ -528,6 +530,7 @@ class mod_assign_external extends external_api {
                 'intro' => new external_value(PARAM_RAW,
                     'assignment intro, not allways returned because it deppends on the activity configuration', VALUE_OPTIONAL),
                 'introformat' => new external_format_value('intro', VALUE_OPTIONAL),
+                'introfiles' => new external_files('Files in the introduction text', VALUE_OPTIONAL),
                 'introattachments' => new external_multiple_structure(
                     new external_single_structure(
                         array (
index 419e7c7..e8c75a7 100644 (file)
@@ -140,6 +140,8 @@ $string['duedatecolon'] = 'Due date: {$a}';
 $string['duedate_help'] = 'This is when the assignment is due. Submissions will still be allowed after this date but any assignments submitted after this date are marked as late. To prevent submissions after a certain date - set the assignment cut off date.';
 $string['duedateno'] = 'No due date';
 $string['submissionempty'] = 'Nothing was submitted';
+$string['submissionmodified'] = 'You have existing submission data. Please leave this page and try again.';
+$string['submissionmodifiedgroup'] = 'The submission has been modified by somebody else. Please leave this page and try again.';
 $string['duedatereached'] = 'The due date for this assignment has now passed';
 $string['duedatevalidation'] = 'Due date must be after the allow submissions from date.';
 $string['editattemptfeedback'] = 'Edit the grade and feedback for attempt number {$a}.';
index 5160225..4da2bdc 100644 (file)
@@ -6371,6 +6371,22 @@ class assign {
         return $allempty;
     }
 
+    /**
+     * Determine if a new submission is empty or not
+     *
+     * @param stdClass $data Submission data
+     * @return bool
+     */
+    public function new_submission_empty($data) {
+        foreach ($this->submissionplugins as $plugin) {
+            if ($plugin->is_enabled() && $plugin->is_visible() && $plugin->allow_submissions() &&
+                    !$plugin->submission_is_empty($data)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
     /**
      * Save assignment submission for the current user.
      *
@@ -6403,6 +6419,17 @@ class assign {
         } else {
             $submission = $this->get_user_submission($userid, true);
         }
+
+        // Check that no one has modified the submission since we started looking at it.
+        if (isset($data->lastmodified) && ($submission->timemodified > $data->lastmodified)) {
+            // Another user has submitted something. Notify the current user.
+            if ($submission->status !== ASSIGN_SUBMISSION_STATUS_NEW) {
+                $notices[] = $instance->teamsubmission ? get_string('submissionmodifiedgroup', 'mod_assign')
+                                                       : get_string('submissionmodified', 'mod_assign');
+                return false;
+            }
+        }
+
         if ($instance->submissiondrafts) {
             $submission->status = ASSIGN_SUBMISSION_STATUS_DRAFT;
         } else {
index e812536..c55f48a 100644 (file)
@@ -479,6 +479,20 @@ class assign_submission_file extends assign_submission_plugin {
         return $this->count_files($submission->id, ASSIGNSUBMISSION_FILE_FILEAREA) == 0;
     }
 
+    /**
+     * Determine if a submission is empty
+     *
+     * This is distinct from is_empty in that it is intended to be used to
+     * determine if a submission made before saving is empty.
+     *
+     * @param stdClass $data The submission data
+     * @return bool
+     */
+    public function submission_is_empty(stdClass $data) {
+        $files = file_get_drafarea_files($data->files_filemanager);
+        return count($files->list) == 0;
+    }
+
     /**
      * Get file areas returns a list of areas this plugin stores files
      * @return array - An array of fileareas (keys) and descriptions (values)
diff --git a/mod/assign/submission/file/tests/locallib_test.php b/mod/assign/submission/file/tests/locallib_test.php
new file mode 100644 (file)
index 0000000..8df613a
--- /dev/null
@@ -0,0 +1,141 @@
+<?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/>.
+
+/**
+ * Tests for mod/assign/submission/file/locallib.php
+ *
+ * @package   assignsubmission_file
+ * @copyright 2016 Cameron Ball
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/mod/assign/tests/base_test.php');
+
+/**
+ * Unit tests for mod/assign/submission/file/locallib.php
+ *
+ * @copyright  2016 Cameron Ball
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class assignsubmission_file_locallib_testcase extends advanced_testcase {
+
+    /** @var stdClass $user A user to submit an assignment. */
+    protected $user;
+
+    /** @var stdClass $course New course created to hold the assignment activity. */
+    protected $course;
+
+    /** @var stdClass $cm A context module object. */
+    protected $cm;
+
+    /** @var stdClass $context Context of the assignment activity. */
+    protected $context;
+
+    /** @var stdClass $assign The assignment object. */
+    protected $assign;
+
+    /**
+     * Setup all the various parts of an assignment activity including creating an onlinetext submission.
+     */
+    protected function setUp() {
+        $this->user = $this->getDataGenerator()->create_user();
+        $this->course = $this->getDataGenerator()->create_course();
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_assign');
+        $params = [
+            'course' => $this->course->id,
+            'assignsubmission_file_enabled' => 1,
+            'assignsubmission_file_maxfiles' => 12,
+            'assignsubmission_file_maxsizebytes' => 10,
+        ];
+        $instance = $generator->create_instance($params);
+        $this->cm = get_coursemodule_from_instance('assign', $instance->id);
+        $this->context = context_module::instance($this->cm->id);
+        $this->assign = new testable_assign($this->context, $this->cm, $this->course);
+        $this->setUser($this->user->id);
+    }
+
+    /**
+     * Test submission_is_empty
+     *
+     * @dataProvider submission_is_empty_testcases
+     * @param string $data The file submission data
+     * @param bool $expected The expected return value
+     */
+    public function test_submission_is_empty($data, $expected) {
+        $this->resetAfterTest();
+
+        $itemid = file_get_unused_draft_itemid();
+        $submission = (object)['files_filemanager' => $itemid];
+        $plugin = $this->assign->get_submission_plugin_by_type('file');
+
+        if ($data) {
+            $data += ['contextid' => context_user::instance($this->user->id)->id, 'itemid' => $itemid];
+            $fs = get_file_storage();
+            $fs->create_file_from_string((object)$data, 'Content of ' . $data['filename']);
+        }
+
+        $result = $plugin->submission_is_empty($submission);
+        $this->assertTrue($result === $expected);
+    }
+
+    /**
+     * Test new_submission_empty
+     *
+     * @dataProvider submission_is_empty_testcases
+     * @param string $data The file submission data
+     * @param bool $expected The expected return value
+     */
+    public function test_new_submission_empty($data, $expected) {
+        $this->resetAfterTest();
+
+        $itemid = file_get_unused_draft_itemid();
+        $submission = (object)['files_filemanager' => $itemid];
+
+        if ($data) {
+            $data += ['contextid' => context_user::instance($this->user->id)->id, 'itemid' => $itemid];
+            $fs = get_file_storage();
+            $fs->create_file_from_string((object)$data, 'Content of ' . $data['filename']);
+        }
+
+        $result = $this->assign->new_submission_empty($submission);
+        $this->assertTrue($result === $expected);
+    }
+
+    /**
+     * Dataprovider for the test_submission_is_empty testcase
+     *
+     * @return array of testcases
+     */
+    public function submission_is_empty_testcases() {
+        return [
+            'With file' => [
+                [
+                    'component' => 'user',
+                    'filearea' => 'draft',
+                    'filepath' => '/',
+                    'filename' => 'not_a_virus.exe'
+                ],
+                false
+            ],
+            'Without file' => [null, true]
+        ];
+    }
+
+
+}
index 0638490..aa852ee 100644 (file)
@@ -572,6 +572,22 @@ class assign_submission_onlinetext extends assign_submission_plugin {
         return empty($onlinetextsubmission->onlinetext);
     }
 
+    /**
+     * Determine if a submission is empty
+     *
+     * This is distinct from is_empty in that it is intended to be used to
+     * determine if a submission made before saving is empty.
+     *
+     * @param stdClass $data The submission data
+     * @return bool
+     */
+    public function submission_is_empty(stdClass $data) {
+        if (!isset($data->onlinetext_editor)) {
+            return true;
+        }
+        return !strlen((string)$data->onlinetext_editor['text']);
+    }
+
     /**
      * Get file areas returns a list of areas this plugin stores files
      * @return array - An array of fileareas (keys) and descriptions (values)
@@ -615,7 +631,7 @@ class assign_submission_onlinetext extends assign_submission_plugin {
      * @return external_description|null
      */
     public function get_external_parameters() {
-        $editorparams = array('text' => new external_value(PARAM_TEXT, 'The text for this submission.'),
+        $editorparams = array('text' => new external_value(PARAM_RAW, 'The text for this submission.'),
                               'format' => new external_value(PARAM_INT, 'The format for this submission'),
                               'itemid' => new external_value(PARAM_INT, 'The draft area id for files attached to the submission'));
         $editorstructure = new external_single_structure($editorparams, 'Editor structure', VALUE_OPTIONAL);
diff --git a/mod/assign/submission/onlinetext/tests/locallib_test.php b/mod/assign/submission/onlinetext/tests/locallib_test.php
new file mode 100644 (file)
index 0000000..c7595c4
--- /dev/null
@@ -0,0 +1,116 @@
+<?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/>.
+
+/**
+ * Tests for mod/assign/submission/onlinetext/locallib.php
+ *
+ * @package   assignsubmission_onlinetext
+ * @copyright 2016 Cameron Ball
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/mod/assign/tests/base_test.php');
+
+/**
+ * Unit tests for mod/assign/submission/onlinetext/locallib.php
+ *
+ * @copyright  2016 Cameron Ball
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class assignsubmission_onlinetext_locallib_testcase extends advanced_testcase {
+
+    /** @var stdClass $user A user to submit an assignment. */
+    protected $user;
+
+    /** @var stdClass $course New course created to hold the assignment activity. */
+    protected $course;
+
+    /** @var stdClass $cm A context module object. */
+    protected $cm;
+
+    /** @var stdClass $context Context of the assignment activity. */
+    protected $context;
+
+    /** @var stdClass $assign The assignment object. */
+    protected $assign;
+
+    /**
+     * Setup all the various parts of an assignment activity including creating an onlinetext submission.
+     */
+    protected function setUp() {
+        $this->user = $this->getDataGenerator()->create_user();
+        $this->course = $this->getDataGenerator()->create_course();
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_assign');
+        $params = ['course' => $this->course->id, 'assignsubmission_onlinetext_enabled' => 1];
+        $instance = $generator->create_instance($params);
+        $this->cm = get_coursemodule_from_instance('assign', $instance->id);
+        $this->context = context_module::instance($this->cm->id);
+        $this->assign = new testable_assign($this->context, $this->cm, $this->course);
+        $this->setUser($this->user->id);
+    }
+
+    /**
+     * Test submission_is_empty
+     *
+     * @dataProvider submission_is_empty_testcases
+     * @param string $submissiontext The online text submission text
+     * @param bool $expected The expected return value
+     */
+    public function test_submission_is_empty($submissiontext, $expected) {
+        $this->resetAfterTest();
+
+        $plugin = $this->assign->get_submission_plugin_by_type('onlinetext');
+        $data = new stdClass();
+        $data->onlinetext_editor = ['text' => $submissiontext];
+
+        $result = $plugin->submission_is_empty($data);
+        $this->assertTrue($result === $expected);
+    }
+
+    /**
+     * Test new_submission_empty
+     *
+     * @dataProvider submission_is_empty_testcases
+     * @param string $submissiontext The file submission data
+     * @param bool $expected The expected return value
+     */
+    public function test_new_submission_empty($submissiontext, $expected) {
+        $this->resetAfterTest();
+        $data = new stdClass();
+        $data->onlinetext_editor = ['text' => $submissiontext];
+
+        $result = $this->assign->new_submission_empty($data);
+        $this->assertTrue($result === $expected);
+    }
+
+    /**
+     * Dataprovider for the test_submission_is_empty testcase
+     *
+     * @return array of testcases
+     */
+    public function submission_is_empty_testcases() {
+        return [
+            'Empty submission string' => ['', true],
+            'Empty submission null' => [null, true],
+            'Value 0' => [0, false],
+            'String 0' => ['0', false],
+            'Text' => ['Ai! laurië lantar lassi súrinen, yéni únótimë ve rámar aldaron!', false]
+        ];
+    }
+}
index fc46669..99f4416 100644 (file)
@@ -41,12 +41,22 @@ class mod_assign_submission_form extends moodleform {
      * Define this form - called by the parent constructor
      */
     public function definition() {
+        global $USER;
         $mform = $this->_form;
-
         list($assign, $data) = $this->_customdata;
 
-        $assign->add_submission_form_elements($mform, $data);
+        $instance = $assign->get_instance();
+        if ($instance->teamsubmission) {
+            $submission = $assign->get_group_submission($USER->id, 0, true);
+        } else {
+            $submission = $assign->get_user_submission($USER->id, true);
+        }
+        if ($submission) {
+            $mform->addElement('hidden', 'lastmodified', $submission->timemodified);
+            $mform->setType('lastmodified', PARAM_INT);
+        }
 
+        $assign->add_submission_form_elements($mform, $data);
         $this->add_action_buttons(true, get_string('savechanges', 'assign'));
         if ($data) {
             $this->set_data($data);
index 7e9ac77..29f9220 100644 (file)
@@ -125,4 +125,16 @@ abstract class assign_submission_plugin extends assign_plugin {
     public function add_attempt(stdClass $oldsubmission, stdClass $newsubmission) {
     }
 
+    /**
+     * Determine if a submission is empty
+     *
+     * This is distinct from is_empty in that it is intended to be used to
+     * determine if a submission made before saving is empty.
+     *
+     * @param stdClass $data The submission data
+     * @return bool
+     */
+    public function submission_is_empty(stdClass $data) {
+        return false;
+    }
 }
index e035af6..f06ac43 100644 (file)
@@ -970,7 +970,7 @@ class mod_assign_external_testcase extends externallib_advanced_testcase {
         // Now try a submission.
         $submissionpluginparams = array();
         $submissionpluginparams['files_filemanager'] = $draftidfile;
-        $onlinetexteditorparams = array('text'=>'Yeeha!',
+        $onlinetexteditorparams = array('text' => '<p>Yeeha!</p>',
                                         'format'=>1,
                                         'itemid'=>$draftidonlinetext);
         $submissionpluginparams['onlinetext_editor'] = $onlinetexteditorparams;
index 65670a5..1e0095a 100644 (file)
@@ -687,6 +687,63 @@ class mod_assign_locallib_testcase extends mod_assign_base_testcase {
         $this->assertContains(get_string('submitassignment', 'assign'), $output, 'Can submit non empty onlinetext assignment');
     }
 
+    /**
+     * Test new_submission_empty
+     *
+     * We only test combinations of plugins here. Individual plugins are tested
+     * in their respective test files.
+     *
+     * @dataProvider test_new_submission_empty_testcases
+     * @param string $data The file submission data
+     * @param bool $expected The expected return value
+     */
+    public function test_new_submission_empty($data, $expected) {
+        $this->resetAfterTest();
+        $assign = $this->create_instance(['assignsubmission_file_enabled' => 1,
+                                          'assignsubmission_file_maxfiles' => 12,
+                                          'assignsubmission_file_maxsizebytes' => 10,
+                                          'assignsubmission_onlinetext_enabled' => 1]);
+        $this->setUser($this->students[0]);
+        $submission = new stdClass();
+
+        if ($data['file'] && isset($data['file']['filename'])) {
+            $itemid = file_get_unused_draft_itemid();
+            $submission->files_filemanager = $itemid;
+            $data['file'] += ['contextid' => context_user::instance($this->students[0]->id)->id, 'itemid' => $itemid];
+            $fs = get_file_storage();
+            $fs->create_file_from_string((object)$data['file'], 'Content of ' . $data['file']['filename']);
+        }
+
+        if ($data['onlinetext']) {
+            $submission->onlinetext_editor = ['text' => $data['onlinetext']];
+        }
+
+        $result = $assign->new_submission_empty($submission);
+        $this->assertTrue($result === $expected);
+    }
+
+    /**
+     * Dataprovider for the test_new_submission_empty testcase
+     *
+     * @return array of testcases
+     */
+    public function test_new_submission_empty_testcases() {
+        return [
+            'With file and onlinetext' => [
+                [
+                    'file' => [
+                        'component' => 'user',
+                        'filearea' => 'draft',
+                        'filepath' => '/',
+                        'filename' => 'not_a_virus.exe'
+                    ],
+                    'onlinetext' => 'Balin Fundinul Uzbadkhazaddumu'
+                ],
+                false
+            ]
+        ];
+    }
+
     public function test_list_participants() {
         global $CFG, $DB;
 
index 9188027..8cb88a7 100644 (file)
@@ -3,6 +3,9 @@ This files describes API changes in the assign code.
 === 3.2 ===
 * External function mod_assign_external::get_assignments now returns additional optional fields:
    - preventsubmissionnotingroup: Prevent submission not in group.
+* Proper checking for empty submissions
+* Submission modification time checking - this will help students working in groups not clobber each others'
+  submissions
 
 === 3.1 ===
 * The feedback plugins now need to implement the is_feedback_modified() method. The default is to return true
index 455abac..23e28b2 100644 (file)
@@ -25,6 +25,6 @@
 defined('MOODLE_INTERNAL') || die();
 
 $plugin->component = 'mod_assign'; // Full name of the plugin (used for diagnostics).
-$plugin->version  = 2016052300;    // The current module version (Date: YYYYMMDDXX).
+$plugin->version  = 2016070400;    // The current module version (Date: YYYYMMDDXX).
 $plugin->requires = 2016051900;    // Requires this Moodle version.
 $plugin->cron     = 60;
index 34d1493..3fde48d 100644 (file)
@@ -207,6 +207,7 @@ class mod_book_external extends external_api {
                 // Format intro.
                 list($bookdetails['intro'], $bookdetails['introformat']) =
                     external_format_text($book->intro, $book->introformat, $context->id, 'mod_book', 'intro', null);
+                $bookdetails['introfiles'] = external_util::get_area_files($context->id, 'mod_book', 'intro', false, false);
                 $bookdetails['numbering']         = $book->numbering;
                 $bookdetails['navstyle']          = $book->navstyle;
                 $bookdetails['customtitles']      = $book->customtitles;
@@ -247,6 +248,7 @@ class mod_book_external extends external_api {
                             'name' => new external_value(PARAM_RAW, 'Book name'),
                             'intro' => new external_value(PARAM_RAW, 'The Book intro'),
                             'introformat' => new external_format_value('intro'),
+                            'introfiles' => new external_files('Files in the introduction text', VALUE_OPTIONAL),
                             'numbering' => new external_value(PARAM_INT, 'Book numbering configuration'),
                             'navstyle' => new external_value(PARAM_INT, 'Book navigation style configuration'),
                             'customtitles' => new external_value(PARAM_INT, 'Book custom titles type'),
index 74dfec8..e363ec7 100644 (file)
@@ -164,8 +164,8 @@ class mod_book_external_testcase extends externallib_advanced_testcase {
         $books = external_api::clean_returnvalue(mod_book_external::get_books_by_courses_returns(), $books);
         $this->assertCount(1, $books['books']);
         $this->assertEquals('First Book', $books['books'][0]['name']);
-        // We see 9 fields.
-        $this->assertCount(9, $books['books'][0]);
+        // We see 10 fields.
+        $this->assertCount(10, $books['books'][0]);
 
         // As Student you cannot see some book properties like 'section'.
         $this->assertFalse(isset($books['books'][0]['section']));
@@ -186,8 +186,8 @@ class mod_book_external_testcase extends externallib_advanced_testcase {
 
         $this->assertCount(1, $books['books']);
         $this->assertEquals('Second Book', $books['books'][0]['name']);
-        // We see 16 fields.
-        $this->assertCount(16, $books['books'][0]);
+        // We see 17 fields.
+        $this->assertCount(17, $books['books'][0]);
         // As an Admin you can see some book properties like 'section'.
         $this->assertEquals(0, $books['books'][0]['section']);
 
index 8405efb..cd08786 100644 (file)
@@ -549,6 +549,7 @@ class mod_chat_external extends external_api {
                 // Format intro.
                 list($chatdetails['intro'], $chatdetails['introformat']) =
                     external_format_text($chat->intro, $chat->introformat, $chatcontext->id, 'mod_chat', 'intro', null);
+                $chatdetails['introfiles'] = external_util::get_area_files($chatcontext->id, 'mod_chat', 'intro', false, false);
 
                 if (has_capability('mod/chat:chat', $chatcontext)) {
                     $chatdetails['chatmethod']    = $CFG->chat_method;
@@ -592,6 +593,7 @@ class mod_chat_external extends external_api {
                             'name' => new external_value(PARAM_RAW, 'Chat name'),
                             'intro' => new external_value(PARAM_RAW, 'The Chat intro'),
                             'introformat' => new external_format_value('intro'),
+                            'introfiles' => new external_files('Files in the introduction text', VALUE_OPTIONAL),
                             'chatmethod' => new external_value(PARAM_ALPHA, 'chat method (sockets, daemon)', VALUE_OPTIONAL),
                             'keepdays' => new external_value(PARAM_INT, 'keep days', VALUE_OPTIONAL),
                             'studentlogs' => new external_value(PARAM_INT, 'student logs visible to everyone', VALUE_OPTIONAL),
index 9211a31..a7e38f3 100644 (file)
@@ -109,14 +109,6 @@ ob_start();
         <meta http-equiv="content-type" content="text/html; charset=utf-8" />
         <script type="text/javascript">
         //<![CDATA[
-        function safari_refresh() {
-            self.location.href= '<?php echo $refreshurl;?>';
-        }
-        var issafari = false;
-        if (window.devicePixelRatio) {
-            issafari = true;
-            setTimeout('safari_refresh()', <?php echo $CFG->chat_refresh_room * 1000;?>);
-        }
         if (parent.msg && parent.msg.document.getElementById("msgStarted") == null) {
             parent.msg.document.close();
             parent.msg.document.open("text/html","replace");
index d22c4d4..c30de52 100644 (file)
@@ -99,14 +99,6 @@ $refreshurl = "{$CFG->wwwroot}/mod/chat/gui_header_js/jsupdated.php?".
         <meta http-equiv="content-type" content="text/html; charset=utf-8" />
         <script type="text/javascript">
         //<![CDATA[
-        function safari_refresh() {
-            self.location.href= '<?php echo $refreshurl;?>';
-        }
-        var issafari = false;
-        if(window.devicePixelRatio){
-            issafari = true;
-            setTimeout('safari_refresh()', <?php echo $CFG->chat_refresh_room * 1000;?>);
-        }
         if (parent.msg.document.getElementById("msgStarted") == null) {
             parent.msg.document.close();
             parent.msg.document.open("text/html","replace");
index cbd335f..e9698d2 100644 (file)
@@ -250,8 +250,8 @@ class mod_chat_external_testcase extends externallib_advanced_testcase {
         $chats = external_api::clean_returnvalue(mod_chat_external::get_chats_by_courses_returns(), $chats);
         $this->assertCount(1, $chats['chats']);
         $this->assertEquals('First Chat', $chats['chats'][0]['name']);
-        // We see 11 fields.
-        $this->assertCount(11, $chats['chats'][0]);
+        // We see 12 fields.
+        $this->assertCount(12, $chats['chats'][0]);
 
         // As Student you cannot see some chat properties like 'section'.
         $this->assertFalse(isset($chats['chats'][0]['section']));
@@ -272,8 +272,8 @@ class mod_chat_external_testcase extends externallib_advanced_testcase {
 
         $this->assertCount(1, $chats['chats']);
         $this->assertEquals('Second Chat', $chats['chats'][0]['name']);
-        // We see 16 fields.
-        $this->assertCount(16, $chats['chats'][0]);
+        // We see 17 fields.
+        $this->assertCount(17, $chats['chats'][0]);
         // As an Admin you can see some chat properties like 'section'.
         $this->assertEquals(0, $chats['chats'][0]['section']);
 
index 7eb5a16..6732b94 100644 (file)
@@ -507,6 +507,8 @@ class mod_choice_external extends external_api {
                 list($choicedetails['intro'], $choicedetails['introformat']) =
                     external_format_text($choice->intro, $choice->introformat,
                                             $context->id, 'mod_choice', 'intro', null);
+                    $choicedetails['introfiles'] = external_util::get_area_files($context->id, 'mod_choice', 'intro', false,
+                                                                                    false);
 
                 if (has_capability('mod/choice:choose', $context)) {
                     $choicedetails['publish']  = $choice->publish;
@@ -557,6 +559,7 @@ class mod_choice_external extends external_api {
                             'name' => new external_value(PARAM_RAW, 'Choice name'),
                             'intro' => new external_value(PARAM_RAW, 'The choice intro'),
                             'introformat' => new external_format_value('intro'),
+                            'introfiles' => new external_files('Files in the introduction text', VALUE_OPTIONAL),
                             'publish' => new external_value(PARAM_BOOL, 'If choice is published', VALUE_OPTIONAL),
                             'showresults' => new external_value(PARAM_INT, '0 never, 1 after answer, 2 after close, 3 always',
                                                                 VALUE_OPTIONAL),
index 12aedca..7cc9872 100644 (file)
@@ -285,7 +285,7 @@ class mod_choice_renderer extends plugin_renderer_base {
      * @param object $choices
      * @return string
      */
-    public function display_publish_anonymous_vertical($choices) {
+    public function display_publish_anonymous_horizontal($choices) {
         global $CHOICE_COLUMN_HEIGHT;
 
         $html = '';
@@ -388,7 +388,7 @@ class mod_choice_renderer extends plugin_renderer_base {
      * @param object $choices
      * @return string
      */
-    public function display_publish_anonymous_horizontal($choices) {
+    public function display_publish_anonymous_vertical($choices) {
         global $CHOICE_COLUMN_WIDTH;
 
         $table = new html_table();
index 3ac5e82..8a53280 100644 (file)
@@ -104,6 +104,7 @@ class mod_data_external extends external_api {
                 list($newdb['intro'], $newdb['introformat']) =
                     external_format_text($database->intro, $database->introformat,
                                             $datacontext->id, 'mod_data', 'intro', null);
+                $newdb['introfiles'] = external_util::get_area_files($datacontext->id, 'mod_data', 'intro', false, false);
 
                 // This information should be only available if the user can see the database entries.
                 if (has_capability('mod/data:viewentry', $datacontext)) {
@@ -167,6 +168,7 @@ class mod_data_external extends external_api {
                             'name' => new external_value(PARAM_RAW, 'Database name'),
                             'intro' => new external_value(PARAM_RAW, 'The Database intro'),
                             'introformat' => new external_format_value('intro'),
+                            'introfiles' => new external_files('Files in the introduction text', VALUE_OPTIONAL),
                             'comments' => new external_value(PARAM_BOOL, 'comments enabled', VALUE_OPTIONAL),
                             'timeavailablefrom' => new external_value(PARAM_INT, 'timeavailablefrom field', VALUE_OPTIONAL),
                             'timeavailableto' => new external_value(PARAM_INT, 'timeavailableto field', VALUE_OPTIONAL),
index 831f0c9..b569105 100644 (file)
@@ -94,11 +94,13 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
         // First for the student user.
         $expectedfields = array('id', 'coursemodule', 'course', 'name', 'comments', 'timeavailablefrom',
                             'timeavailableto', 'timeviewfrom', 'timeviewto', 'requiredentries', 'requiredentriestoview',
-                            'intro', 'introformat');
+                            'intro', 'introformat', 'introfiles');
 
         // Add expected coursemodule.
         $database1->coursemodule = $database1->cmid;
+        $database1->introfiles = [];
         $database2->coursemodule = $database2->cmid;
+        $database2->introfiles = [];
 
         $expected1 = array();
         $expected2 = array();
index 1b393ec..8df9913 100644 (file)
@@ -92,6 +92,7 @@ class mod_forum_external extends external_api {
                 // Format the intro before being returning using the format setting.
                 list($forum->intro, $forum->introformat) = external_format_text($forum->intro, $forum->introformat,
                                                                                 $context->id, 'mod_forum', 'intro', 0);
+                $forum->introfiles = external_util::get_area_files($context->id, 'mod_forum', 'intro', false, false);
                 // Discussions count. This function does static request cache.
                 $forum->numdiscussions = forum_count_discussions($forum, $cm, $course);
                 $forum->cmid = $forum->coursemodule;
@@ -121,6 +122,7 @@ class mod_forum_external extends external_api {
                     'name' => new external_value(PARAM_RAW, 'Forum name'),
                     'intro' => new external_value(PARAM_RAW, 'The forum intro'),
                     'introformat' => new external_format_value('intro'),
+                    'introfiles' => new external_files('Files in the introduction text', VALUE_OPTIONAL),
                     'assessed' => new external_value(PARAM_INT, 'Aggregate type'),
                     'assesstimestart' => new external_value(PARAM_INT, 'Assess start time'),
                     'assesstimefinish' => new external_value(PARAM_INT, 'Assess finish time'),
index 455abca..f9f08ca 100644 (file)
@@ -79,6 +79,7 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
         $record->introformat = FORMAT_HTML;
         $record->course = $course2->id;
         $forum2 = self::getDataGenerator()->create_module('forum', $record);
+        $forum2->introfiles = [];
 
         // Add discussions to the forums.
         $record = new stdClass();
@@ -89,6 +90,7 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
         // Expect one discussion.
         $forum1->numdiscussions = 1;
         $forum1->cancreatediscussions = true;
+        $forum1->introfiles = [];
 
         $record = new stdClass();
         $record->course = $course2->id;
index ee61c3f..a8b7fc6 100644 (file)
@@ -232,6 +232,7 @@ class mod_glossary_external extends external_api {
                 $glossary->name = external_format_string($glossary->name, $context->id);
                 list($glossary->intro, $glossary->introformat) = external_format_text($glossary->intro, $glossary->introformat,
                     $context->id, 'mod_glossary', 'intro', null);
+                $glossary->introfiles = external_util::get_area_files($context->id, 'mod_glossary', 'intro', false, false);
 
                 // Make sure we have a number of entries per page.
                 if (!$glossary->entbypage) {
@@ -268,6 +269,7 @@ class mod_glossary_external extends external_api {
                     'name' => new external_value(PARAM_RAW, 'Glossary name'),
                     'intro' => new external_value(PARAM_RAW, 'The Glossary intro'),
                     'introformat' => new external_format_value('intro'),
+                    'introfiles' => new external_files('Files in the introduction text', VALUE_OPTIONAL),
                     'allowduplicatedentries' => new external_value(PARAM_INT, 'If enabled, multiple entries can have the' .
                         ' same concept name'),
                     'displayformat' => new external_value(PARAM_TEXT, 'Display format type'),
index 4ae2fdd..a5f0b37 100644 (file)
@@ -165,6 +165,7 @@ class mod_imscp_external extends external_api {
                     // Format intro.
                     list($imscpdetails['intro'], $imscpdetails['introformat']) =
                         external_format_text($imscp->intro, $imscp->introformat, $context->id, 'mod_imscp', 'intro', null);
+                    $imscpdetails['introfiles'] = external_util::get_area_files($context->id, 'mod_imscp', 'intro', false, false);
                 }
 
                 if (has_capability('moodle/course:manageactivities', $context)) {
@@ -204,6 +205,7 @@ class mod_imscp_external extends external_api {
                             'name' => new external_value(PARAM_RAW, 'Activity name'),
                             'intro' => new external_value(PARAM_RAW, 'The IMSCP intro', VALUE_OPTIONAL),
                             'introformat' => new external_format_value('intro', VALUE_OPTIONAL),
+                            'introfiles' => new external_files('Files in the introduction text', VALUE_OPTIONAL),
                             'revision' => new external_value(PARAM_INT, 'Revision', VALUE_OPTIONAL),
                             'keepold' => new external_value(PARAM_INT, 'Number of old IMSCP to keep', VALUE_OPTIONAL),
                             'structure' => new external_value(PARAM_RAW, 'IMSCP structure', VALUE_OPTIONAL),
index e6d5181..460340d 100644 (file)
@@ -51,8 +51,8 @@ class mod_lti_external extends external_api {
         return new external_single_structure(
             array(
                 'id' => new external_value(PARAM_INT, 'Tool type id'),
-                'name' => new external_value(PARAM_TEXT, 'Tool type name'),
-                'description' => new external_value(PARAM_TEXT, 'Tool type description'),
+                'name' => new external_value(PARAM_NOTAGS, 'Tool type name'),
+                'description' => new external_value(PARAM_NOTAGS, 'Tool type description'),
                 'urls' => new external_single_structure(
                     array(
                         'icon' => new external_value(PARAM_URL, 'Tool type icon URL'),
@@ -309,6 +309,7 @@ class mod_lti_external extends external_api {
                     list($module['intro'], $module['introformat']) =
                         external_format_text($lti->intro, $lti->introformat, $context->id, 'mod_lti', 'intro', $lti->id);
 
+                    $module['introfiles'] = external_util::get_area_files($context->id, 'mod_lti', 'intro', false, false);
                     $viewablefields = array('launchcontainer', 'showtitlelaunch', 'showdescriptionlaunch', 'icon', 'secureicon');
                 }
 
@@ -356,6 +357,7 @@ class mod_lti_external extends external_api {
                             'name' => new external_value(PARAM_RAW, 'LTI name'),
                             'intro' => new external_value(PARAM_RAW, 'The LTI intro', VALUE_OPTIONAL),
                             'introformat' => new external_format_value('intro', VALUE_OPTIONAL),
+                            'introfiles' => new external_files('Files in the introduction text', VALUE_OPTIONAL),
                             'timecreated' => new external_value(PARAM_INT, 'Time of creation', VALUE_OPTIONAL),
                             'timemodified' => new external_value(PARAM_INT, 'Time of last modification', VALUE_OPTIONAL),
                             'typeid' => new external_value(PARAM_INT, 'Type id', VALUE_OPTIONAL),
@@ -812,8 +814,8 @@ class mod_lti_external extends external_api {
         return new external_function_parameters(
             array(
                 'id' => new external_value(PARAM_INT, 'Tool type id'),
-                'name' => new external_value(PARAM_TEXT, 'Tool type name', VALUE_DEFAULT, null),
-                'description' => new external_value(PARAM_TEXT, 'Tool type description', VALUE_DEFAULT, null),
+                'name' => new external_value(PARAM_RAW, 'Tool type name', VALUE_DEFAULT, null),
+                'description' => new external_value(PARAM_RAW, 'Tool type description', VALUE_DEFAULT, null),
                 'state' => new external_value(PARAM_INT, 'Tool type state', VALUE_DEFAULT, null)
             )
         );
index caeb3b6..092ac4c 100644 (file)
@@ -1149,10 +1149,12 @@ function lti_get_configured_types($courseid, $sectionreturn = 0) {
         $type           = new stdClass();
         $type->modclass = MOD_CLASS_ACTIVITY;
         $type->name     = 'lti_type_' . $ltitype->id;
-        $type->title    = $ltitype->name;
+        // Clean the name. We don't want tags here.
+        $type->title    = clean_param($ltitype->name, PARAM_NOTAGS);
         $trimmeddescription = trim($ltitype->description);
         if ($trimmeddescription != '') {
-            $type->help     = $trimmeddescription;
+            // Clean the description. We don't want tags here.
+            $type->help     = clean_param($trimmeddescription, PARAM_NOTAGS);
             $type->helplink = get_string('modulename_shortcut_link', 'lti');
         }
         if (empty($ltitype->icon)) {
@@ -2491,11 +2493,18 @@ function get_tool_type_instance_ids($type) {
 function serialise_tool_type(stdClass $type) {
     $capabilitygroups = get_tool_type_capability_groups($type);
     $instanceids = get_tool_type_instance_ids($type);
-
+    // Clean the name. We don't want tags here.
+    $name = clean_param($type->name, PARAM_NOTAGS);
+    if (!empty($type->description)) {
+        // Clean the description. We don't want tags here.
+        $description = clean_param($type->description, PARAM_NOTAGS);
+    } else {
+        $description = get_string('editdescription', 'mod_lti');
+    }
     return array(
         'id' => $type->id,
-        'name' => $type->name,
-        'description' => isset($type->description) ? $type->description : get_string('editdescription', 'mod_lti'),
+        'name' => $name,
+        'description' => $description,
         'urls' => get_tool_type_urls($type),
         'state' => get_tool_type_state_info($type),
         'hascapabilitygroups' => !empty($capabilitygroups),
index a7f0323..07fc5b4 100644 (file)
@@ -129,7 +129,7 @@ class mod_lti_external_testcase extends externallib_advanced_testcase {
 
         // Create what we expect to be returned when querying the two courses.
         // First for the student user.
-        $expectedfields = array('id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'launchcontainer',
+        $expectedfields = array('id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'introfiles', 'launchcontainer',
                                 'showtitlelaunch', 'showdescriptionlaunch', 'icon', 'secureicon');
 
         // Add expected coursemodule and data.
@@ -140,6 +140,7 @@ class mod_lti_external_testcase extends externallib_advanced_testcase {
         $lti1->visible = true;
         $lti1->groupmode = 0;
         $lti1->groupingid = 0;
+        $lti1->introfiles = [];
 
         $lti2->coursemodule = $lti2->cmid;
         $lti2->introformat = 1;
@@ -147,6 +148,7 @@ class mod_lti_external_testcase extends externallib_advanced_testcase {
         $lti2->visible = true;
         $lti2->groupmode = 0;
         $lti2->groupingid = 0;
+        $lti2->introfiles = [];
 
         foreach ($expectedfields as $field) {
                 $expected1[$field] = $lti1->{$field};
index d38a907..2359578 100644 (file)
@@ -108,6 +108,7 @@ class mod_quiz_external extends external_api {
                     list($quizdetails['intro'], $quizdetails['introformat']) = external_format_text($quiz->intro,
                                                                     $quiz->introformat, $context->id, 'mod_quiz', 'intro', null);
 
+                    $quizdetails['introfiles'] = external_util::get_area_files($context->id, 'mod_quiz', 'intro', false, false);
                     $viewablefields = array('timeopen', 'timeclose', 'grademethod', 'section', 'visible', 'groupmode',
                                             'groupingid');
 
@@ -171,6 +172,7 @@ class mod_quiz_external extends external_api {
                             'name' => new external_value(PARAM_RAW, 'Quiz name.'),
                             'intro' => new external_value(PARAM_RAW, 'Quiz introduction text.', VALUE_OPTIONAL),
                             'introformat' => new external_format_value('intro', VALUE_OPTIONAL),
+                            'introfiles' => new external_files('Files in the introduction text', VALUE_OPTIONAL),
                             'timeopen' => new external_value(PARAM_INT, 'The time when this quiz opens. (0 = no restriction.)',
                                                                 VALUE_OPTIONAL),
                             'timeclose' => new external_value(PARAM_INT, 'The time when this quiz closes. (0 = no restriction.)',
index a62bb95..c106aca 100644 (file)
@@ -189,8 +189,8 @@ class mod_quiz_external_testcase extends externallib_advanced_testcase {
 
         // Create what we expect to be returned when querying the two courses.
         // First for the student user.
-        $allusersfields = array('id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'timeopen', 'timeclose',
-                                'grademethod', 'section', 'visible', 'groupmode', 'groupingid');
+        $allusersfields = array('id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'introfiles', 'timeopen',
+                                'timeclose', 'grademethod', 'section', 'visible', 'groupmode', 'groupingid');
         $userswithaccessfields = array('timelimit', 'attempts', 'attemptonlast', 'grademethod', 'decimalpoints',
                                         'questiondecimalpoints', 'reviewattempt', 'reviewcorrectness', 'reviewmarks',
                                         'reviewspecificfeedback', 'reviewgeneralfeedback', 'reviewrightanswer',
@@ -211,6 +211,7 @@ class mod_quiz_external_testcase extends externallib_advanced_testcase {
         $quiz1->hasquestions = 0;
         $quiz1->hasfeedback = 0;
         $quiz1->autosaveperiod = get_config('quiz', 'autosaveperiod');
+        $quiz1->introfiles = [];
 
         $quiz2->coursemodule = $quiz2->cmid;
         $quiz2->introformat = 1;
@@ -221,6 +222,7 @@ class mod_quiz_external_testcase extends externallib_advanced_testcase {
         $quiz2->hasquestions = 0;
         $quiz2->hasfeedback = 0;
         $quiz2->autosaveperiod = get_config('quiz', 'autosaveperiod');
+        $quiz2->introfiles = [];
 
         foreach (array_merge($allusersfields, $userswithaccessfields) as $field) {
             $expected1[$field] = $quiz1->{$field};
index ab8e166..d41122f 100644 (file)
@@ -693,6 +693,7 @@ class mod_scorm_external extends external_api {
                 $module['name']  = external_format_string($scorm->name, $context->id);
                 list($module['intro'], $module['introformat']) =
                     external_format_text($scorm->intro, $scorm->introformat, $context->id, 'mod_scorm', 'intro', $scorm->id);
+                $module['introfiles'] = external_util::get_area_files($context->id, 'mod_scorm', 'intro', false, false);
 
                 // Check if the SCORM open and return warnings if so.
                 list($open, $openwarnings) = scorm_get_availability_status($scorm, true, $context);
@@ -769,6 +770,7 @@ class mod_scorm_external extends external_api {
                             'name' => new external_value(PARAM_RAW, 'SCORM name'),
                             'intro' => new external_value(PARAM_RAW, 'The SCORM intro'),
                             'introformat' => new external_format_value('intro'),
+                            'introfiles' => new external_files('Files in the introduction text', VALUE_OPTIONAL),
                             'packagesize' => new external_value(PARAM_INT, 'SCORM zip package size', VALUE_OPTIONAL),
                             'packageurl' => new external_value(PARAM_URL, 'SCORM zip package URL', VALUE_OPTIONAL),
                             'version' => new external_value(PARAM_NOTAGS, 'SCORM version (SCORM_12, SCORM_13, SCORM_AICC)',
index c25b8ec..17c44f1 100644 (file)
@@ -139,7 +139,7 @@ function scorm_get_manifest($blocks, $scoes) {
                         $scoes->elements[$manifest][$organization][$identifier]->launch = '';
                         $scoes->elements[$manifest][$organization][$identifier]->scormtype = 'asset';
                     } else {
-                        $idref = $block['attrs']['IDENTIFIERREF'];
+                        $idref = addslashes_js($block['attrs']['IDENTIFIERREF']);
                         $base = '';
                         if (isset($resources[$idref]['XML:BASE'])) {
                             $base = $resources[$idref]['XML:BASE'];
index bfecc07..8b2d014 100644 (file)
@@ -74,6 +74,7 @@ $string['browserepository'] = 'Browse repository';
 $string['calculatedweight'] = 'Calculated weight';
 $string['cannotaccess'] = 'You cannot call this script in that way';
 $string['cannotfindsco'] = 'Could not find SCO';
+$string['closebeforeopen'] = 'You have specified a close date before the open date.';
 $string['collapsetocwinsize'] = 'Collapse TOC when window size below';
 $string['collapsetocwinsizedesc'] = 'This setting lets you specify the window size below which the TOC should automatically collapse.';
 $string['compatibilitysettings'] = 'Compatibility settings';
index 5039646..4b00473 100644 (file)
@@ -442,6 +442,13 @@ class mod_scorm_mod_form extends moodleform_mod {
 
         }
 
+        // Validate availability dates.
+        if ($data['timeopen'] && $data['timeclose']) {
+            if ($data['timeopen'] > $data['timeclose']) {
+                $errors['timeclose'] = get_string('closebeforeopen', 'scorm');
+            }
+        }
+
         return $errors;
     }
 
index 5a71a6a..53aaeea 100644 (file)
@@ -649,8 +649,8 @@ class mod_scorm_external_testcase extends externallib_advanced_testcase {
         $result = mod_scorm_external::get_scorms_by_courses(array($course1->id));
         $result = external_api::clean_returnvalue($returndescription, $result);
         $this->assertCount(1, $result['warnings']);
-        // Only 'id', 'coursemodule', 'course', 'name', 'intro', 'introformat'.
-        $this->assertCount(6, $result['scorms'][0]);
+        // Only 'id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'introfiles'.
+        $this->assertCount(7, $result['scorms'][0]);
         $this->assertEquals('expired', $result['warnings'][0]['warningcode']);
 
         $scorm1->timeopen = $timenow + DAYSECS;
@@ -660,8 +660,8 @@ class mod_scorm_external_testcase extends externallib_advanced_testcase {
         $result = mod_scorm_external::get_scorms_by_courses(array($course1->id));
         $result = external_api::clean_returnvalue($returndescription, $result);
         $this->assertCount(1, $result['warnings']);
-        // Only 'id', 'coursemodule', 'course', 'name', 'intro', 'introformat'.
-        $this->assertCount(6, $result['scorms'][0]);
+        // Only 'id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'introfiles'.
+        $this->assertCount(7, $result['scorms'][0]);
         $this->assertEquals('notopenyet', $result['warnings'][0]['warningcode']);
 
         // Reset times.
@@ -716,6 +716,9 @@ class mod_scorm_external_testcase extends externallib_advanced_testcase {
 
             // Since we return the fields used as boolean as PARAM_BOOL instead PARAM_INT we need to force casting here.
             // From the returned fields definition we obtain the type expected for the field.
+            if (empty($returndescription->keys['scorms']->content->keys[$field]->type)) {
+                continue;
+            }
             $fieldtype = $returndescription->keys['scorms']->content->keys[$field]->type;
             if ($fieldtype == PARAM_BOOL) {
                 $expected1[$field] = (bool) $scorm1->{$field};
@@ -725,6 +728,8 @@ class mod_scorm_external_testcase extends externallib_advanced_testcase {
                 $expected2[$field] = $scorm2->{$field};
             }
         }
+        $expected1['introfiles'] = [];
+        $expected2['introfiles'] = [];
 
         $expectedscorms = array();
         $expectedscorms[] = $expected2;
index ef042d7..5bd3b08 100644 (file)
@@ -39,7 +39,7 @@ class backup_survey_activity_structure_step extends backup_activity_structure_st
         // Define each element separated
         $survey = new backup_nested_element('survey', array('id'), array(
             'name', 'intro', 'introformat', 'template',
-            'questions', 'days', 'timecreated', 'timemodified'));
+            'questions', 'days', 'timecreated', 'timemodified', 'completionsubmit'));
 
         $answers = new backup_nested_element('answers');
 
index 762f4d5..d232b90 100644 (file)
@@ -106,6 +106,8 @@ class mod_survey_external extends external_api {
                     // Format intro.
                     list($surveydetails['intro'], $surveydetails['introformat']) =
                         external_format_text($survey->intro, $survey->introformat, $context->id, 'mod_survey', 'intro', null);
+                    $surveydetails['introfiles'] = external_util::get_area_files($context->id, 'mod_survey', 'intro', false,
+                                                                                    false);
 
                     $surveydetails['template']  = $survey->template;
                     $surveydetails['days']      = $survey->days;
@@ -149,6 +151,7 @@ class mod_survey_external extends external_api {
                             'name' => new external_value(PARAM_RAW, 'Survey name'),
                             'intro' => new external_value(PARAM_RAW, 'The Survey intro', VALUE_OPTIONAL),
                             'introformat' => new external_format_value('intro', VALUE_OPTIONAL),
+                            'introfiles' => new external_files('Files in the introduction text', VALUE_OPTIONAL),
                             'template' => new external_value(PARAM_INT, 'Survey type', VALUE_OPTIONAL),
                             'days' => new external_value(PARAM_INT, 'Days', VALUE_OPTIONAL),
                             'questions' => new external_value(PARAM_RAW, 'Question ids', VALUE_OPTIONAL),
index 07831a5..ebf7e04 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="mod/survey/db" VERSION="20120122" COMMENT="XMLDB file for Moodle mod/survey"
+<XMLDB PATH="mod/survey/db" VERSION="20160615" COMMENT="XMLDB file for Moodle mod/survey"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
 >
@@ -16,6 +16,7 @@
         <FIELD NAME="intro" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="introformat" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="intro text field format"/>
         <FIELD NAME="questions" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="completionsubmit" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="If this field is set to 1, then the activity will be automatically marked as 'complete' once the user submits the survey."/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
index 004fc6d..6347e37 100644 (file)
@@ -22,8 +22,8 @@
 defined('MOODLE_INTERNAL') || die();
 
 function xmldb_survey_upgrade($oldversion) {
-    global $CFG;
-
+    global $DB;
+    $dbman = $DB->get_manager(); // Loads ddl manager and xmldb classes.
     // Moodle v2.8.0 release upgrade line.
     // Put any upgrade step following this.
 
@@ -35,6 +35,20 @@ function xmldb_survey_upgrade($oldversion) {
 
     // Moodle v3.1.0 release upgrade line.
     // Put any upgrade step following this.
+    if ($oldversion < 2016061400) {
+
+        // Define field completionsubmit to be added to survey.
+        $table = new xmldb_table('survey');
+        $field = new xmldb_field('completionsubmit', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '0', 'questions');
+
+        // Conditionally launch add field completionsubmit.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Survey savepoint reached.
+        upgrade_mod_savepoint(true, 2016061400, 'survey');
+    }
 
     return true;
 }
index 849d2a0..10712fe 100644 (file)
@@ -82,6 +82,7 @@ $string['attls9short'] = 'argue with authors';
 $string['cannotfindanswer'] = 'There are no answers for this survey yet.';
 $string['cannotfindquestion'] = 'Question doesn\'t exist';
 $string['cannotfindsurveytmpt'] = 'No survey templates found!';
+$string['completionsubmit'] = 'Student must submit to this activity to complete it';
 $string['ciqintro'] = 'While thinking about recent events in this class, answer the questions below.';
 $string['ciqname'] = 'Critical incidents';
 $string['ciq1'] = 'At what moment in class were you most engaged as a learner?';
index 9d57fb3..a3e7756 100644 (file)
@@ -785,6 +785,7 @@ function survey_supports($feature) {
         case FEATURE_GROUPINGS:               return true;
         case FEATURE_MOD_INTRO:               return true;
         case FEATURE_COMPLETION_TRACKS_VIEWS: return true;
+        case FEATURE_COMPLETION_HAS_RULES:    return true;
         case FEATURE_GRADE_HAS_GRADE:         return false;
         case FEATURE_GRADE_OUTCOMES:          return false;
         case FEATURE_BACKUP_MOODLE2:          return true;
@@ -1008,6 +1009,13 @@ function survey_save_answers($survey, $answersrawdata, $course, $context) {
         $DB->insert_records("survey_answers", $answerstoinsert);
     }
 
+    // Update completion state.
+    $cm = get_coursemodule_from_instance('survey', $survey->id, $course->id);
+    $completion = new completion_info($course);
+    if (isloggedin() && !isguestuser() && $completion->is_enabled($cm) && $survey->completionsubmit) {
+        $completion->update_state($cm, COMPLETION_COMPLETE);
+    }
+
     $params = array(
         'context' => $context,
         'courseid' => $course->id,
@@ -1016,3 +1024,29 @@ function survey_save_answers($survey, $answersrawdata, $course, $context) {
     $event = \mod_survey\event\response_submitted::create($params);
     $event->trigger();
 }
+
+/**
+ * Obtains the automatic completion state for this survey based on the condition
+ * in feedback settings.
+ *
+ * @param object $course Course
+ * @param object $cm Course-module
+ * @param int $userid User ID
+ * @param bool $type Type of comparison (or/and; can be used as return value if no conditions)
+ * @return bool True if completed, false if not, $type if conditions not set.
+ */
+function survey_get_completion_state($course, $cm, $userid, $type) {
+    global $DB;
+
+    // Get survey details.
+    $survey = $DB->get_record('survey', array('id' => $cm->instance), '*', MUST_EXIST);
+
+    // If completion option is enabled, evaluate it and return true/false.
+    if ($survey->completionsubmit) {
+        $params = array('userid' => $userid, 'survey' => $survey->id);
+        return $DB->record_exists('survey_answers', $params);
+    } else {
+        // Completion option is not enabled so just return $type.
+        return $type;
+    }
+}
index 19219a1..d59d3c4 100644 (file)
@@ -46,6 +46,46 @@ class mod_survey_mod_form extends moodleform_mod {
         $this->add_action_buttons();
     }
 
+    /**
+     * Return submitted data if properly submitted or returns NULL if validation fails or
+     * if there is no submitted data.
+     *
+     * @return stdClass submitted data; NULL if not valid or not submitted or cancelled
+     */
+    public function get_data() {
+        $data = parent::get_data();
+        if (!$data) {
+            return false;
+        }
+
+        if (!empty($data->completionunlocked)) {
+            // Turn off completion settings if the checkboxes aren't ticked.
+            $autocompletion = !empty($data->completion) &&
+                $data->completion == COMPLETION_TRACKING_AUTOMATIC;
+            if (!$autocompletion || empty($data->completionsubmit)) {
+                $data->completionsubmit = 0;
+            }
+        }
+        return $data;
+    }
 
+    /**
+     * Add completion rules to form.
+     * @return array
+     */
+    public function add_completion_rules() {
+        $mform =& $this->_form;
+        $mform->addElement('checkbox', 'completionsubmit', '', get_string('completionsubmit', 'survey'));
+        return array('completionsubmit');
+    }
+
+    /**
+     * Enable completion rules
+     * @param stdclass $data
+     * @return array
+     */
+    public function completion_rule_enabled($data) {
+        return !empty($data['completionsubmit']);
+    }
 }
 
diff --git a/mod/survey/tests/behat/survey_completion.feature b/mod/survey/tests/behat/survey_completion.feature
new file mode 100644 (file)
index 0000000..33ebfaf
--- /dev/null
@@ -0,0 +1,46 @@
+@mod @mod_survey
+Feature: A teacher can use activity completion to track a student progress
+  In order to use activity completion
+  As a teacher
+  I need to set survey activities and enable activity completion
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category | enablecompletion |
+      | Course 1 | C1 | 0 | 1 |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+
+  Scenario: Require survey view
+    Given I add a "Survey" to section "1" and I fill the form with:
+      | Name | Test survey name |
+      | Survey type | Critical incidents |
+      | Description | Test survey description |
+      | Completion tracking | Show activity as complete when conditions are met |
+      | id_completionview | 1 |
+    And I turn editing mode off
+    And the "Test survey name" "survey" activity with "auto" completion should be marked as not complete
+    When I follow "Test survey name"
+    And I follow "Course 1"
+    Then the "Test survey name" "survey" activity with "auto" completion should be marked as complete
+
+  Scenario: Require survey submission
+    Given I add a "Survey" to section "1" and I fill the form with:
+      | Name | Test survey name |
+      | Survey type | Critical incidents |
+      | Description | Test survey description |
+      | Completion tracking | Show activity as complete when conditions are met |
+      | id_completionsubmit | 1 |
+    And I turn editing mode off
+    And the "Test survey name" "survey" activity with "auto" completion should be marked as not complete
+    When I follow "Test survey name"
+    And I press "Click here to continue"
+    And I follow "Course 1"
+    Then the "Test survey name" "survey" activity with "auto" completion should be marked as complete
index 5b9976d..30de66b 100644 (file)
@@ -101,8 +101,8 @@ class mod_survey_external_testcase extends externallib_advanced_testcase {
 
         // Create what we expect to be returned when querying the two courses.
         // First for the student user.
-        $expectedfields = array('id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'template', 'days', 'questions',
-                                    'surveydone');
+        $expectedfields = array('id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'introfiles', 'template', 'days',
+                                'questions', 'surveydone');
 
         // Add expected coursemodule and data.
         $survey1 = $this->survey;
@@ -113,6 +113,7 @@ class mod_survey_external_testcase extends externallib_advanced_testcase {
         $survey1->visible = true;
         $survey1->groupmode = 0;
         $survey1->groupingid = 0;
+        $survey1->introfiles = [];
 
         $survey2->coursemodule = $survey2->cmid;
         $survey2->introformat = 1;
@@ -123,6 +124,7 @@ class mod_survey_external_testcase extends externallib_advanced_testcase {
         $survey2->groupingid = 0;
         $tempo = $DB->get_field("survey", "intro", array("id" => $survey2->template));
         $survey2->intro = nl2br(get_string($tempo, "survey"));
+        $survey2->introfiles = [];
 
         foreach ($expectedfields as $field) {
             $expected1[$field] = $survey1->{$field};
index bdd0297..cba05bf 100644 (file)
@@ -24,7 +24,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2016052300;       // The current module version (Date: YYYYMMDDXX)
+$plugin->version   = 2016061400;       // The current module version (Date: YYYYMMDDXX)
 $plugin->requires  = 2016051900;    // Requires this Moodle version
 $plugin->component = 'mod_survey';     // Full name of the plugin (used for diagnostics)
 $plugin->cron      = 0;
index 4d8f00b..c79f544 100644 (file)
@@ -104,6 +104,7 @@ class mod_wiki_external extends external_api {
                 if (has_capability('mod/wiki:viewpage', $context)) {
                     list($module['intro'], $module['introformat']) =
                         external_format_text($wiki->intro, $wiki->introformat, $context->id, 'mod_wiki', 'intro', $wiki->id);
+                    $module['introfiles'] = external_util::get_area_files($context->id, 'mod_wiki', 'intro', false, false);
 
                     $viewablefields = array('firstpagetitle', 'wikimode', 'defaultformat', 'forceformat', 'editbegin', 'editend',
                                             'section', 'visible', 'groupmode', 'groupingid');
@@ -151,6 +152,7 @@ class mod_wiki_external extends external_api {
                             'name' => new external_value(PARAM_RAW, 'Wiki name.'),
                             'intro' => new external_value(PARAM_RAW, 'Wiki intro.', VALUE_OPTIONAL),
                             'introformat' => new external_format_value('Wiki intro format.', VALUE_OPTIONAL),
+                            'introfiles' => new external_files('Files in the introduction text', VALUE_OPTIONAL),
                             'timecreated' => new external_value(PARAM_INT, 'Time of creation.', VALUE_OPTIONAL),
                             'timemodified' => new external_value(PARAM_INT, 'Time of last modification.', VALUE_OPTIONAL),
                             'firstpagetitle' => new external_value(PARAM_RAW, 'First page title.', VALUE_OPTIONAL),
index 3cb4e83..f1ff9e8 100644 (file)
@@ -172,9 +172,9 @@ class mod_wiki_external_testcase extends externallib_advanced_testcase {
 
         // Create what we expect to be returned when querying the two courses.
         // First for the student user.
-        $expectedfields = array('id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'firstpagetitle', 'wikimode',
-                                'defaultformat', 'forceformat', 'editbegin', 'editend', 'section', 'visible', 'groupmode',
-                                'groupingid');
+        $expectedfields = array('id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'introfiles', 'firstpagetitle',
+                                'wikimode', 'defaultformat', 'forceformat', 'editbegin', 'editend', 'section', 'visible',
+                                'groupmode', 'groupingid');
 
         // Add expected coursemodule and data.
         $wiki1 = $this->wiki;
@@ -184,6 +184,7 @@ class mod_wiki_external_testcase extends externallib_advanced_testcase {
         $wiki1->visible = true;
         $wiki1->groupmode = 0;
         $wiki1->groupingid = 0;
+        $wiki1->introfiles = [];
 
         $wiki2->coursemodule = $wiki2->cmid;
         $wiki2->introformat = 1;
@@ -191,6 +192,7 @@ class mod_wiki_external_testcase extends externallib_advanced_testcase {
         $wiki2->visible = true;
         $wiki2->groupmode = 0;
         $wiki2->groupingid = 0;
+        $wiki2->introfiles = [];
 
         foreach ($expectedfields as $field) {
             $expected1[$field] = $wiki1->{$field};
index 91ac7fa..2902db5 100644 (file)
@@ -877,7 +877,7 @@ class mod_workshop_renderer extends plugin_renderer_base {
             $type       = $file->get_mimetype();
             $image      = $this->output->pix_icon(file_file_icon($file), get_mimetype_description($file), 'moodle', array('class' => 'icon'));
 
-            $linkhtml   = html_writer::link($fileurl, $image) . substr($filepath, 1) . html_writer::link($fileurl, $filename);
+            $linkhtml   = html_writer::link($fileurl, $image . substr($filepath, 1) . $filename);
             $linktxt    = "$filename [$fileurl]";
 
             if ($format == 'html') {
index 8ad1e70..3192c32 100644 (file)
@@ -149,29 +149,18 @@ function my_reset_page_for_all_users($private = MY_PAGE_PRIVATE, $pagetype = 'my
     // This may take a while. Raise the execution time limit.
     core_php_time_limit::raise();
 
-    // Find all the user pages.
-    $where = 'userid IS NOT NULL AND private = :private';
-    $params = array('private' => $private);
-    $pages = $DB->get_recordset_select('my_pages', $where, $params, 'id, userid');
-    $blockids = array();
-
-    foreach ($pages as $page) {
-        $usercontext = context_user::instance($page->userid);
-
-        // Find all block instances in that page.
-        $blockswhere = 'parentcontextid = :parentcontextid AND
-            pagetypepattern = :pagetypepattern AND
-            (subpagepattern IS NULL OR subpagepattern = :subpagepattern)';
-        $blockswhereparams = [
-            'parentcontextid' => $usercontext->id,
-            'pagetypepattern' => $pagetype,
-            'subpagepattern' => $page->id
-        ];
-        if ($pageblockids = $DB->get_fieldset_select('block_instances', 'id', $blockswhere, $blockswhereparams)) {
-            $blockids = array_merge($blockids, $pageblockids);
-        }
-    }
-    $pages->close();
+    // Find all the user pages and all block instances in them.
+    $sql = "SELECT bi.id
+        FROM {my_pages} p
+        JOIN {context} ctx ON ctx.instanceid = p.userid AND ctx.contextlevel = :usercontextlevel
+        JOIN {block_instances} bi ON bi.parentcontextid = ctx.id AND
+            bi.pagetypepattern = :pagetypepattern AND
+            (bi.subpagepattern IS NULL OR bi.subpagepattern = " . $DB->sql_concat("''", 'p.id') . ")
+        WHERE p.private = :private";
+    $params = array('private' => $private,
+        'usercontextlevel' => CONTEXT_USER,
+        'pagetypepattern' => $pagetype);
+    $blockids = $DB->get_fieldset_sql($sql, $params);
 
     // Wrap the SQL queries in a transaction.
     $transaction = $DB->start_delegated_transaction();
@@ -182,9 +171,7 @@ function my_reset_page_for_all_users($private = MY_PAGE_PRIVATE, $pagetype = 'my
     }
 
     // Finally delete the pages.
-    if (!empty($pages)) {
-        $DB->delete_records_select('my_pages', $where, $params);
-    }
+    $DB->delete_records_select('my_pages', 'userid IS NOT NULL AND private = :private', ['private' => $private]);
 
     // We should be good to go now.
     $transaction->allow_commit();
index 6411bc5..11749ac 100644 (file)
         }
       }
     },
-    "grunt-contrib-jshint": {
-      "version": "0.11.3",
-      "from": "grunt-contrib-jshint@0.11.3",
-      "resolved": "https://registry.npmjs.org/grunt-contrib-jshint/-/grunt-contrib-jshint-0.11.3.tgz"
-    },
     "grunt-contrib-less": {
       "version": "1.1.0",
       "from": "grunt-contrib-less@1.1.0",
       "dependencies": {
         "lodash": {
           "version": "4.13.1",
-          "from": "lodash@>=4.3.0 <5.0.0"
+          "from": "lodash@4.13.1",
+          "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.13.1.tgz"
         }
       }
     },
       "from": "jsbn@>=0.1.0 <0.2.0",
       "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.0.tgz"
     },
-    "jshint": {
-      "version": "2.8.0",
-      "from": "jshint@>=2.8.0 <2.9.0",
-      "resolved": "https://registry.npmjs.org/jshint/-/jshint-2.8.0.tgz",
-      "dependencies": {
-        "lodash": {
-          "version": "3.7.0",
-          "from": "lodash@>=3.7.0 <3.8.0",
-          "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.7.0.tgz"
-        },
-        "minimatch": {
-          "version": "2.0.10",
-          "from": "minimatch@>=2.0.0 <2.1.0",
-          "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-2.0.10.tgz"
-        }
-      }
-    },
     "jslint": {
       "version": "0.3.4",
       "from": "jslint@>=0.3.0 <0.4.0",
       "dependencies": {
         "lodash": {
           "version": "4.13.1",
-          "from": "lodash@>=4.0.0 <5.0.0"
+          "from": "lodash@>=4.0.0 <5.0.0",
+          "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.13.1.tgz"
         }
       }
     },
index 5989a0e..fa72459 100644 (file)
@@ -5,7 +5,6 @@
   "devDependencies": {
     "async": "^1.5.2",
     "grunt": "0.4.5",
-    "grunt-contrib-jshint": "0.11.3",
     "grunt-contrib-less": "1.1.0",
     "grunt-contrib-uglify": "0.11.0",
     "grunt-contrib-watch": "0.6.1",
index 63b6b67..7305552 100644 (file)
@@ -37,6 +37,7 @@ $string['choiceno'] = 'Choice {$a}';
 $string['choices'] = 'Available choices';
 $string['clozeaid'] = 'Enter missing word';
 $string['correctansweris'] = 'The correct answer is: {$a}';
+$string['correctanswersare'] = 'The correct answers are: {$a}';
 $string['correctfeedback'] = 'For any correct response';
 $string['deletedchoice'] = 'This choice was deleted after the attempt was started.';
 $string['errgradesetanswerblank'] = 'Grade set, but the Answer is blank';
index 00fc66e..bc114eb 100644 (file)
@@ -185,6 +185,24 @@ abstract class qtype_multichoice_renderer_base extends qtype_with_combined_feedb
     public function specific_feedback(question_attempt $qa) {
         return $this->combined_feedback($qa);
     }
+
+    /**
+     * Function returns string based on number of correct answers
+     * @param array $right An Array of correct responses to the current question
+     * @return string based on number of correct responses
+     */
+    protected function correct_choices(array $right) {
+        // Return appropriate string for single/multiple correct answer(s).
+        if (count($right) == 1) {
+                return get_string('correctansweris', 'qtype_multichoice',
+                        implode(', ', $right));
+        } else if (count($right) > 1) {
+                return get_string('correctanswersare', 'qtype_multichoice',
+                        implode(', ', $right));
+        } else {
+                return "";
+        }
+    }
 }
 
 
@@ -223,16 +241,16 @@ class qtype_multichoice_single_renderer extends qtype_multichoice_renderer_base
     public function correct_response(question_attempt $qa) {
         $question = $qa->get_question();
 
+        // Put all correct answers (100% grade) into $right.
+        $right = array();
         foreach ($question->answers as $ansid => $ans) {
             if (question_state::graded_state_for_fraction($ans->fraction) ==
                     question_state::$gradedright) {
-                return get_string('correctansweris', 'qtype_multichoice',
-                        $question->make_html_inline($question->format_text($ans->answer, $ans->answerformat,
-                                $qa, 'question', 'answer', $ansid)));
+                $right[] = $question->make_html_inline($question->format_text($ans->answer, $ans->answerformat,
+                        $qa, 'question', 'answer', $ansid));
             }
         }
-
-        return '';
+        return $this->correct_choices($right);
     }
 }
 
@@ -282,12 +300,7 @@ class qtype_multichoice_multi_renderer extends qtype_multichoice_renderer_base {
                         $qa, 'question', 'answer', $ansid));
             }
         }
-
-        if (!empty($right)) {
-                return get_string('correctansweris', 'qtype_multichoice',
-                        implode(', ', $right));
-        }
-        return '';
+        return $this->correct_choices($right);
     }
 
     protected function num_parts_correct(question_attempt $qa) {
index aa7d7cd..e3f5b5e 100644 (file)
@@ -54,7 +54,7 @@ Feature: Preview a Multiple choice question
     And I should see "Mark 1.00 out of 1.00"
     And I should see "Well done!"
     And I should see "The odd numbers are One and Three."
-    And I should see "The correct answer is: One, Three"
+    And I should see "The correct answers are: One, Three"
     And I switch to the main window
 
   @javascript @_switch_window
index 82f7483..8364e98 100644 (file)
@@ -61,6 +61,10 @@ class search extends \moodleform {
         foreach ($searchareas as $areaid => $searcharea) {
             $areanames[$areaid] = $searcharea->get_visible_name();
         }
+
+        // Sort the array by the text.
+        \core_collator::asort($areanames);
+
         $options = array(
             'multiple' => true,
             'noselectionstring' => get_string('allareas', 'search'),
index 74e7b07..14f8eeb 100644 (file)
@@ -85,7 +85,7 @@ class theme_clean_core_renderer extends theme_bootstrapbase_core_renderer {
             array('class' => 'small-logo'));
 
         if ($returnlink) {
-            $logocontainer = html_writer::link($CFG->wwwroot, $image,
+            $logocontainer = html_writer::link(new moodle_url('/'), $image,
                 array('class' => 'small-logo-container', 'title' => get_string('home')));
         } else {
             $logocontainer = html_writer::tag('span', $image, array('class' => 'small-logo-container'));
@@ -113,7 +113,7 @@ class theme_clean_core_renderer extends theme_bootstrapbase_core_renderer {
         $sitename = format_string($SITE->shortname, true, array('context' => context_course::instance(SITEID)));
 
         if ($returnlink) {
-            return html_writer::link($CFG->wwwroot, $sitename, array('class' => 'brand', 'title' => get_string('home')));
+            return html_writer::link(new moodle_url('/'), $sitename, array('class' => 'brand', 'title' => get_string('home')));
         }
 
         return html_writer::tag('span', $sitename, array('class' => 'brand'));
diff --git a/user/classes/search/user.php b/user/classes/search/user.php
new file mode 100644 (file)
index 0000000..b51ed8e
--- /dev/null
@@ -0,0 +1,123 @@
+<?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/>.
+
+/**
+ * Search area for Users for whom I have authority to view profile.
+ *
+ * @package    core_user
+ * @copyright  2016 Devang Gaur {@link http://www.devanggaur.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_user\search;
+
+require_once($CFG->dirroot . '/user/lib.php');
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Search area for Users for whom I have access to view profile.
+ *
+ * @package    core_user
+ * @copyright  2016 Devang Gaur {@link http://www.devanggaur.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class user extends \core_search\area\base {
+
+    /**
+     * Returns recordset containing required data attributes for indexing.
+     *
+     * @param number $modifiedfrom
+     * @return \moodle_recordset
+     */
+    public function get_recordset_by_timestamp($modifiedfrom = 0) {
+        global $DB;
+        return $DB->get_recordset_select('user', 'timemodified >= ? AND deleted = ? AND
+                confirmed = ?', array($modifiedfrom, 0, 1));
+    }
+
+    /**
+     * Returns document instances for each record in the recordset.
+     *
+     * @param StdClass $record
+     * @param array $options
+     * @return core_search/document
+     */
+    public function get_document($record, $options = array()) {
+
+        $context = \context_system::instance();
+
+        // Prepare associative array with data from DB.
+        $doc = \core_search\document_factory::instance($record->id, $this->componentname, $this->areaname);
+        // Assigning properties to our document.
+        $doc->set('title', content_to_text(fullname($record), false));
+        $doc->set('contextid', $context->id);
+        $doc->set('courseid', SITEID);
+        $doc->set('itemid', $record->id);
+        $doc->set('modified', $record->timemodified);
+        $doc->set('owneruserid', \core_search\manager::NO_OWNER_ID);
+        $doc->set('content', content_to_text($record->description, $record->descriptionformat));
+
+        // Check if this document should be considered new.
+        if (isset($options['lastindexedtime']) && $options['lastindexedtime'] < $record->timecreated) {
+            // If the document was created after the last index time, it must be new.
+            $doc->set_is_new(true);
+        }
+
+        return $doc;
+    }
+
+    /**
+     * Checking whether I can access a document
+     *
+     * @param int $id user id
+     * @return int
+     */
+    public function check_access($id) {
+        global $DB, $USER;
+
+        $user = $DB->get_record('user', array('id' => $id));
+        if (!$user || $user->deleted) {
+            return \core_search\manager::ACCESS_DELETED;
+        }
+
+        if (user_can_view_profile($user)) {
+            return \core_search\manager::ACCESS_GRANTED;
+        }
+
+        return \core_search\manager::ACCESS_DENIED;
+    }
+
+    /**
+     * Returns a url to the profile page of user.
+     *
+     * @param \core_search\document $doc
+     * @return moodle_url
+     */
+    public function get_doc_url(\core_search\document $doc) {
+        return $this->get_context_url($doc);
+    }
+
+    /**
+     * Returns a url to the document context.
+     *
+     * @param \core_search\document $doc
+     * @return moodle_url
+     */
+    public function get_context_url(\core_search\document $doc) {
+        return new \moodle_url('/user/profile.php', array('id' => $doc->get('itemid')));
+    }
+}
index bc1b693..99f2d96 100644 (file)
@@ -97,7 +97,7 @@ if ($data = data_submitted()) {
             if (!array_key_exists($m[2], $SESSION->emailto[$id])) {
                 if ($user = $DB->get_record_select('user', "id = ?", array($m[2]), 'id, '.
                         $namefields . ', idnumber, email, mailformat, lastaccess, lang, '.
-                        'maildisplay, auth, suspended, deleted, emailstop')) {
+                        'maildisplay, auth, suspended, deleted, emailstop, username')) {
                     $SESSION->emailto[$id][$m[2]] = $user;
                     $count++;
                 }
diff --git a/user/tests/search_test.php b/user/tests/search_test.php
new file mode 100644 (file)
index 0000000..371185b
--- /dev/null
@@ -0,0 +1,189 @@
+<?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/>.
+
+/**
+ * Course global search unit tests.
+ *
+ * @package     core
+ * @copyright   2016 Devang Gaur {@link http://www.devanggaur.com}
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php');
+
+/**
+ * Provides the unit tests for course global search.
+ *
+ * @package     core
+ * @copyright   2016 Devang Gaur {@link http://www.davidmonllao.com}
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class user_search_testcase extends advanced_testcase {
+
+    /**
+     * @var string Area id
+     */
+    protected $userareaid = null;
+
+    public function setUp() {
+        $this->resetAfterTest(true);
+        set_config('enableglobalsearch', true);
+
+        $this->userareaid = \core_search\manager::generate_areaid('core_user', 'user');
+
+        // Set \core_search::instance to the mock_search_engine as we don't require the search engine to be working to test this.
+        $search = testable_core_search::instance();
+    }
+
+    /**
+     * Indexing users contents.
+     *
+     * @return void
+     */
+    public function test_users_indexing() {
+
+        // Returns the instance as long as the area is supported.
+        $searcharea = \core_search\manager::get_search_area($this->userareaid);
+        $this->assertInstanceOf('\core_user\search\user', $searcharea);
+
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        // All records.
+        // Recordset will produce 4 user records:
+        // Guest User, Admin User and two above generated users.
+        $recordset = $searcharea->get_recordset_by_timestamp(0);
+        $this->assertTrue($recordset->valid());
+        $nrecords = 0;
+        foreach ($recordset as $record) {
+            $this->assertInstanceOf('stdClass', $record);
+            $doc = $searcharea->get_document($record);
+            $this->assertInstanceOf('\core_search\document', $doc);
+            $nrecords++;
+        }
+        // If there would be an error/failure in the foreach above the recordset would be closed on shutdown.
+        $recordset->close();
+        $this->assertEquals(4, $nrecords);
+
+        // The +2 is to prevent race conditions.
+        $recordset = $searcharea->get_recordset_by_timestamp(time() + 2);
+
+        // No new records.
+        $this->assertFalse($recordset->valid());
+        $recordset->close();
+    }
+
+    /**
+     * Document contents.
+     *
+     * @return void
+     */
+    public function test_users_document() {
+
+        // Returns the instance as long as the area is supported.
+        $searcharea = \core_search\manager::get_search_area($this->userareaid);
+        $this->assertInstanceOf('\core_user\search\user', $searcharea);
+
+        $user = self::getDataGenerator()->create_user();
+
+        $doc = $searcharea->get_document($user);
+        $this->assertInstanceOf('\core_search\document', $doc);
+        $this->assertEquals($user->id, $doc->get('itemid'));
+        $this->assertEquals($this->userareaid . '-' . $user->id, $doc->get('id'));
+        $this->assertEquals(SITEID, $doc->get('courseid'));
+        $this->assertFalse($doc->is_set('userid'));
+        $this->assertEquals(\core_search\manager::NO_OWNER_ID, $doc->get('owneruserid'));
+        $this->assertEquals(content_to_text(fullname($user), false), $doc->get('title'));
+        $this->assertEquals(content_to_text($user->description, $user->descriptionformat), $doc->get('content'));
+    }
+
+    /**
+     * Document accesses.
+     *
+     * @return void
+     */
+    public function test_users_access() {
+
+        // Returns the instance as long as the area is supported.
+        $searcharea = \core_search\manager::get_search_area($this->userareaid);
+
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+        $user3 = self::getDataGenerator()->create_user();
+        $user4 = self::getDataGenerator()->create_user();
+
+        $deleteduser = self::getDataGenerator()->create_user(array('deleted' => 1));
+        $unconfirmeduser = self::getDataGenerator()->create_user(array('confirmed' => 0));
+        $suspendeduser = self::getDataGenerator()->create_user(array('suspended' => 1));
+
+        $course1 = self::getDataGenerator()->create_course();
+        $course2 = self::getDataGenerator()->create_course();
+
+        $group1 = $this->getDataGenerator()->create_group(array('courseid' => $course1->id));
+        $group2 = $this->getDataGenerator()->create_group(array('courseid' => $course1->id));
+
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id, 'teacher');
+        $this->getDataGenerator()->enrol_user($user2->id, $course1->id, 'student');
+        $this->getDataGenerator()->enrol_user($user2->id, $course2->id, 'student');
+        $this->getDataGenerator()->enrol_user($user3->id, $course2->id, 'student');
+        $this->getDataGenerator()->enrol_user($user4->id, $course2->id, 'student');
+        $this->getDataGenerator()->enrol_user($suspendeduser->id, $course1->id, 'student');
+
+        $this->getDataGenerator()->create_group_member(array('userid' => $user2->id, 'groupid' => $group1->id));
+        $this->getDataGenerator()->create_group_member(array('userid' => $user3->id, 'groupid' => $group1->id));
+        $this->getDataGenerator()->create_group_member(array('userid' => $user4->id, 'groupid' => $group2->id));
+
+        $this->setAdminUser();
+        $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user1->id));
+        $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user2->id));
+        $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user3->id));
+        $this->assertEquals(\core_search\manager::ACCESS_DELETED, $searcharea->check_access($deleteduser->id));
+        $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($unconfirmeduser->id));
+        $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($suspendeduser->id));
+        $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access(2));
+
+        $this->setUser($user1);
+        $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user1->id));
+        $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user2->id));
+        $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($user3->id));
+        $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($user4->id));
+        $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access(1));// Guest user can't be accessed.
+        $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access(2));// Admin user can't be accessed.
+        $this->assertEquals(\core_search\manager::ACCESS_DELETED, $searcharea->check_access(-123));
+        $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($unconfirmeduser->id));
+        $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($suspendeduser->id));
+
+        $this->setUser($user2);
+        $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user1->id));
+        $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user2->id));
+        $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user3->id));
+        $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user4->id));
+
+        $this->setUser($user3);
+        $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($user1->id));
+        $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user2->id));
+        $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user3->id));
+        $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($suspendeduser->id));
+
+        $this->setGuestUser();
+        $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user1->id));
+        $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user2->id));
+        $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user3->id));
+    }
+}
\ No newline at end of file
index e4fb7b5..005e18c 100644 (file)
@@ -111,6 +111,10 @@ foreach ($_FILES as $fieldname=>$uploaded_file) {
             throw new moodle_exception('nofile');
         }
     }
+
+    // Scan for viruses.
+    \core\antivirus\manager::scan_file($_FILES[$fieldname]['tmp_name'], $_FILES[$fieldname]['name'], true);
+
     $file = new stdClass();
     $file->filename = clean_param($_FILES[$fieldname]['name'], PARAM_FILE);
     // check system maxbytes setting