Merge branch 'MDL-59950_master' of git://github.com/dmonllao/moodle
authorDamyon Wiese <damyon@moodle.com>
Thu, 2 Nov 2017 06:39:32 +0000 (14:39 +0800)
committerDamyon Wiese <damyon@moodle.com>
Thu, 2 Nov 2017 06:39:32 +0000 (14:39 +0800)
151 files changed:
.travis.yml
admin/environment.xml
admin/index.php
admin/registration/renderer.php
admin/renderer.php
admin/settings/plugins.php
admin/tool/httpsreplace/classes/url_finder.php
admin/tool/messageinbound/classes/manager.php
admin/tool/mobile/classes/api.php
admin/tool/mobile/tests/externallib_test.php
analytics/classes/analysable.php
analytics/classes/course.php
analytics/classes/local/analyser/by_course.php
analytics/classes/local/time_splitting/base.php
analytics/tests/course_test.php
analytics/tests/prediction_test.php
blocks/calendar_upcoming/block_calendar_upcoming.php
calendar/amd/build/calendar.min.js
calendar/amd/build/calendar_mini.min.js
calendar/amd/build/calendar_view.min.js
calendar/amd/build/crud.min.js
calendar/amd/build/modal_event_form.min.js
calendar/amd/build/repository.min.js
calendar/amd/build/selectors.min.js
calendar/amd/build/view_manager.min.js
calendar/amd/src/calendar.js
calendar/amd/src/calendar_mini.js
calendar/amd/src/calendar_view.js
calendar/amd/src/crud.js
calendar/amd/src/modal_event_form.js
calendar/amd/src/repository.js
calendar/amd/src/selectors.js
calendar/amd/src/view_manager.js
calendar/classes/external/calendar_day_exporter.php
calendar/classes/external/calendar_upcoming_exporter.php
calendar/classes/external/day_exporter.php
calendar/classes/external/month_exporter.php
calendar/classes/external/week_day_exporter.php
calendar/classes/local/event/forms/create.php
calendar/classes/local/event/forms/update.php
calendar/externallib.php
calendar/lib.php
calendar/renderer.php
calendar/templates/calendar_upcoming_mini.mustache [new file with mode: 0644]
calendar/templates/event_summary_body.mustache
calendar/templates/month_detailed.mustache
calendar/templates/upcoming_mini.mustache
calendar/tests/lib_test.php
completion/classes/api.php
completion/tests/api_test.php
course/externallib.php
course/lib.php
course/tests/indicators_test.php
filter/mathjaxloader/db/upgrade.php
filter/mathjaxloader/readme_moodle.txt
filter/mathjaxloader/settings.php
filter/mathjaxloader/version.php
grade/grading/lib.php
group/classes/output/group_details.php [new file with mode: 0644]
group/classes/output/renderer.php
group/members.php
group/templates/group_details.mustache [new file with mode: 0644]
group/tests/behat/group_description.feature [new file with mode: 0644]
lang/en/admin.php
lang/en/calendar.php
lib/amd/build/modal.min.js
lib/amd/src/modal.js
lib/behat/classes/partial_named_selector.php
lib/classes/event/user_login_failed.php
lib/classes/hub/site_registration_form.php
lib/classes/output/icon_system.php
lib/classes/output/icon_system_fontawesome.php
lib/classes/task/refresh_mod_calendar_events_task.php
lib/environmentlib.php
lib/form/editor.php
lib/navigationlib.php
lib/phpunit/classes/base_testcase.php
lib/phpunit/classes/util.php
lib/requirejs/moodle-config.js
lib/requirejs/readme_moodle.txt
lib/templates/modal.mustache
lib/weblib.php
message/output/popup/templates/message_popover.mustache
message/output/popup/templates/notification_popover.mustache
mod/assign/amd/build/grading_navigation.min.js
mod/assign/amd/build/grading_panel.min.js
mod/assign/amd/src/grading_navigation.js
mod/assign/amd/src/grading_panel.js
mod/assign/styles.css
mod/data/classes/external.php
mod/data/tests/externallib_test.php
mod/feedback/db/upgrade.php
mod/label/mod_form.php
mod/lesson/lang/en/lesson.php
mod/lesson/locallib.php
mod/lti/services.php
mod/page/classes/external.php
mod/quiz/attemptlib.php
mod/quiz/classes/question/bank/custom_view.php
mod/quiz/report/attemptsreport.php
mod/quiz/report/attemptsreport_table.php
mod/quiz/report/grading/report.php
mod/quiz/report/overview/overview_table.php
mod/quiz/report/overview/report.php
mod/quiz/report/overview/tests/behat/basic.feature
mod/quiz/report/overview/tests/report_test.php
mod/quiz/report/responses/first_or_all_responses_table.php
mod/quiz/report/responses/report.php
mod/quiz/report/responses/responses_form.php
mod/quiz/report/responses/tests/behat/basic.feature [new file with mode: 0644]
mod/quiz/styles.css
mod/workshop/classes/external.php
mod/workshop/tests/external_test.php
package.json
pix/i/calendareventdescription.png [new file with mode: 0644]
pix/i/calendareventdescription.svg [new file with mode: 0644]
pix/i/calendareventtime.png [new file with mode: 0644]
pix/i/calendareventtime.svg [new file with mode: 0644]
question/behaviour/manualgraded/tests/walkthrough_test.php
question/behaviour/rendererbase.php
question/engine/datalib.php
question/engine/questionattempt.php
question/type/numerical/tests/helper.php
search/classes/engine.php
search/classes/manager.php
search/engine/solr/lang/en/search_solr.php
search/tests/fixtures/mock_search_engine.php
search/tests/fixtures/testable_core_search.php
search/tests/manager_test.php
theme/boost/scss/moodle/calendar.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/course.scss
theme/boost/scss/moodle/modal.scss
theme/boost/scss/moodle/question.scss
theme/boost/scss/moodle/user.scss
theme/boost/templates/core/modal.mustache
theme/bootstrapbase/less/bootstrap/popovers.less
theme/bootstrapbase/less/moodle/calendar.less
theme/bootstrapbase/less/moodle/core.less
theme/bootstrapbase/less/moodle/modules.less
theme/bootstrapbase/less/moodle/question.less
theme/bootstrapbase/less/moodle/user.less
theme/bootstrapbase/readme_moodle.txt
theme/bootstrapbase/style/moodle.css
theme/bootstrapbase/templates/core_calendar/event_summary_body.mustache [new file with mode: 0644]
user/amd/build/unified_filter.min.js
user/amd/build/unified_filter_datasource.min.js
user/amd/src/unified_filter.js
user/amd/src/unified_filter_datasource.js
user/index.php
version.php

index f9f8b76..6542585 100644 (file)
@@ -55,7 +55,7 @@ matrix:
     include:
           # Run grunt/npm install on highest version ('node' is an alias for the latest node.js version.)
         - php: 7.1
-          env: DB=none     TASK=GRUNT   NVM_VERSION='node'
+          env: DB=none     TASK=GRUNT   NVM_VERSION='8.9'
 
     exclude:
         # MySQL - it's just too slow.
index b8c7f16..1f2f001 100644 (file)
       <VENDOR name="oracle" version="10.2" />
     </DATABASE>
     <PHP version="5.6.5" level="required">
+      <RESTRICT function="restrict_php_version_72" message="unsupportedphpversion72" />
     </PHP>
     <PCREUNICODE level="optional">
       <FEEDBACK>
       <VENDOR name="oracle" version="10.2" />
     </DATABASE>
     <PHP version="5.6.5" level="required">
+      <RESTRICT function="restrict_php_version_72" message="unsupportedphpversion72" />
     </PHP>
     <PCREUNICODE level="optional">
       <FEEDBACK>
index 8d0199e..e04c319 100644 (file)
@@ -868,6 +868,7 @@ $cachewarnings = cache_helper::warnings();
 $eventshandlers = $DB->get_records_sql('SELECT DISTINCT component FROM {events_handlers}');
 $themedesignermode = !empty($CFG->themedesignermode);
 $mobileconfigured = !empty($CFG->enablemobilewebservice);
+$invalidforgottenpasswordurl = !empty($CFG->forgottenpasswordurl) && empty(clean_param($CFG->forgottenpasswordurl, PARAM_URL));
 
 // Check if a directory with development libraries exists.
 if (empty($CFG->disabledevlibdirscheck) && (is_dir($CFG->dirroot.'/vendor') || is_dir($CFG->dirroot.'/node_modules'))) {
@@ -885,4 +886,4 @@ $output = $PAGE->get_renderer('core', 'admin');
 echo $output->admin_notifications_page($maturity, $insecuredataroot, $errorsdisplayed, $cronoverdue, $dbproblems,
                                        $maintenancemode, $availableupdates, $availableupdatesfetch, $buggyiconvnomb,
                                        $registered, $cachewarnings, $eventshandlers, $themedesignermode, $devlibdir,
-                                       $mobileconfigured, $overridetossl);
+                                       $mobileconfigured, $overridetossl, $invalidforgottenpasswordurl);
index 4832013..b7414b4 100644 (file)
@@ -36,6 +36,6 @@ class core_register_renderer extends plugin_renderer_base {
      * @return string
      */
     public function moodleorg_registration_message() {
-        return format_text(get_string('registermoodlenet', 'admin'), FORMAT_MARKDOWN, ['noclean' => true]);
+        return format_text(get_string('registermoodlenet', 'admin'), FORMAT_HTML, ['noclean' => true]);
     }
 }
index 9ea4ea1..dbd77f6 100644 (file)
@@ -280,6 +280,7 @@ class core_admin_renderer extends plugin_renderer_base {
      * @param bool $devlibdir Warn about development libs directory presence.
      * @param bool $mobileconfigured Whether the mobile web services have been enabled
      * @param bool $overridetossl Whether or not ssl is being forced.
+     * @param bool $invalidforgottenpasswordurl Whether the forgotten password URL does not link to a valid URL.
      *
      * @return string HTML to output.
      */
@@ -287,7 +288,7 @@ class core_admin_renderer extends plugin_renderer_base {
             $cronoverdue, $dbproblems, $maintenancemode, $availableupdates, $availableupdatesfetch,
             $buggyiconvnomb, $registered, array $cachewarnings = array(), $eventshandlers = 0,
             $themedesignermode = false, $devlibdir = false, $mobileconfigured = false,
-            $overridetossl = false) {
+            $overridetossl = false, $invalidforgottenpasswordurl = false) {
         global $CFG;
         $output = '';
 
@@ -308,6 +309,7 @@ class core_admin_renderer extends plugin_renderer_base {
         $output .= $this->events_handlers($eventshandlers);
         $output .= $this->registration_warning($registered);
         $output .= $this->mobile_configuration_warning($mobileconfigured);
+        $output .= $this->forgotten_password_url_warning($invalidforgottenpasswordurl);
 
         //////////////////////////////////////////////////////////////////////////////////////////////////
         ////  IT IS ILLEGAL AND A VIOLATION OF THE GPL TO HIDE, REMOVE OR MODIFY THIS COPYRIGHT NOTICE ///
@@ -866,6 +868,24 @@ class core_admin_renderer extends plugin_renderer_base {
         return $output;
     }
 
+    /**
+     * Display a warning about the forgotten password URL not linking to a valid URL.
+     *
+     * @param boolean $invalidforgottenpasswordurl true if the forgotten password URL is not valid
+     * @return string HTML to output.
+     */
+    protected function forgotten_password_url_warning($invalidforgottenpasswordurl) {
+        $output = '';
+        if ($invalidforgottenpasswordurl) {
+            $settingslink = new moodle_url('/admin/settings.php', ['section' => 'manageauths']);
+            $configurebutton = $this->single_button($settingslink, get_string('check', 'moodle'));
+            $output .= $this->warning(get_string('invalidforgottenpasswordurl', 'admin') . '&nbsp;' . $configurebutton,
+                'error alert alert-danger');
+        }
+
+        return $output;
+    }
+
     /**
      * Helper method to render the information about the available Moodle update
      *
index 4444ff0..c55b1f1 100644 (file)
@@ -102,7 +102,7 @@ if ($hassiteconfig) {
     $temp->add(new admin_setting_configtext('alternateloginurl', new lang_string('alternateloginurl', 'auth'),
                                             new lang_string('alternatelogin', 'auth', htmlspecialchars(get_login_url())), ''));
     $temp->add(new admin_setting_configtext('forgottenpasswordurl', new lang_string('forgottenpasswordurl', 'auth'),
-                                            new lang_string('forgottenpassword', 'auth'), ''));
+                                            new lang_string('forgottenpassword', 'auth'), '', PARAM_URL));
     $temp->add(new admin_setting_confightmleditor('auth_instructions', new lang_string('instructions', 'auth'),
                                                 new lang_string('authinstructions', 'auth'), ''));
     $setting = new admin_setting_configtext('allowemailaddresses', new lang_string('allowemailaddresses', 'admin'),
index af9fc9b..8dc1416 100644 (file)
@@ -24,6 +24,9 @@
 
 namespace tool_httpsreplace;
 
+use database_column_info;
+use progress_bar;
+
 defined('MOODLE_INTERNAL') || die();
 
 /**
@@ -60,7 +63,7 @@ class url_finder {
      * for less straightforward swaps.
      *
      * @param string $table
-     * @param string $column
+     * @param database_column_info $column
      * @param string $domain
      * @param string $search search string that has prefix, protocol, domain name and one extra character,
      *      example1: src="http://host.com/
@@ -174,7 +177,7 @@ class url_finder {
                             $regex = '#((src|data)\ *=\ *[\'\"])(http://)([^\s()<>]+(?:\([\w\d]+\)|([^[:punct:]\s]|/)))[\'\"]#i';
                             preg_match_all($regex, $record->$columnname, $match);
                             foreach ($match[0] as $i => $fullmatch) {
-                                if (strpos($fullmatch, $CFG->wwwroot) !== false) {
+                                if (\core_text::strpos($fullmatch, $CFG->wwwroot) !== false) {
                                     continue;
                                 }
                                 $prefix = $match[1][$i];
@@ -186,7 +189,7 @@ class url_finder {
                                 }
                                 if ($replacing) {
                                     // For replace string use: prefix, protocol, host and one extra character.
-                                    $found[$prefix . substr($url, 0, strlen($host) + 8)] = $host;
+                                    $found[$prefix . \core_text::substr($url, 0, \core_text::strlen($host) + 8)] = $host;
                                 } else {
                                     $entry["table"] = $table;
                                     $entry["columnname"] = $columnname;
index 74de88a..c9925e4 100644 (file)
@@ -584,10 +584,10 @@ class manager {
                 'usestream' => true,
             ));
 
-            if ($part === $plainpartid) {
+            if ($part == $plainpartid) {
                 $contentplain = $this->process_message_part_body($messagedata, $partdata, $part);
 
-            } else if ($part === $htmlpartid) {
+            } else if ($part == $htmlpartid) {
                 $contenthtml = $this->process_message_part_body($messagedata, $partdata, $part);
 
             } else if ($filename = $partdata->getName($part)) {
index bfc42ee..9b3e3b3 100644 (file)
@@ -140,7 +140,7 @@ class api {
             'rememberusername' => $CFG->rememberusername,
             'authloginviaemail' => $CFG->authloginviaemail,
             'registerauth' => $CFG->registerauth,
-            'forgottenpasswordurl' => $CFG->forgottenpasswordurl,
+            'forgottenpasswordurl' => clean_param($CFG->forgottenpasswordurl, PARAM_URL), // We may expect a mailto: here.
             'authinstructions' => $authinstructions,
             'authnoneenabled' => (int) is_enabled_auth('none'),
             'enablewebservices' => $CFG->enablewebservices,
index b5986aa..ff1f3c1 100644 (file)
@@ -97,11 +97,13 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
         set_config('typeoflogin', api::LOGIN_VIA_BROWSER, 'tool_mobile');
         set_config('logo', 'mock.png', 'core_admin');
         set_config('logocompact', 'mock.png', 'core_admin');
+        set_config('forgottenpasswordurl', 'mailto:fake@email.zy'); // Test old hack.
 
         list($authinstructions, $notusedformat) = external_format_text($authinstructions, FORMAT_MOODLE, $context->id);
         $expected['registerauth'] = 'email';
         $expected['authinstructions'] = $authinstructions;
         $expected['typeoflogin'] = api::LOGIN_VIA_BROWSER;
+        $expected['forgottenpasswordurl'] = ''; // Expect empty when it's not an URL.
 
         if ($logourl = $OUTPUT->get_logo_url()) {
             $expected['logourl'] = $logourl->out(false);
index 97faf59..e9dcaae 100644 (file)
@@ -29,6 +29,10 @@ defined('MOODLE_INTERNAL') || die();
 /**
  * Any element analysers can analyse.
  *
+ * Analysers get_analysers method return all analysable elements in the site;
+ * it is important that analysable elements implement lazy loading to avoid
+ * big memory footprints. See \core_analytics\course example.
+ *
  * @package   core_analytics
  * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
index 07c5e9d..fed39db 100644 (file)
@@ -40,9 +40,19 @@ require_once($CFG->dirroot . '/lib/enrollib.php');
 class course implements \core_analytics\analysable {
 
     /**
-     * @var \core_analytics\course[] $instances
+     * @var bool Has this course data been already loaded.
      */
-    protected static $instances = array();
+    protected $loaded = false;
+
+    /**
+     * @var int $cachedid self::$cachedinstance analysable id.
+     */
+    protected static $cachedid = 0;
+
+    /**
+     * @var \core_analytics\course $cachedinstance
+     */
+    protected static $cachedinstance = null;
 
     /**
      * Course object
@@ -122,7 +132,7 @@ class course implements \core_analytics\analysable {
      * Use self::instance() instead to get cached copies of the course. Instances obtained
      * through this constructor will not be cached.
      *
-     * Loads course students and teachers.
+     * Lazy load of course data, students and teachers.
      *
      * @param int|stdClass $course Course id
      * @return void
@@ -130,35 +140,19 @@ class course implements \core_analytics\analysable {
     public function __construct($course) {
 
         if (is_scalar($course)) {
-            $this->course = get_course($course);
+            $this->course = new \stdClass();
+            $this->course->id = $course;
         } else {
             $this->course = $course;
         }
-
-        $this->coursecontext = \context_course::instance($this->course->id);
-
-        $this->now = time();
-
-        // Get the course users, including users assigned to student and teacher roles at an higher context.
-        $cache = \cache::make_from_params(\cache_store::MODE_REQUEST, 'core_analytics', 'rolearchetypes');
-
-        if (!$studentroles = $cache->get('student')) {
-            $studentroles = array_keys(get_archetype_roles('student'));
-            $cache->set('student', $studentroles);
-        }
-        $this->studentids = $this->get_user_ids($studentroles);
-
-        if (!$teacherroles = $cache->get('teacher')) {
-            $teacherroles = array_keys(get_archetype_roles('editingteacher') + get_archetype_roles('teacher'));
-            $cache->set('teacher', $teacherroles);
-        }
-        $this->teacherids = $this->get_user_ids($teacherroles);
     }
 
     /**
      * Returns an analytics course instance.
      *
-     * @param int|stdClass $course Course id
+     * Lazy load of course data, students and teachers.
+     *
+     * @param int|stdClass $course Course object or course id
      * @return \core_analytics\course
      */
     public static function instance($course) {
@@ -168,31 +162,59 @@ class course implements \core_analytics\analysable {
             $courseid = $course->id;
         }
 
-        if (!empty(self::$instances[$courseid])) {
-            return self::$instances[$courseid];
+        if (self::$cachedid === $courseid) {
+            return self::$cachedinstance;
         }
 
-        $instance = new \core_analytics\course($course);
-        self::$instances[$courseid] = $instance;
-        return self::$instances[$courseid];
+        $cachedinstance = new \core_analytics\course($course);
+        self::$cachedinstance = $cachedinstance;
+        self::$cachedid = (int)$courseid;
+        return self::$cachedinstance;
     }
 
     /**
-     * Clears all statically cached instances.
+     * get_id
      *
-     * @return void
+     * @return int
      */
-    public static function reset_caches() {
-        self::$instances = array();
+    public function get_id() {
+        return $this->course->id;
     }
 
     /**
-     * get_id
+     * Loads the analytics course object.
      *
-     * @return int
+     * @return null
      */
-    public function get_id() {
-        return $this->course->id;
+    protected function load() {
+
+        // The instance constructor could be already loaded with the full course object. Using shortname
+        // because it is a required course field.
+        if (empty($this->course->shortname)) {
+            $this->course = get_course($this->course->id);
+        }
+
+        $this->coursecontext = $this->get_context();
+
+        $this->now = time();
+
+        // Get the course users, including users assigned to student and teacher roles at an higher context.
+        $cache = \cache::make_from_params(\cache_store::MODE_REQUEST, 'core_analytics', 'rolearchetypes');
+
+        // Flag the instance as loaded.
+        $this->loaded = true;
+
+        if (!$studentroles = $cache->get('student')) {
+            $studentroles = array_keys(get_archetype_roles('student'));
+            $cache->set('student', $studentroles);
+        }
+        $this->studentids = $this->get_user_ids($studentroles);
+
+        if (!$teacherroles = $cache->get('teacher')) {
+            $teacherroles = array_keys(get_archetype_roles('editingteacher') + get_archetype_roles('teacher'));
+            $cache->set('teacher', $teacherroles);
+        }
+        $this->teacherids = $this->get_user_ids($teacherroles);
     }
 
     /**
@@ -201,7 +223,7 @@ class course implements \core_analytics\analysable {
      * @return string
      */
     public function get_name() {
-        return format_string($this->course->shortname, true, array('context' => $this->get_context()));
+        return format_string($this->get_course_data()->shortname, true, array('context' => $this->get_context()));
     }
 
     /**
@@ -228,8 +250,8 @@ class course implements \core_analytics\analysable {
         }
 
         // The field always exist but may have no valid if the course is created through a sync process.
-        if (!empty($this->course->startdate)) {
-            $this->starttime = (int)$this->course->startdate;
+        if (!empty($this->get_course_data()->startdate)) {
+            $this->starttime = (int)$this->get_course_data()->startdate;
         } else {
             $this->starttime = 0;
         }
@@ -256,7 +278,7 @@ class course implements \core_analytics\analysable {
 
         // We first try to find current course student logs.
         $firstlogs = array();
-        foreach ($this->studentids as $studentid) {
+        foreach ($this->get_students() as $studentid) {
             // Grrr, we are limited by logging API, we could do this easily with a
             // select min(timecreated) from xx where courseid = yy group by userid.
 
@@ -278,7 +300,7 @@ class course implements \core_analytics\analysable {
         sort($firstlogs);
         $firstlogsmedian = $this->median($firstlogs);
 
-        $studentenrolments = enrol_get_course_users($this->get_id(), $this->studentids);
+        $studentenrolments = enrol_get_course_users($this->get_id(), $this->get_students());
         if (empty($studentenrolments)) {
             return 0;
         }
@@ -306,8 +328,8 @@ class course implements \core_analytics\analysable {
         }
 
         // The enddate field is only available from Moodle 3.2 (MDL-22078).
-        if (!empty($this->course->enddate)) {
-            $this->endtime = (int)$this->course->enddate;
+        if (!empty($this->get_course_data()->enddate)) {
+            $this->endtime = (int)$this->get_course_data()->enddate;
             return $this->endtime;
         }
 
@@ -362,21 +384,12 @@ class course implements \core_analytics\analysable {
      * @return \stdClass
      */
     public function get_course_data() {
-        return $this->course;
-    }
 
-    /**
-     * Is the course valid to extract indicators from it?
-     *
-     * @return bool
-     */
-    public function is_valid() {
-
-        if (!$this->was_started() || !$this->is_finished()) {
-            return false;
+        if (!$this->loaded) {
+            $this->load();
         }
 
-        return true;
+        return $this->course;
     }
 
     /**
@@ -427,7 +440,7 @@ class course implements \core_analytics\analysable {
     public function get_user_ids($roleids) {
 
         // We need to index by ra.id as a user may have more than 1 $roles role.
-        $records = get_role_users($roleids, $this->coursecontext, true, 'ra.id, u.id AS userid, r.id AS roleid', 'ra.id ASC');
+        $records = get_role_users($roleids, $this->get_context(), true, 'ra.id, u.id AS userid, r.id AS roleid', 'ra.id ASC');
 
         // If a user have more than 1 $roles role array_combine will discard the duplicate.
         $callable = array($this, 'filter_user_id');
@@ -441,6 +454,11 @@ class course implements \core_analytics\analysable {
      * @return stdClass[]
      */
     public function get_students() {
+
+        if (!$this->loaded) {
+            $this->load();
+        }
+
         return $this->studentids;
     }
 
@@ -453,7 +471,7 @@ class course implements \core_analytics\analysable {
         global $DB;
 
         // No logs if no students.
-        if (empty($this->studentids)) {
+        if (empty($this->get_students())) {
             return 0;
         }
 
@@ -606,7 +624,7 @@ class course implements \core_analytics\analysable {
 
         // When the course is using format weeks we use the week's end date.
         $format = course_get_format($activity->get_modinfo()->get_course());
-        if ($this->course->format === 'weeks') {
+        if ($this->get_course_data()->format === 'weeks') {
             $dates = $format->get_section_dates($section);
 
             // We need to consider the +2 hours added by get_section_dates.
@@ -628,7 +646,7 @@ class course implements \core_analytics\analysable {
             return false;
         }
 
-        if (!course_format_uses_sections($this->course->format)) {
+        if (!course_format_uses_sections($this->get_course_data()->format)) {
             // If it does not use sections and there are no availability conditions to access it it is available
             // and we can not magically classify it into any other time range than this one.
             return true;
@@ -731,7 +749,7 @@ class course implements \core_analytics\analysable {
         }
 
         // Check the amount of student logs in the 4 previous weeks.
-        list($studentssql, $studentsparams) = $DB->get_in_or_equal($this->studentids, SQL_PARAMS_NAMED);
+        list($studentssql, $studentsparams) = $DB->get_in_or_equal($this->get_students(), SQL_PARAMS_NAMED);
         $filterselect = $prefix . 'courseid = :courseid AND ' . $prefix . 'userid ' . $studentssql;
         $filterparams = array('courseid' => $this->course->id) + $studentsparams;
 
index 6661806..bbda69a 100644 (file)
@@ -44,17 +44,21 @@ abstract class by_course extends base {
 
         // Default to all system courses.
         if (!empty($this->options['filter'])) {
-            $courses = $this->options['filter'];
+            $courses = array();
+            foreach ($this->options['filter'] as $courseid) {
+                $courses[$courseid] = new \stdClass();
+                $courses[$courseid]->id = $courseid;
+            }
         } else {
             // Iterate through all potentially valid courses.
-            $courses = get_courses('all', 'c.sortorder ASC');
+            $courses = get_courses('all', 'c.sortorder ASC', 'c.id');
         }
         unset($courses[SITEID]);
 
         $analysables = array();
         foreach ($courses as $course) {
             // Skip the frontpage course.
-            $analysable = \core_analytics\course::instance($course);
+            $analysable = \core_analytics\course::instance($course->id);
             $analysables[$analysable->get_id()] = $analysable;
         }
 
index 11532a9..1ee6dfb 100644 (file)
@@ -197,6 +197,10 @@ abstract class base {
 
         $dataset = $this->calculate_indicators($sampleids, $samplesorigin, $indicators, $ranges);
 
+        if (empty($dataset)) {
+            return false;
+        }
+
         // Now that we have the indicators in place we can add the time range indicators (and target if provided) to each of them.
         $this->fill_dataset($dataset, $calculatedtarget);
 
index f96bcb7..0383c08 100644 (file)
@@ -86,7 +86,6 @@ class core_analytics_course_testcase extends advanced_testcase {
         $courseman = new \core_analytics\course($this->course->id);
         $this->assertFalse($courseman->was_started());
         $this->assertFalse($courseman->is_finished());
-        $this->assertFalse($courseman->is_valid());
 
         // Nothing should change when assigning as teacher.
         for ($i = 0; $i < 10; $i++) {
@@ -94,7 +93,8 @@ class core_analytics_course_testcase extends advanced_testcase {
             $this->getDataGenerator()->enrol_user($user->id, $this->course->id, $this->teacherroleid);
         }
         $courseman = new \core_analytics\course($this->course->id);
-        $this->assertFalse($courseman->is_valid());
+        $this->assertFalse($courseman->was_started());
+        $this->assertFalse($courseman->is_finished());
 
         // More students now.
         for ($i = 0; $i < 10; $i++) {
@@ -102,7 +102,8 @@ class core_analytics_course_testcase extends advanced_testcase {
             $this->getDataGenerator()->enrol_user($user->id, $this->course->id, $this->studentroleid);
         }
         $courseman = new \core_analytics\course($this->course->id);
-        $this->assertFalse($courseman->is_valid());
+        $this->assertFalse($courseman->was_started());
+        $this->assertFalse($courseman->is_finished());
 
         // Valid start date unknown end date.
         $this->course->startdate = gmmktime('0', '0', '0', 10, 24, 2015);
@@ -110,7 +111,6 @@ class core_analytics_course_testcase extends advanced_testcase {
         $courseman = new \core_analytics\course($this->course->id);
         $this->assertTrue($courseman->was_started());
         $this->assertFalse($courseman->is_finished());
-        $this->assertFalse($courseman->is_valid());
 
         // Valid start and end date.
         $this->course->enddate = gmmktime('0', '0', '0', 8, 27, 2016);
@@ -118,7 +118,6 @@ class core_analytics_course_testcase extends advanced_testcase {
         $courseman = new \core_analytics\course($this->course->id);
         $this->assertTrue($courseman->was_started());
         $this->assertTrue($courseman->is_finished());
-        $this->assertTrue($courseman->is_valid());
 
         // Valid start and ongoing course.
         $this->course->enddate = gmmktime('0', '0', '0', 8, 27, 2286);
@@ -126,7 +125,6 @@ class core_analytics_course_testcase extends advanced_testcase {
         $courseman = new \core_analytics\course($this->course->id);
         $this->assertTrue($courseman->was_started());
         $this->assertFalse($courseman->is_finished());
-        $this->assertFalse($courseman->is_valid());
     }
 
     /**
index 269a5c1..39c2b98 100644 (file)
@@ -24,6 +24,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
+global $CFG;
 require_once(__DIR__ . '/fixtures/test_indicator_max.php');
 require_once(__DIR__ . '/fixtures/test_indicator_min.php');
 require_once(__DIR__ . '/fixtures/test_indicator_fullname.php');
index 0bc46c9..0d6fc79 100644 (file)
@@ -61,7 +61,7 @@ class block_calendar_upcoming extends block_base {
             $courses = [$course->id => $course];
         }
         $calendar = new calendar_information(0, 0, 0, time());
-        $calendar->set_sources($course, $courses);
+        $calendar->set_sources($course, $courses, $this->page->category);
 
         list($data, $template) = calendar_get_view($calendar, 'upcoming_mini');
 
@@ -78,5 +78,3 @@ class block_calendar_upcoming extends block_base {
         return $this->content;
     }
 }
-
-
index fbec430..ffa3f2e 100644 (file)
Binary files a/calendar/amd/build/calendar.min.js and b/calendar/amd/build/calendar.min.js differ
index 57db248..ad5f0df 100644 (file)
Binary files a/calendar/amd/build/calendar_mini.min.js and b/calendar/amd/build/calendar_mini.min.js differ
index 9a1ec73..18416c3 100644 (file)
Binary files a/calendar/amd/build/calendar_view.min.js and b/calendar/amd/build/calendar_view.min.js differ
index 7ba0606..57b54a4 100644 (file)
Binary files a/calendar/amd/build/crud.min.js and b/calendar/amd/build/crud.min.js differ
index 5b5ef99..79035b5 100644 (file)
Binary files a/calendar/amd/build/modal_event_form.min.js and b/calendar/amd/build/modal_event_form.min.js differ
index 388f46e..2c8f751 100644 (file)
Binary files a/calendar/amd/build/repository.min.js and b/calendar/amd/build/repository.min.js differ
index 303217e..5bb7643 100644 (file)
Binary files a/calendar/amd/build/selectors.min.js and b/calendar/amd/build/selectors.min.js differ
index 5d76b1d..7313a59 100644 (file)
Binary files a/calendar/amd/build/view_manager.min.js and b/calendar/amd/build/view_manager.min.js differ
index 62ce49d..6102199 100644 (file)
@@ -63,77 +63,14 @@ define([
     var SELECTORS = {
         ROOT: "[data-region='calendar']",
         DAY: "[data-region='day']",
-        EVENT_ITEM: "[data-region='event-item']",
-        EVENT_LINK: "[data-action='view-event']",
         NEW_EVENT_BUTTON: "[data-action='new-event-button']",
         DAY_CONTENT: "[data-region='day-content']",
         LOADING_ICON: '.loading-icon',
         VIEW_DAY_LINK: "[data-action='view-day-link']",
         CALENDAR_MONTH_WRAPPER: ".calendarwrapper",
-        COURSE_SELECTOR: 'select[name="course"]',
         TODAY: '.today',
     };
 
-    /**
-     * Get the event type lang string.
-     *
-     * @param {String} eventType The event type.
-     * @return {promise} The lang string promise.
-     */
-    var getEventType = function(eventType) {
-        var lang = 'type' + eventType;
-        return Str.get_string(lang, 'core_calendar').then(function(langStr) {
-            return langStr;
-        });
-    };
-
-    /**
-     * Render the event summary modal.
-     *
-     * @param {Number} eventId The calendar event id.
-     */
-    var renderEventSummaryModal = function(eventId) {
-        // Calendar repository promise.
-        CalendarRepository.getEventById(eventId).then(function(getEventResponse) {
-            if (!getEventResponse.event) {
-                throw new Error('Error encountered while trying to fetch calendar event with ID: ' + eventId);
-            }
-            var eventData = getEventResponse.event;
-
-            return getEventType(eventData.eventtype).then(function(eventType) {
-                eventData.eventtype = eventType;
-                return eventData;
-            });
-        }).then(function(eventData) {
-            // Build the modal parameters from the event data.
-            var modalParams = {
-                title: eventData.name,
-                type: SummaryModal.TYPE,
-                body: Templates.render('core_calendar/event_summary_body', eventData),
-                templateContext: {
-                    canedit: eventData.canedit,
-                    candelete: eventData.candelete,
-                    isactionevent: eventData.isactionevent,
-                    url: eventData.url
-                }
-            };
-
-            // Create the modal.
-            return ModalFactory.create(modalParams);
-
-        }).done(function(modal) {
-            // Handle hidden event.
-            modal.getRoot().on(ModalEvents.hidden, function() {
-                // Destroy when hidden.
-                modal.destroy();
-            });
-
-            // Finally, render the modal!
-            modal.show();
-
-        }).fail(Notification.exception);
-    };
-
     /**
      * Handler for the drag and drop move event. Provides a loading indicator
      * while the request is sent to the server to update the event start date.
@@ -226,19 +163,7 @@ define([
             CalendarViewManager.reloadCurrentMonth(root);
         });
 
-        eventFormModalPromise
-        .then(function(modal) {
-            // When something within the calendar tells us the user wants
-            // to edit an event then show the event form modal.
-            body.on(CalendarEvents.editEvent, function(e, eventId) {
-                var calendarWrapper = root.find(CalendarSelectors.wrapper);
-                modal.setEventId(eventId);
-                modal.setContextId(calendarWrapper.data('contextId'));
-                modal.show();
-            });
-            return;
-        })
-        .fail(Notification.exception);
+        CalendarCrud.registerEditListeners(root, eventFormModalPromise);
     };
 
     /**
@@ -247,34 +172,13 @@ define([
      * @param {object} root The calendar root element
      */
     var registerEventListeners = function(root) {
-        // Bind click events to event links.
-        root.on('click', SELECTORS.EVENT_ITEM, function(e) {
-            e.preventDefault();
-            // We've handled the event so stop it from bubbling
-            // and causing the day click handler to fire.
-            e.stopPropagation();
-
-            var target = $(e.target);
-            var eventId = null;
-
-            var eventLink = target.closest(SELECTORS.EVENT_LINK);
-
-            if (eventLink.length) {
-                eventId = eventLink.data('eventId');
-            } else {
-                eventId = target.find(SELECTORS.EVENT_LINK).data('eventId');
-            }
-
-            renderEventSummaryModal(eventId);
-        });
-
-        root.on('change', SELECTORS.COURSE_SELECTOR, function() {
+        root.on('change', CalendarSelectors.elements.courseSelector, function() {
             var selectElement = $(this);
             var courseId = selectElement.val();
             CalendarViewManager.reloadCurrentMonth(root, courseId, null)
                 .then(function() {
                     // We need to get the selector again because the content has changed.
-                    return root.find(SELECTORS.COURSE_SELECTOR).val(courseId);
+                    return root.find(CalendarSelectors.elements.courseSelector).val(courseId);
                 })
                 .fail(Notification.exception);
         });
index 6853bf7..e8031ee 100644 (file)
@@ -69,10 +69,10 @@ function(
         } else {
             // The root has been removed.
             // Remove all events in the namespace.
-            body.on(CalendarEvents.created + namespace);
-            body.on(CalendarEvents.deleted + namespace);
-            body.on(CalendarEvents.updated + namespace);
-            body.on(CalendarEvents.eventMoved + namespace);
+            body.off(CalendarEvents.created + namespace);
+            body.off(CalendarEvents.deleted + namespace);
+            body.off(CalendarEvents.updated + namespace);
+            body.off(CalendarEvents.eventMoved + namespace);
         }
     };
 
@@ -82,6 +82,20 @@ function(
 
             daysWithEvent.toggleClass('calendar_event_' + data.type, !data.hidden);
         });
+
+        var namespace = '.' + root.attr('id');
+        $('body').on('change' + namespace, CalendarSelectors.elements.courseSelector, function() {
+            if (root.is(':visible')) {
+                var selectElement = $(this);
+                var courseId = selectElement.val();
+                var categoryId = null;
+
+                CalendarViewManager.reloadCurrentMonth(root, courseId, categoryId);
+            } else {
+                $('body').off('change' + namespace);
+            }
+        });
+
     };
 
     return {
index d4a2b3b..5b4e589 100644 (file)
@@ -51,7 +51,6 @@ define([
         var registerEventListeners = function(root, type) {
             var body = $('body');
 
-            CalendarCrud.registerEventFormModal(root);
             CalendarCrud.registerRemove(root);
 
             var reloadFunction = 'reloadCurrent' + type.charAt(0).toUpperCase() + type.slice(1);
@@ -74,6 +73,11 @@ define([
                         // We need to get the selector again because the content has changed.
                         return root.find(CalendarSelectors.courseSelector).val(courseId);
                     })
+                    .then(function() {
+                        window.history.pushState({}, '', '?view=upcoming&course=' + courseId);
+
+                        return;
+                    })
                     .fail(Notification.exception);
             });
 
@@ -85,6 +89,9 @@ define([
                     daysWithEvent.removeClass('hidden');
                 }
             });
+
+            var eventFormPromise = CalendarCrud.registerEventFormModal(root);
+            CalendarCrud.registerEditListeners(root, eventFormPromise);
         };
 
         return {
index d05afb7..b757df8 100644 (file)
@@ -198,6 +198,8 @@ function(
 
                 modal.setContextId(calendarWrapper.data('contextId'));
                 modal.show();
+
+                e.stopImmediatePropagation();
                 return;
             }).fail(Notification.exception);
         });
@@ -223,8 +225,36 @@ function(
         });
     }
 
+    /**
+     * Register the listeners required to edit the event.
+     *
+     * @param   {jQuery} root
+     * @param   {Promise} eventFormModalPromise
+     * @returns {Promise}
+     */
+    function registerEditListeners(root, eventFormModalPromise) {
+        eventFormModalPromise
+        .then(function(modal) {
+            // When something within the calendar tells us the user wants
+            // to edit an event then show the event form modal.
+            $('body').on(CalendarEvents.editEvent, function(e, eventId) {
+                var calendarWrapper = root.find(CalendarSelectors.wrapper);
+                modal.setEventId(eventId);
+                modal.setContextId(calendarWrapper.data('contextId'));
+                modal.show();
+
+                e.stopImmediatePropagation();
+            });
+            return;
+        })
+        .fail(Notification.exception);
+
+        return eventFormModalPromise;
+    }
+
     return {
         registerRemove: registerRemove,
+        registerEditListeners: registerEditListeners,
         registerEventFormModal: registerEventFormModal
     };
 });
index 78d6a15..2401ac5 100644 (file)
@@ -423,12 +423,15 @@ define([
                     this.reloadBodyContent(formData);
                     return;
                 } else {
+                    // Check whether this was a new event or not.
+                    // The hide function unsets the form data so grab this before the hide.
+                    var isExisting = this.hasEventId();
+
                     // No problemo! Our work here is done.
                     this.hide();
 
-                    // Trigger the appropriate calendar event so that the view can
-                    // be updated.
-                    if (this.hasEventId()) {
+                    // Trigger the appropriate calendar event so that the view can be updated.
+                    if (isExisting) {
                         $('body').trigger(CalendarEvents.updated, [response.event]);
                     } else {
                         $('body').trigger(CalendarEvents.created, [response.event]);
index 1115ba1..e522bb2 100644 (file)
@@ -164,13 +164,15 @@ define(['jquery', 'core/ajax'], function($, Ajax) {
      *
      * @method getCalendarUpcomingData
      * @param {Number} courseid The course id.
+     * @param {Number} categoryid The category id.
      * @return {promise} Resolved with the month view data.
      */
-    var getCalendarUpcomingData = function(courseid) {
+    var getCalendarUpcomingData = function(courseid, categoryid) {
         var request = {
             methodname: 'core_calendar_get_calendar_upcoming_view',
             args: {
                 courseid: courseid,
+                categoryid: categoryid,
             }
         };
 
index 8dd6da1..d8ead43 100644 (file)
@@ -46,10 +46,21 @@ define([], function() {
             create: '[data-action="new-event-button"]',
             edit: '[data-action="edit"]',
             remove: '[data-action="delete"]',
+            viewEvent: '[data-action="view-event"]',
+        },
+        elements: {
+            courseSelector: 'select[name="course"]',
         },
         today: '.today',
         day: '[data-region="day"]',
         wrapper: '.calendarwrapper',
         eventItem: '[data-type="event"]',
+        links: {
+            navLink: '.calendarwrapper .arrow_link',
+            eventLink: "[data-region='event-item']",
+        },
+        containers: {
+            loadingIcon: '[data-region="overlay-icon-container"]',
+        },
     };
 });
index 8b3d969..4c29f20 100644 (file)
 define([
     'jquery',
     'core/templates',
+    'core/str',
     'core/notification',
     'core_calendar/repository',
     'core_calendar/events',
     'core_calendar/selectors',
+    'core/modal_factory',
+    'core/modal_events',
+    'core_calendar/summary_modal',
 ], function(
     $,
     Templates,
+    Str,
     Notification,
     CalendarRepository,
     CalendarEvents,
-    CalendarSelectors
+    CalendarSelectors,
+    ModalFactory,
+    ModalEvents,
+    SummaryModal
 ) {
 
-        var SELECTORS = {
-            CALENDAR_NAV_LINK: ".calendarwrapper .arrow_link",
-            LOADING_ICON_CONTAINER: '[data-region="overlay-icon-container"]'
-        };
-
         /**
          * Register event listeners for the module.
          *
@@ -50,7 +53,38 @@ define([
         var registerEventListeners = function(root) {
             root = $(root);
 
-            root.on('click', SELECTORS.CALENDAR_NAV_LINK, function(e) {
+            // Bind click events to event links.
+            root.on('click', CalendarSelectors.links.eventLink, function(e) {
+                var target = $(e.target);
+                var eventId = null;
+
+                var eventLink;
+                if (target.is(CalendarSelectors.actions.viewEvent)) {
+                    eventLink = target;
+                } else {
+                    eventLink = target.closest(CalendarSelectors.actions.viewEvent);
+                }
+
+                if (eventLink.length) {
+                    eventId = eventLink.data('eventId');
+                } else {
+                    eventId = target.find(CalendarSelectors.actions.viewEvent).data('eventId');
+                }
+
+                if (eventId) {
+                    // A link was found. Show the modal.
+
+                    e.preventDefault();
+                    // We've handled the event so stop it from bubbling
+                    // and causing the day click handler to fire.
+                    e.stopPropagation();
+
+                    renderEventSummaryModal(eventId);
+                }
+            });
+
+
+            root.on('click', CalendarSelectors.links.navLink, function(e) {
                 var wrapper = root.find(CalendarSelectors.wrapper);
                 var view = wrapper.data('view');
                 var courseId = wrapper.data('courseid');
@@ -249,7 +283,7 @@ define([
          * @method startLoading
          */
         var startLoading = function(root) {
-            var loadingIconContainer = root.find(SELECTORS.LOADING_ICON_CONTAINER);
+            var loadingIconContainer = root.find(CalendarSelectors.containers.loadingIcon);
 
             loadingIconContainer.removeClass('hidden');
         };
@@ -261,7 +295,7 @@ define([
          * @method stopLoading
          */
         var stopLoading = function(root) {
-            var loadingIconContainer = root.find(SELECTORS.LOADING_ICON_CONTAINER);
+            var loadingIconContainer = root.find(CalendarSelectors.containers.loadingIcon);
 
             loadingIconContainer.addClass('hidden');
         };
@@ -271,23 +305,27 @@ define([
          *
          * @param {object} root The container element.
          * @param {Number} courseId The course id.
+         * @param {Number} categoryId The id of the category whose events are shown
          * @return {promise}
          */
-        var reloadCurrentUpcoming = function(root, courseId) {
+        var reloadCurrentUpcoming = function(root, courseId, categoryId) {
             startLoading(root);
 
             var target = root.find(CalendarSelectors.wrapper);
 
-            if (!courseId) {
+            if (typeof courseId === 'undefined') {
                 courseId = root.find(CalendarSelectors.wrapper).data('courseid');
             }
 
-            return CalendarRepository.getCalendarUpcomingData(courseId)
+            if (typeof categoryId === 'undefined') {
+                categoryId = root.find(CalendarSelectors.wrapper).data('categoryid');
+            }
+
+            return CalendarRepository.getCalendarUpcomingData(courseId, categoryId)
                 .then(function(context) {
                     return Templates.render(root.attr('data-template'), context);
                 })
                 .then(function(html, js) {
-                    window.history.replaceState(null, null, '?view=upcoming&course=' + courseId);
                     return Templates.replaceNode(target, html, js);
                 })
                 .then(function() {
@@ -300,6 +338,93 @@ define([
                 .fail(Notification.exception);
         };
 
+        /**
+         * Get the CSS class to apply for the given event type.
+         *
+         * @param {String} eventType The calendar event type
+         * @return {String}
+         */
+        var getEventTypeClassFromType = function(eventType) {
+            switch (eventType) {
+                case 'user':
+                    return 'calendar_event_user';
+                case 'site':
+                    return 'calendar_event_site';
+                case 'group':
+                    return 'calendar_event_group';
+                case 'category':
+                    return 'calendar_event_category';
+                case 'course':
+                    return 'calendar_event_course';
+                default:
+                    return 'calendar_event_course';
+            }
+        };
+
+        /**
+         * Render the event summary modal.
+         *
+         * @param {Number} eventId The calendar event id.
+         */
+        var renderEventSummaryModal = function(eventId) {
+            var typeClass = '';
+
+            // Calendar repository promise.
+            CalendarRepository.getEventById(eventId).then(function(getEventResponse) {
+                if (!getEventResponse.event) {
+                    throw new Error('Error encountered while trying to fetch calendar event with ID: ' + eventId);
+                }
+                var eventData = getEventResponse.event;
+                typeClass = getEventTypeClassFromType(eventData.eventtype);
+
+                return getEventType(eventData.eventtype).then(function(eventType) {
+                    eventData.eventtype = eventType;
+                    return eventData;
+                });
+            }).then(function(eventData) {
+                // Build the modal parameters from the event data.
+                var modalParams = {
+                    title: eventData.name,
+                    type: SummaryModal.TYPE,
+                    body: Templates.render('core_calendar/event_summary_body', eventData),
+                    templateContext: {
+                        canedit: eventData.canedit,
+                        candelete: eventData.candelete,
+                        headerclasses: typeClass,
+                        isactionevent: eventData.isactionevent,
+                        url: eventData.url
+                    }
+                };
+
+                // Create the modal.
+                return ModalFactory.create(modalParams);
+
+            }).done(function(modal) {
+                // Handle hidden event.
+                modal.getRoot().on(ModalEvents.hidden, function() {
+                    // Destroy when hidden.
+                    modal.destroy();
+                });
+
+                // Finally, render the modal!
+                modal.show();
+
+            }).fail(Notification.exception);
+        };
+
+        /**
+         * Get the event type lang string.
+         *
+         * @param {String} eventType The event type.
+         * @return {promise} The lang string promise.
+         */
+        var getEventType = function(eventType) {
+            var lang = 'type' + eventType;
+            return Str.get_string(lang, 'core_calendar').then(function(langStr) {
+                return langStr;
+            });
+        };
+
         return {
             init: function(root) {
                 registerEventListeners(root);
index 091dffc..5869c10 100644 (file)
@@ -74,7 +74,7 @@ class calendar_day_exporter extends exporter {
             ],
             'defaulteventcontext' => [
                 'type' => PARAM_INT,
-                'default' => null,
+                'default' => 0,
             ],
             'filter_selector' => [
                 'type' => PARAM_RAW,
@@ -238,7 +238,7 @@ class calendar_day_exporter extends exporter {
      * @return string The html code for the course filter selector.
      */
     protected function get_course_filter_selector(renderer_base $output) {
-        $langstr = get_string('upcomingeventsfor', 'calendar');
+        $langstr = get_string('dayviewfor', 'calendar');
         return $output->course_filter_selector($this->url, $langstr, $this->calendar->course->id);
     }
 
index ab35779..42fe7b9 100644 (file)
@@ -74,7 +74,7 @@ class calendar_upcoming_exporter extends exporter {
             ],
             'defaulteventcontext' => [
                 'type' => PARAM_INT,
-                'default' => null,
+                'default' => 0,
             ],
             'filter_selector' => [
                 'type' => PARAM_RAW,
@@ -82,6 +82,11 @@ class calendar_upcoming_exporter extends exporter {
             'courseid' => [
                 'type' => PARAM_INT,
             ],
+            'categoryid' => [
+                'type' => PARAM_INT,
+                'optional' => true,
+                'default' => 0,
+            ],
         ];
     }
 
@@ -127,6 +132,11 @@ class calendar_upcoming_exporter extends exporter {
         }
         $return['filter_selector'] = $this->get_course_filter_selector($output);
         $return['courseid'] = $this->calendar->courseid;
+
+        if ($this->calendar->categoryid) {
+            $return['categoryid'] = $this->calendar->categoryid;
+        }
+
         return $return;
     }
 
index 6600b23..3920107 100644 (file)
@@ -147,20 +147,10 @@ class day_exporter extends exporter {
             'navigation' => [
                 'type' => PARAM_RAW,
             ],
-            'popovertitle' => [
-                'type' => PARAM_RAW,
-                'default' => '',
-            ],
             'haslastdayofevent' => [
                 'type' => PARAM_BOOL,
                 'default' => false,
             ],
-            'filter_selector' => [
-                'type' => PARAM_RAW,
-            ],
-            'new_event_button' => [
-                'type' => PARAM_RAW,
-            ],
         ];
     }
 
@@ -190,8 +180,6 @@ class day_exporter extends exporter {
             'previousperiod' => $this->get_previous_day_timestamp($daytimestamp),
             'nextperiod' => $this->get_next_day_timestamp($daytimestamp),
             'navigation' => $this->get_navigation(),
-            'filter_selector' => $this->get_course_filter_selector($output),
-            'new_event_button' => $this->get_new_event_button(),
             'viewdaylink' => $this->url->out(false),
         ];
 
@@ -277,82 +265,4 @@ class day_exporter extends exporter {
             'time' => $this->calendar->time,
         ]);
     }
-
-    /**
-     * Get the course filter selector.
-     *
-     * This is a temporary solution, this code will be removed by MDL-60096.
-     *
-     * @param renderer_base $output
-     * @return string The html code for the course filter selector.
-     */
-    protected function get_course_filter_selector(renderer_base $output) {
-        global $CFG;
-        // TODO remove this code on MDL-60096.
-        if (!isloggedin() or isguestuser()) {
-            return '';
-        }
-
-        if (has_capability('moodle/calendar:manageentries', \context_system::instance()) && !empty($CFG->calendar_adminseesall)) {
-            $courses = get_courses('all', 'c.shortname', 'c.id, c.shortname');
-        } else {
-            $courses = enrol_get_my_courses();
-        }
-
-        unset($courses[SITEID]);
-
-        $courseoptions = array();
-        $courseoptions[SITEID] = get_string('fulllistofcourses');
-        foreach ($courses as $course) {
-            $coursecontext = \context_course::instance($course->id);
-            $courseoptions[$course->id] = format_string($course->shortname, true, array('context' => $coursecontext));
-        }
-
-        if ($this->calendar->courseid !== SITEID) {
-            $selected = $this->calendar->courseid;
-        } else {
-            $selected = '';
-        }
-
-        $courseurl = new moodle_url($this->url);
-        $courseurl->remove_params('course');
-        $select = new \single_select($courseurl, 'courseselect', $courseoptions, $selected, null);
-        $select->class = 'm-r-1';
-        $label = get_string('dayviewfor', 'calendar');
-        if ($label !== null) {
-            $select->set_label($label);
-        } else {
-            $select->set_label(get_string('listofcourses'), array('class' => 'accesshide'));
-        }
-
-        return $output->render($select);
-    }
-
-    /**
-     * Get the course filter selector.
-     *
-     * This is a temporary solution, this code will be removed by MDL-60096.
-     *
-     * @return string The html code for the course filter selector.
-     */
-    protected function get_new_event_button() {
-        // TODO remove this code on MDL-60096.
-        $output = \html_writer::start_tag('div', array('class' => 'buttons'));
-        $output .= \html_writer::start_tag('form',
-                array('action' => CALENDAR_URL . 'event.php', 'method' => 'get'));
-        $output .= \html_writer::start_tag('div');
-        $output .= \html_writer::empty_tag('input',
-                array('type' => 'hidden', 'name' => 'action', 'value' => 'new'));
-        $output .= \html_writer::empty_tag('input',
-                array('type' => 'hidden', 'name' => 'course', 'value' => $this->calendar->courseid));
-        $output .= \html_writer::empty_tag('input',
-                array('type' => 'hidden', 'name' => 'time', 'value' => $this->calendar->time));
-        $attributes = array('type' => 'submit', 'value' => get_string('newevent', 'calendar'),
-            'class' => 'btn btn-secondary');
-        $output .= \html_writer::empty_tag('input', $attributes);
-        $output .= \html_writer::end_tag('div');
-        $output .= \html_writer::end_tag('form');
-        $output .= \html_writer::end_tag('div');
-        return $output;
-    }
 }
index 9ca253e..12b910e 100644 (file)
@@ -171,7 +171,7 @@ class month_exporter extends exporter {
             ],
             'defaulteventcontext' => [
                 'type' => PARAM_INT,
-                'default' => null,
+                'default' => 0,
             ],
         ];
     }
index 5d87d18..951b84e 100644 (file)
@@ -67,28 +67,10 @@ class week_day_exporter extends day_exporter {
     protected static function define_other_properties() {
         $return = parent::define_other_properties();
         $return = array_merge($return, [
-            'timestamp' => [
-                'type' => PARAM_INT,
-            ],
-            'neweventtimestamp' => [
-                'type' => PARAM_INT,
-            ],
-            'viewdaylink' => [
-                'type' => PARAM_URL,
-                'optional' => true,
-            ],
-            'calendareventtypes' => [
-                'type' => PARAM_RAW,
-                'multiple' => true,
-            ],
             'popovertitle' => [
                 'type' => PARAM_RAW,
                 'default' => '',
             ],
-            'haslastdayofevent' => [
-                'type' => PARAM_BOOL,
-                'default' => false,
-            ],
         ]);
 
         return $return;
index 0beb6e0..0c7a824 100644 (file)
@@ -23,6 +23,8 @@
  */
 namespace core_calendar\local\event\forms;
 
+use context_system;
+
 defined('MOODLE_INTERNAL') || die();
 
 require_once($CFG->dirroot.'/lib/formslib.php');
@@ -48,7 +50,8 @@ class create extends \moodleform {
             'context' => $context,
             'maxfiles' => EDITOR_UNLIMITED_FILES,
             'maxbytes' => $CFG->maxbytes,
-            'noclean' => true
+            'noclean' => true,
+            'autosave' => false
         ];
     }
 
@@ -245,7 +248,8 @@ class create extends \moodleform {
         }
 
         if (isset($eventtypes['course'])) {
-            $mform->addElement('course', 'courseid', get_string('course'), ['limittoenrolled' => true]);
+            $limit = !has_capability('moodle/calendar:manageentries', context_system::instance());
+            $mform->addElement('course', 'courseid', get_string('course'), ['limittoenrolled' => $limit]);
             $mform->hideIf('courseid', 'eventtype', 'noteq', 'course');
         }
 
index 94b0a26..a2944b4 100644 (file)
@@ -45,13 +45,15 @@ class update extends create {
         $mform->addElement('hidden', 'repeatid');
         $mform->setType('repeatid', PARAM_INT);
 
-        $group = [];
-        $group[] = $mform->createElement('radio', 'repeateditall', null, get_string('repeateditall', 'calendar',
-                $event->eventrepeats), 1);
-        $group[] = $mform->createElement('radio', 'repeateditall', null, get_string('repeateditthis', 'calendar'), 0);
-        $mform->addGroup($group, 'repeatgroup', get_string('repeatedevents', 'calendar'), '<br />', false);
+        if (!empty($event->repeatid)) {
+            $group = [];
+            $group[] = $mform->createElement('radio', 'repeateditall', null, get_string('repeateditall', 'calendar',
+                    $event->eventrepeats), 1);
+            $group[] = $mform->createElement('radio', 'repeateditall', null, get_string('repeateditthis', 'calendar'), 0);
+            $mform->addGroup($group, 'repeatgroup', get_string('repeatedevents', 'calendar'), '<br />', false);
 
-        $mform->setDefault('repeateditall', 1);
-        $mform->setAdvanced('repeatgroup');
+            $mform->setDefault('repeateditall', 1);
+            $mform->setAdvanced('repeatgroup');
+        }
     }
 }
index 0c82f8a..0937d4c 100644 (file)
@@ -1154,12 +1154,26 @@ class core_calendar_external extends external_api {
         // Parameter validation.
         self::validate_parameters(self::get_calendar_upcoming_view_parameters(), [
             'courseid' => $courseid,
+            'categoryid' => $categoryid,
         ]);
+        $PAGE->set_url('/calendar/');
 
+        $category = null;
         if ($courseid != SITEID && !empty($courseid)) {
             // Course ID must be valid and existing.
             $course = $DB->get_record('course', array('id' => $courseid), '*', MUST_EXIST);
             $courses = [$course->id => $course];
+        } else if (!empty($categoryid)) {
+            $course = get_site();
+            $courses = calendar_get_default_courses();
+
+            $category = \coursecat::get($categoryid);
+            $ids += $category->get_parents();
+            $categories = \coursecat::get_many($ids);
+            $courses = array_filter($courses, function($course) use ($categories) {
+                return array_search($course->category, $categories) !== false;
+            });
+            $category = $category->get_db_record();
         } else {
             $course = get_site();
             $courses = calendar_get_default_courses();
@@ -1169,7 +1183,7 @@ class core_calendar_external extends external_api {
         self::validate_context($context);
 
         $calendar = new calendar_information(0, 0, 0, time());
-        $calendar->set_sources($course, $courses);
+        $calendar->set_sources($course, $courses, $category);
 
         list($data, $template) = calendar_get_view($calendar, 'upcoming');
 
@@ -1185,6 +1199,7 @@ class core_calendar_external extends external_api {
         return new external_function_parameters(
             [
                 'courseid' => new external_value(PARAM_INT, 'Course being viewed', VALUE_DEFAULT, SITEID, NULL_ALLOWED),
+                'categoryid' => new external_value(PARAM_INT, 'Category being viewed', VALUE_DEFAULT, null, NULL_ALLOWED),
             ]
         );
     }
index d858b9f..b4b0c2c 100644 (file)
@@ -2137,31 +2137,41 @@ function calendar_delete_event_allowed($event) {
  * Returns the default courses to display on the calendar when there isn't a specific
  * course to display.
  *
+ * @param int $courseid (optional) If passed, an additional course can be returned for admins (the current course).
+ * @param string $fields Comma separated list of course fields to return.
+ * @param bool $canmanage If true, this will return the list of courses the current user can create events in, rather
+ *                        than the list of courses they see events from (an admin can always add events in a course
+ *                        calendar, even if they are not enrolled in the course).
  * @return array $courses Array of courses to display
  */
-function calendar_get_default_courses() {
+function calendar_get_default_courses($courseid = null, $fields = '*', $canmanage=false) {
     global $CFG, $DB;
 
     if (!isloggedin()) {
         return array();
     }
 
-    if (!empty($CFG->calendar_adminseesall) && has_capability('moodle/calendar:manageentries', \context_system::instance())) {
-        $select = ', ' . \context_helper::get_preload_record_columns_sql('ctx');
-        $join = "LEFT JOIN {context} ctx ON (ctx.instanceid = c.id AND ctx.contextlevel = :contextlevel)";
-        $sql = "SELECT c.* $select
-                      FROM {course} c
-                      $join
-                     WHERE EXISTS (SELECT 1 FROM {event} e WHERE e.courseid = c.id)
-                  ";
-        $courses = $DB->get_records_sql($sql, array('contextlevel' => CONTEXT_COURSE), 0, 20);
-        foreach ($courses as $course) {
-            \context_helper::preload_from_record($course);
-        }
-        return $courses;
+    if (has_capability('moodle/calendar:manageentries', context_system::instance()) &&
+            (!empty($CFG->calendar_adminseesall) || $canmanage)) {
+
+        // Add a c. prefix to every field as expected by get_courses function.
+        $fieldlist = explode(',', $fields);
+
+        $prefixedfields = array_map(function($value) {
+            return 'c.' . trim($value);
+        }, $fieldlist);
+        $courses = get_courses('all', 'c.shortname', implode(',', $prefixedfields));
+    } else {
+        $courses = enrol_get_my_courses($fields);
     }
 
-    $courses = enrol_get_my_courses();
+    if ($courseid && $courseid != SITEID) {
+        if (empty($courses[$courseid]) && has_capability('moodle/calendar:manageentries', context_system::instance())) {
+            // Allow a site admin to see calendars from courses he is not enrolled in.
+            // This will come from $COURSE.
+            $courses[$courseid] = get_course($courseid);
+        }
+    }
 
     return $courses;
 }
@@ -2404,6 +2414,8 @@ function calendar_get_all_allowed_types() {
 
     $types = [];
 
+    $allowed = new stdClass();
+
     calendar_get_allowed_types($allowed);
 
     if ($allowed->user) {
@@ -2421,7 +2433,8 @@ function calendar_get_all_allowed_types() {
     // This function warms the context cache for the course so the calls
     // to load the course context in calendar_get_allowed_types don't result
     // in additional DB queries.
-    $courses = enrol_get_users_courses($USER->id, true);
+    $courses = calendar_get_default_courses(null, '*', true);
+
     // We want to pre-fetch all of the groups for each course in a single
     // query to avoid calendar_get_allowed_types from hitting the DB for
     // each separate course.
@@ -3207,7 +3220,7 @@ function calendar_get_view(\calendar_information $calendar, $view, $includenavig
         if ($view == "upcoming") {
             $template = 'core_calendar/calendar_upcoming';
         } else if ($view == "upcoming_mini") {
-            $template = 'core_calendar/upcoming_mini';
+            $template = 'core_calendar/calendar_upcoming_mini';
         }
     }
 
index 5688e27..7193a97 100644 (file)
@@ -64,15 +64,17 @@ class core_calendar_renderer extends plugin_renderer_base {
         $time = $calendartype->timestamp_to_date_array($calendar->time);
 
         $current = $calendar->time;
+        $prevmonthyear = $calendartype->get_prev_month($time['year'], $time['mon']);
         $prev = $calendartype->convert_to_timestamp(
-                $time['year'],
-                $time['mon'] - 1,
-                $time['mday']
+                $prevmonthyear[1],
+                $prevmonthyear[0],
+                1
             );
+        $nextmonthyear = $calendartype->get_next_month($time['year'], $time['mon']);
         $next = $calendartype->convert_to_timestamp(
-                $time['year'],
-                $time['mon'] + 1,
-                $time['mday']
+                $nextmonthyear[1],
+                $nextmonthyear[0],
+                1
             );
 
         $content = '';
@@ -247,11 +249,7 @@ class core_calendar_renderer extends plugin_renderer_base {
             return '';
         }
 
-        if (has_capability('moodle/calendar:manageentries', context_system::instance()) && !empty($CFG->calendar_adminseesall)) {
-            $courses = get_courses('all', 'c.shortname','c.id,c.shortname');
-        } else {
-            $courses = enrol_get_my_courses();
-        }
+        $courses = calendar_get_default_courses($courseid, 'id, shortname');
 
         unset($courses[SITEID]);
 
diff --git a/calendar/templates/calendar_upcoming_mini.mustache b/calendar/templates/calendar_upcoming_mini.mustache
new file mode 100644 (file)
index 0000000..8f276be
--- /dev/null
@@ -0,0 +1,41 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_calendar/calendar_upcoming_block
+
+    Calendar upcoming view.
+
+    The purpose of this template is to render the calendar upcoming view.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Example context (json):
+    {
+    }
+}}
+<div id="calendar-upcoming-block-{{uniqid}}" data-template="core_calendar/upcoming_mini">
+    {{> core_calendar/upcoming_mini}}
+</div>
+{{#js}}
+require(['jquery', 'core_calendar/calendar_view'], function($, CalendarView) {
+    CalendarView.init($("#calendar-upcoming-block-{{uniqid}}"), 'upcoming');
+});
+{{/js}}
index 2a67233..0770cc5 100644 (file)
     }} data-action-event="{{isactionevent}}"{{!
     }} data-edit-url="{{editurl}}"{{!
     }}>
-    <h4>{{#str}} when, core_calendar {{/str}}</h4>
-    {{#userdate}} {{timestart}}, {{#str}} strftimerecentfull {{/str}} {{/userdate}}
-    <br>
-    {{#description}}
-        <h4>{{#str}} description {{/str}}</h4>
-        {{{description}}}
-    {{/description}}
-    <h4>{{#str}} eventtype, core_calendar {{/str}}</h4>
-    {{eventtype}}
-    {{#iscategoryevent}}
-        <div>{{{category.nestedname}}}</div>
-    {{/iscategoryevent}}
-    {{#iscourseevent}}
-        <div><a href="{{url}}">{{course.fullname}}</a></div>
-    {{/iscourseevent}}
-    {{> core_calendar/event_subscription}}
-    {{#groupname}}
-        <div><a href="{{url}}">{{{course.fullname}}}</a></div>
-        <div>{{{groupname}}}</div>
-    {{/groupname}}
+    <div class="container-fluid">
+        <div class="row">
+            <div class="col-xs-1">{{#pix}} i/calendareventtime, core, {{#str}} when, core_calendar {{/str}} {{/pix}}</div>
+            <div class="col-xs-11">{{#userdate}} {{timestart}}, {{#str}} strftimerecentfull {{/str}} {{/userdate}}</div>
+        </div>
+        <div class="row m-t-1">
+            <div class="col-xs-1">{{#pix}} i/calendar, core, {{#str}} eventtype, core_calendar {{/str}} {{/pix}}</div>
+            <div class="col-xs-11">{{eventtype}}</div>
+        </div>
+        {{#description}}
+        <div class="row m-t-1">
+            <div class="col-xs-1">{{#pix}} i/calendareventdescription, core, {{#str}} description {{/str}} {{/pix}}</div>
+            <div class="col-xs-11">{{{.}}}</div>
+        </div>
+        {{/description}}
+        {{#iscategoryevent}}
+        <div class="row m-t-1">
+            <div class="col-xs-1">{{#pix}} i/categoryevent, core, {{#str}} category {{/str}} {{/pix}}</div>
+            <div class="col-xs-11">{{{category.nestedname}}}</div>
+        </div>
+        {{/iscategoryevent}}
+        {{#iscourseevent}}
+        <div class="row m-t-1">
+            <div class="col-xs-1">{{#pix}} i/courseevent, core, {{#str}} course {{/str}} {{/pix}}</div>
+            <div class="col-xs-11"><a href="{{url}}">{{{course.fullname}}}</a></div>
+        </div>
+        {{/iscourseevent}}
+        {{#groupname}}
+        <div class="row m-t-1">
+            <div class="col-xs-1">{{#pix}} i/courseevent, core, {{#str}} course {{/str}} {{/pix}}</div>
+            <div class="col-xs-11"><a href="{{url}}">{{{course.fullname}}}</a></div>
+        </div>
+        <div class="row m-t-1">
+            <div class="col-xs-1">{{#pix}} i/groupevent, core, {{#str}} group {{/str}} {{/pix}}</div>
+            <div class="col-xs-11">{{{groupname}}}</div>
+        </div>
+        {{/groupname}}
+        {{#subscription}}
+            {{#displayeventsource}}
+            <div class="row m-t-1">
+                <div class="col-xs-1">{{#pix}} i/rss, core, {{#str}} eventsource, core_calendar {{/str}} {{/pix}}</div>
+                <div class="col-xs-11">
+                    {{#url}}
+                        <a href="{{url}}">{{#str}}subscriptionsource, core_calendar, {{name}}{{/str}}</a>
+                    {{/url}}
+                    {{^url}}
+                        <p>{{#str}}subscriptionsource, core_calendar, {{name}}{{/str}}</p>
+                    {{/url}}
+                </div>
+            </div>
+            {{/displayeventsource}}
+        {{/subscription}}
+    </div>
 </div>
index 3ebebaa..0cb0d8e 100644 (file)
@@ -70,7 +70,7 @@
                         data-drop-zone="month-view-day"
                         data-region="day"
                         data-new-event-timestamp="{{neweventtimestamp}}">
-                        <div class="hidden-sm-down text-xs-center">
+                        <div class="hidden-sm-down hidden-phone text-xs-center">
                             {{#hasevents}}
                                 <a data-action="view-day-link" href="{{viewdaylink}}" class="day" title="{{viewdaylinktitle}}">{{mday}}</a>
                             {{/hasevents}}
                                                         &nbsp;
                                                     </span>
                                                     {{> core_calendar/event_icon}}
-                                                    {{name}}
+                                                    <span class="eventname">{{name}}</span>
                                                 </a>
                                             </li>
                                         {{/underway}}
                                 </div>
                             {{/hasevents}}
                         </div>
-                        <div class="hidden-md-up hidden-desktop">
+                        <div class="hidden-md-up hidden-desktop hidden-tablet">
                             {{#hasevents}}
-                                <a href="{{viewdaylink}}" class="day" title="{{viewdaylinktitle}}">{{mday}}</a>
+                                <a data-action="view-day-link" href="{{viewdaylink}}" class="day" title="{{viewdaylinktitle}}">{{mday}}</a>
                             {{/hasevents}}
                             {{^hasevents}}
                                 <div data-region="day-content">
index 8d02953..1be33f7 100644 (file)
     {
     }
 }}
-<div class="card-text content">
+<div class="card-text content calendarwrapper"{{!
+    }} id="month-upcoming-mini-{{uniqid}}"{{!
+    }} data-context-id="{{defaulteventcontext}}"{{!
+    }} data-courseid="{{courseid}}"{{!
+    }} data-categoryid="{{categoryid}}"{{!
+}}>
+    {{> core/overlay_loading}}
     {{#events}}
-        <div class="event">
+        <div{{!
+            }} class="event"{{!
+            }} data-eventtype-{{calendareventtype}}="1"{{!
+            }} data-region="event-item"{{!
+        }}>
             <span>{{#icon}}{{#pix}} {{key}}, {{component}}, {{alttext}} {{/pix}}{{/icon}}</span>
-            <a href="{{viewurl}}">{{{name}}}</a>
+            <a{{!
+                }} data-type="event"{{!
+                }} data-action="view-event"{{!
+                }} data-event-id="{{id}}"{{!
+                }} href="{{viewurl}}"{{!
+            }}>{{{name}}}</a>
             <div class="date">{{{formattedtime}}}</div>
+            <hr>
         </div>
-        <hr>
     {{/events}}
 </div>
+{{#js}}
+require([
+    'jquery',
+    'core_calendar/selectors',
+    'core_calendar/events',
+], function(
+    $,
+    CalendarSelectors,
+    CalendarEvents
+) {
+    var root = $('#month-upcoming-mini-{{uniqid}}');
+
+    $('body').on(CalendarEvents.filterChanged, function(e, data) {
+        M.util.js_pending("month-upcoming-mini-{{uniqid}}-filterChanged");
+
+        // A filter value has been changed.
+        // Find all matching cells in the popover data, and hide them.
+        var target = $("#month-upcoming-mini-{{uniqid}}").find(CalendarSelectors.eventType[data.type]);
+
+        var transitionPromise = $.Deferred();
+        if (data.hidden) {
+            transitionPromise.then(function() {
+                return target.slideUp('fast').promise();
+            });
+        } else {
+            transitionPromise.then(function() {
+                return target.slideDown('fast').promise();
+            });
+        }
+
+        transitionPromise.then(function() {
+            M.util.js_complete("month-upcoming-mini-{{uniqid}}-filterChanged");
+
+            return;
+        });
+
+        transitionPromise.resolve();
+    });
+});
+{{/js}}
index 1bba607..ce2f1ef 100644 (file)
@@ -672,4 +672,60 @@ class core_calendar_lib_testcase extends advanced_testcase {
         $this->assertEquals($group1->id, $typegroups[0]->id);
         $this->assertEquals($group2->id, $typegroups[1]->id);
     }
+
+    public function test_calendar_get_default_courses() {
+        global $USER, $CFG;
+
+        $this->resetAfterTest(true);
+
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $course1 = $generator->create_course();
+        $course2 = $generator->create_course();
+        $course3 = $generator->create_course();
+        $context = context_course::instance($course1->id);
+
+        $this->setAdminUser();
+        $admin = clone $USER;
+
+        $teacher = $generator->create_user();
+        $generator->enrol_user($teacher->id, $course1->id, 'teacher');
+        $generator->enrol_user($admin->id, $course1->id, 'teacher');
+
+        $CFG->calendar_adminseesall = false;
+
+        $courses = calendar_get_default_courses();
+        // Only enrolled in one course.
+        $this->assertCount(1, $courses);
+        $courses = calendar_get_default_courses($course2->id);
+        // Enrolled course + current course.
+        $this->assertCount(2, $courses);
+        $CFG->calendar_adminseesall = true;
+        $courses = calendar_get_default_courses();
+        // All courses + SITE.
+        $this->assertCount(4, $courses);
+        $courses = calendar_get_default_courses($course2->id);
+        // All courses + SITE.
+        $this->assertCount(4, $courses);
+
+        $this->setUser($teacher);
+
+        $CFG->calendar_adminseesall = false;
+
+        $courses = calendar_get_default_courses();
+        // Only enrolled in one course.
+        $this->assertCount(1, $courses);
+        $courses = calendar_get_default_courses($course2->id);
+        // Enrolled course only (ignore current).
+        $this->assertCount(1, $courses);
+        // This setting should not affect teachers.
+        $CFG->calendar_adminseesall = true;
+        $courses = calendar_get_default_courses();
+        // Only enrolled in one course.
+        $this->assertCount(1, $courses);
+        $courses = calendar_get_default_courses($course2->id);
+        // Enrolled course only (ignore current).
+        $this->assertCount(1, $courses);
+
+    }
 }
index 3bc469b..f8040c5 100644 (file)
@@ -59,7 +59,10 @@ class api {
         if (is_object($instanceorid)) {
             $instance = $instanceorid;
         } else {
-            $instance = $DB->get_record($modulename, array('id' => $instanceorid), '*', MUST_EXIST);
+            $instance = $DB->get_record($modulename, array('id' => $instanceorid), '*', IGNORE_MISSING);
+        }
+        if (!$instance) {
+            return false;
         }
         $course = get_course($instance->course);
 
index 489d0d0..af24c07 100644 (file)
@@ -72,6 +72,13 @@ class core_completion_api_testcase extends advanced_testcase {
         $this->assertEquals(\core_completion\api::COMPLETION_EVENT_TYPE_DATE_COMPLETION_EXPECTED, $event->eventtype);
         $this->assertEquals($time, $event->timestart);
         $this->assertEquals($time, $event->timesort);
+
+        require_once($CFG->dirroot . '/course/lib.php');
+        // Delete the module.
+        course_delete_module($assign->cmid);
+
+        // Check we don't get a failure when called on a deleted module.
+        \core_completion\api::update_completion_date_event($assign->cmid, 'assign', null, $time);
     }
 
     public function test_update_completion_date_event_update() {
index 73e4b80..d3aba3b 100644 (file)
@@ -268,8 +268,9 @@ class core_course_external extends external_api {
 
                         if (!empty($cm->showdescription) or $cm->modname == 'label') {
                             // We want to use the external format. However from reading get_formatted_content(), $cm->content format is always FORMAT_HTML.
+                            $options = array('noclean' => true);
                             list($module['description'], $descriptionformat) = external_format_text($cm->content,
-                                FORMAT_HTML, $modcontext->id, $cm->modname, 'intro', $cm->id);
+                                FORMAT_HTML, $modcontext->id, $cm->modname, 'intro', $cm->id, $options);
                         }
 
                         //url of the module
index c688791..d0ad4f0 100644 (file)
@@ -1404,7 +1404,9 @@ function course_module_update_calendar_events($modulename, $instance = null, $cm
         if (!isset($cm)) {
             $cm = get_coursemodule_from_instance($modulename, $instance->id, $instance->course);
         }
-        course_module_calendar_event_update_process($instance, $cm);
+        if (!empty($cm)) {
+            course_module_calendar_event_update_process($instance, $cm);
+        }
         return true;
     }
     return false;
@@ -1433,8 +1435,9 @@ function course_module_bulk_update_calendar_events($modulename, $courseid = 0) {
     }
 
     foreach ($instances as $instance) {
-        $cm = get_coursemodule_from_instance($modulename, $instance->id, $instance->course);
-        course_module_calendar_event_update_process($instance, $cm);
+        if ($cm = get_coursemodule_from_instance($modulename, $instance->id, $instance->course)) {
+            course_module_calendar_event_update_process($instance, $cm);
+        }
     }
     return true;
 }
index b9222d7..1749a13 100644 (file)
 
 defined('MOODLE_INTERNAL') || die();
 
+global $CFG;
+require_once(__DIR__ . '/../../lib/completionlib.php');
 require_once(__DIR__ . '/../../completion/criteria/completion_criteria_self.php');
 
-
 /**
  * Unit tests for core_course indicators.
  *
index ef222ea..4dd40b1 100644 (file)
@@ -169,5 +169,33 @@ MathJax.Hub.Config({
         upgrade_plugin_savepoint(true, 2017101200, 'filter', 'mathjaxloader');
     }
 
+    if ($oldversion < 2017102000) {
+        // Re-add Accessible.js (we should not have removed it).
+        $previousdefault = '
+MathJax.Hub.Config({
+    config: ["default.js", "MMLorHTML.js", "Safe.js"],
+    errorSettings: { message: ["!"] },
+    skipStartupTypeset: true,
+    messageStyle: "none"
+});
+';
+        $newdefault = '
+MathJax.Hub.Config({
+    config: ["Accessible.js", "Safe.js"],
+    errorSettings: { message: ["!"] },
+    skipStartupTypeset: true,
+    messageStyle: "none"
+});
+';
+
+        $mathjaxconfig = get_config('filter_mathjaxloader', 'mathjaxconfig');
+
+        if (empty($mathjaxconfig) || filter_mathjaxloader_upgrade_mathjaxconfig_equal($mathjaxconfig, $previousdefault)) {
+            set_config('mathjaxconfig', $newdefault, 'filter_mathjaxloader');
+        }
+
+        upgrade_plugin_savepoint(true, 2017102000, 'filter', 'mathjaxloader');
+    }
+
     return true;
 }
index e7e666f..055a986 100644 (file)
@@ -18,9 +18,3 @@ Upgrading the default MathJax version
 3. Check and eventually update the list of language mappings in filter.php.
    Also see the unit test for the language mappings.
 
-Changes
--------
-
-* The MathJax 2.7.2 seems to have a bug causing the accessibility extensions
-  fail in web apps using RequireJS (such as Moodle). We had to stop using the
-  Accessible.js config for that reason. See MDL-60209 for details.
index 783282b..a911788 100644 (file)
@@ -45,7 +45,7 @@ if ($ADMIN->fulltree) {
 
     $default = '
 MathJax.Hub.Config({
-    config: ["default.js", "MMLorHTML.js", "Safe.js"],
+    config: ["Accessible.js", "Safe.js"],
     errorSettings: { message: ["!"] },
     skipStartupTypeset: true,
     messageStyle: "none"
index d8213c8..23907d7 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version  = 2017101200;
+$plugin->version  = 2017102000;
 $plugin->requires = 2017050500;  // Requires this Moodle version.
 $plugin->component= 'filter_mathjaxloader';
index 46fd52a..d7c8294 100644 (file)
@@ -489,7 +489,7 @@ class grading_manager {
      * Returns the given method's controller in the gradable area
      *
      * @param string $method the method name, eg 'rubric' (must be available)
-     * @return grading_controller
+     * @return gradingform_controller
      */
     public function get_controller($method) {
         global $CFG, $DB;
@@ -534,7 +534,7 @@ class grading_manager {
     /**
      * Returns the controller for the active method if it is available
      *
-     * @return null|grading_controller
+     * @return null|gradingform_controller
      */
     public function get_active_controller() {
         if ($gradingmethod = $this->get_active_method()) {
diff --git a/group/classes/output/group_details.php b/group/classes/output/group_details.php
new file mode 100644 (file)
index 0000000..11d4a62
--- /dev/null
@@ -0,0 +1,93 @@
+<?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/>.
+
+/**
+ * Group details page.
+ *
+ * @package    core_group
+ * @copyright  2017 Adrian Greeve <adrian@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_group\output;
+defined('MOODLE_INTERNAL') || die();
+
+use renderable;
+use renderer_base;
+use stdClass;
+use templatable;
+use context_course;
+use moodle_url;
+
+/**
+ * Group details page class.
+ *
+ * @package    core_group
+ * @copyright  2017 Adrian Greeve <adrian@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class group_details implements renderable, templatable {
+
+    /** @var stdClass $group An object with the group information. */
+    protected $group;
+
+    /**
+     * group_details constructor.
+     *
+     * @param  int $groupid Group ID to show details of.
+     */
+    public function __construct($groupid) {
+        $this->group = groups_get_group($groupid, '*', MUST_EXIST);
+    }
+
+    /**
+     * Export the data.
+     *
+     * @param renderer_base $output
+     * @return stdClass
+     */
+    public function export_for_template(renderer_base $output) {
+
+        if (!empty($this->group->description) || (!empty($this->group->picture) && empty($this->group->hidepicture))) {
+            $context = context_course::instance($this->group->courseid);
+            $description = file_rewrite_pluginfile_urls($this->group->description,
+                                                        'pluginfile.php',
+                                                        $context->id,
+                                                        'group',
+                                                        'description',
+                                                        $this->group->id);
+
+            $descriptionformat = $this->group->descriptionformat ?? FORMAT_MOODLE;
+            $options = [
+                'overflowdiv' => true,
+                'context'     => $context
+            ];
+
+            $data = new stdClass();
+            $data->name = format_string($this->group->name, true, ['context' => $context]);
+            $data->pictureurl = get_group_picture_url($this->group, $this->group->courseid, true);
+            $data->description = format_text($description, $descriptionformat, $options);
+
+            if (has_capability('moodle/course:managegroups', $context)) {
+                $url = new moodle_url('/group/group.php', ['id' => $this->group->id, 'courseid' => $this->group->courseid]);
+                $data->editurl = $url->out(false);
+            }
+
+            return $data;
+        } else {
+            return;
+        }
+    }
+}
index 14443cf..c3285fa 100644 (file)
@@ -47,4 +47,15 @@ class renderer extends plugin_renderer_base {
         $data = $page->export_for_template($this);
         return parent::render_from_template('core_group/index', $data);
     }
+
+    /**
+     * Defer to template.
+     *
+     * @param group_details $page Group details page object.
+     * @return string HTML to render the group details.
+     */
+    public function group_details(group_details $page) {
+        $data = $page->export_for_template($this);
+        return parent::render_from_template('core_group/group_details', $data);
+    }
 }
index 3f4e352..f089e32 100644 (file)
@@ -102,36 +102,11 @@ echo $OUTPUT->heading(get_string('adduserstogroup', 'group').": $groupname", 3);
 // Store the rows we want to display in the group info.
 $groupinforow = array();
 
-// Check if there is a picture to display.
-if (!empty($group->picture)) {
-    $picturecell = new html_table_cell();
-    $picturecell->attributes['class'] = 'left side picture';
-    $picturecell->text = print_group_picture($group, $course->id, true, true, false);
-    $groupinforow[] = $picturecell;
-}
-
 // Check if there is a description to display.
-$group->description = file_rewrite_pluginfile_urls($group->description, 'pluginfile.php', $context->id, 'group', 'description', $group->id);
 if (!empty($group->description)) {
-    if (!isset($group->descriptionformat)) {
-        $group->descriptionformat = FORMAT_MOODLE;
-    }
-
-    $options = new stdClass;
-    $options->overflowdiv = true;
-
-    $contentcell = new html_table_cell();
-    $contentcell->attributes['class'] = 'content';
-    $contentcell->text = format_text($group->description, $group->descriptionformat, $options);
-    $groupinforow[] = $contentcell;
-}
-
-// Check if we have something to show.
-if (!empty($groupinforow)) {
-    $groupinfotable = new html_table();
-    $groupinfotable->attributes['class'] = 'groupinfobox';
-    $groupinfotable->data[] = new html_table_row($groupinforow);
-    echo html_writer::table($groupinfotable);
+    $grouprenderer = $PAGE->get_renderer('core_group');
+    $groupdetailpage = new \core_group\output\group_details($groupid);
+    echo $grouprenderer->group_details($groupdetailpage);
 }
 
 /// Print the editing form
diff --git a/group/templates/group_details.mustache b/group/templates/group_details.mustache
new file mode 100644 (file)
index 0000000..bd6408f
--- /dev/null
@@ -0,0 +1,53 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_group/group_details
+
+    Template for the Groups page.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * name string Group Name
+    * pictureurl string Group image url
+    * description string Group description
+    * edit string edit link to edit the group
+
+    Example context (json):
+    {
+        "name": "Group Name",
+        "pictureurl": "https://raw.githubusercontent.com/moodle/moodle/master/pix/g/f1.png",
+        "description": "This is the description for Group Name",
+        "editurl": "http://www.moodle.org"
+    }
+}}
+{{#name}}
+<div class="groupinfobox container-fluid p-x-1 p-y-1">
+    {{#pictureurl}}
+    <div class="group-image"><img class="grouppicture" src="{{{pictureurl}}}" alt="{{{name}}}" title="{{{name}}}"/></div>
+    {{/pictureurl}}
+    {{#editurl}}
+    <div class="group-edit"><a href="{{editurl}}">{{#pix}}t/edit, core, {{#str}}editgroupprofile{{/str}}{{/pix}}</a></div>
+    {{/editurl}}
+    <h3 class="">{{{name}}}</h3>
+    <div class="group-description">{{{description}}}</div>
+</div>
+{{/name}}
diff --git a/group/tests/behat/group_description.feature b/group/tests/behat/group_description.feature
new file mode 100644 (file)
index 0000000..015cb26
--- /dev/null
@@ -0,0 +1,104 @@
+@core @core_group
+Feature: The description of a group can be viewed by students and teachers
+  In order to view the description of a group
+  As a teacher
+  I need to create groups and add descriptions to them.
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | format |
+      | Course 1 | C1 | topics |
+    And the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+      | student1 | Student | 1 | student1@example.com |
+      | student2 | Student | 2 | student2@example.com |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+      | student2 | C1 | student |
+
+  @javascript
+  Scenario: A student can see the group description when visible groups are set. Teachers can see group details.
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Group mode | Visible groups |
+    And I press "Save and display"
+    And I navigate to "Users > Groups" in current page administration
+    And I press "Create group"
+    And I set the following fields to these values:
+      | Group name | Group A |
+      | Group description | Description for Group A |
+    And I press "Save changes"
+    And I press "Create group"
+    And I set the following fields to these values:
+      | Group name | Group B |
+    And I press "Save changes"
+    And I add "Student 1 (student1@example.com)" user to "Group A" group members
+    And I add "Student 2 (student2@example.com)" user to "Group B" group members
+    And I am on "Course 1" course homepage
+    And I navigate to course participants
+    And I open the autocomplete suggestions list
+    And I click on "Group: Group A" item in the autocomplete list
+    And I should see "Description for Group A"
+    And ".groupinfobox" "css_element" should exist
+    And I should see "Description for Group A"
+    And I click on "Group: Group A" "autocomplete_selection"
+    And I open the autocomplete suggestions list
+    And I click on "Group: Group B" item in the autocomplete list
+    And ".groupinfobox" "css_element" should not exist
+    And I log out
+    When I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I navigate to course participants
+    Then I should see "Description for Group A"
+    And I log out
+    And I log in as "student2"
+    And I am on "Course 1" course homepage
+    And I navigate to course participants
+    And ".groupinfobox" "css_element" should not exist
+
+  @javascript
+  Scenario: A student can not see the group description when separate groups are set. Teachers can see group details.
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Group mode | Separate groups |
+    And I press "Save and display"
+    And I navigate to "Users > Groups" in current page administration
+    And I press "Create group"
+    And I set the following fields to these values:
+      | Group name | Group A |
+      | Group description | Description for Group A |
+    And I press "Save changes"
+    And I press "Create group"
+    And I set the following fields to these values:
+      | Group name | Group B |
+    And I press "Save changes"
+    And I add "Student 1 (student1@example.com)" user to "Group A" group members
+    And I add "Student 2 (student2@example.com)" user to "Group B" group members
+    And I am on "Course 1" course homepage
+    And I navigate to course participants
+    And I open the autocomplete suggestions list
+    And I click on "Group: Group A" item in the autocomplete list
+    And I should see "Description for Group A"
+    And ".groupinfobox" "css_element" should exist
+    And I click on "Group: Group A" "autocomplete_selection"
+    And I open the autocomplete suggestions list
+    And I click on "Group: Group B" item in the autocomplete list
+    And ".groupinfobox" "css_element" should not exist
+    And I log out
+    When I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I navigate to course participants
+    Then I should not see "Description for Group A"
+    And ".groupinfobox" "css_element" should not exist
+    And I log out
+    And I log in as "student2"
+    And I am on "Course 1" course homepage
+    And I navigate to course participants
+    And ".groupinfobox" "css_element" should not exist
\ No newline at end of file
index 8983294..69291ba 100644 (file)
@@ -581,7 +581,7 @@ $string['gravatardefaulturl_help'] = 'Gravatar needs a default image to display
 $string['gradeexport'] = 'Primary grade export methods';
 $string['guestroleid'] = 'Role for guest';
 $string['guestroleid_help'] = 'This role is automatically assigned to the guest user. It is also temporarily assigned to not enrolled users that enter the course via guest enrolment plugin.';
-$string['helpadminseesall'] = 'Do admins see all calendar events or just those that apply to themselves?';
+$string['helpadminseesall'] = 'In the site calendar, do admins see and filter events from all course calendars or just those from courses they are enrolled in? Regardless of the chosen option, admins will always be able to manage events for each course calendar by navigating to the course first, and then accessing the course calendar directly.';
 $string['helpcalendarcustomexport'] = 'Enable custom date range export option in calendar exports. Calendar exports must be enabled before this is effective.';
 $string['helpexportlookahead'] = 'How many days in the future does the calendar look for events during export for the custom export option?';
 $string['helpexportlookback'] = 'How many days in the past does the calendar look for events during export for the custom export option?';
@@ -616,6 +616,7 @@ $string['installhijacked'] = 'Installation must be finished from the original IP
 $string['installsessionerror'] = 'Can not initialise PHP session, please verify that your browser accepts cookies.';
 $string['intlrecommended'] = 'Intl extension is used to improve internationalization support, such as locale aware sorting.';
 $string['intlrequired'] = 'Intl extension is required to improve internationalization support, such as locale aware sorting and international domain names.';
+$string['invalidforgottenpasswordurl'] = 'The forgotten password URL is not a valid URL.';
 $string['invalidsection'] = 'Invalid section.';
 $string['invaliduserchangeme'] = 'Username "changeme" is reserved -- you cannot create an account with it.';
 $string['ipblocked'] = 'This site is not available currently.';
@@ -939,15 +940,7 @@ $string['quizattemptsupgradedmessage'] = 'In Moodle 2.1 there was a major upgrad
 $string['recaptchaprivatekey'] = 'ReCAPTCHA secret key';
 $string['recaptchapublickey'] = 'ReCAPTCHA site key';
 $string['register'] = 'Register your site';
-$string['registermoodlenet'] = 'We\'d love to stay in touch for important things for your Moodle site!
-
-By registering,
-
-* You are contributing to our collective knowledge about the users of Moodle which helps us improve Moodle and all our community services
-* You’ll be one of the first to find out about important notifications such as security alerts and new Moodle releases.
-* You can access and activate mobile push notifications from your Moodle site through our free [Moodle Mobile app](https://download.moodle.org/mobile/)
-* Optionally, your site can be included as a proud member and supporter of the Moodle community on the [list of registered sites](https://moodle.net/stats).
-';
+$string['registermoodlenet'] = '<p>We\'d love to stay in touch for important things for your Moodle site!</p><p>By registering,</p><ul><li>You are contributing to our collective knowledge about the users of Moodle which helps us improve Moodle and all our community services.</li><li>You’ll be one of the first to find out about important notifications such as security alerts and new Moodle releases.</li><li>You can access and activate mobile push notifications from your Moodle site through our free <a href="https://download.moodle.org/mobile/">Moodle Mobile app</a>.</li><li>Optionally, your site can be included as a proud member and supporter of the Moodle community on the <a href="https://moodle.net/stats">list of registered sites</a>.</li></ul>';
 $string['registermoodleorg'] = 'When you register your site';
 $string['registermoodleorgli1'] = 'You are added to a low-volume mailing list for important notifications such as security alerts and new releases of Moodle.';
 $string['registermoodleorgli2'] = 'Statistics about your site will be added to the {$a} of the worldwide Moodle community.';
@@ -1175,6 +1168,7 @@ $string['unsupporteddbstorageengine'] = 'The database storage engine being used
 $string['unsupporteddbtablerowformat'] = 'Your database has tables using Antelope as the file format. You are recommended to convert the tables to the Barracuda file format. See the documentation <a href="https://docs.moodle.org/en/cli">Administration via command line</a> for details of a tool for converting InnoDB tables to Barracuda.';
 $string['unsupportedphpversion7'] = 'PHP version 7 is not supported.';
 $string['unsupportedphpversion71'] = 'PHP version 7.1 is not supported.';
+$string['unsupportedphpversion72'] = 'PHP version 7.2 is not supported.';
 $string['unsuspenduser'] = 'Activate user account';
 $string['updateaccounts'] = 'Update existing accounts';
 $string['updatecomponent'] = 'Update component';
index 01dcd4f..206ebb2 100644 (file)
@@ -108,6 +108,7 @@ $string['eventrepeat'] = 'Repeats';
 $string['eventsall'] = 'All events';
 $string['eventsdeleted'] = 'Events deleted';
 $string['eventsimported'] = 'Events imported: {$a}';
+$string['eventsource'] = 'Event source';
 $string['eventsupdated'] = 'Events updated: {$a}';
 $string['eventsfor'] = '{$a} events';
 $string['eventskey'] = 'Events key';
index a280a27..ab2020f 100644 (file)
Binary files a/lib/amd/build/modal.min.js and b/lib/amd/build/modal.min.js differ
index 3f368b9..302cd8d 100644 (file)
@@ -358,7 +358,8 @@ define(['jquery', 'core/templates', 'core/notification', 'core/key_codes',
     };
 
     /**
-     * Set the modal footer element.
+     * Set the modal footer element. The footer element is made visible, if it
+     * isn't already.
      *
      * This method is overloaded to take either a string
      * value for the body or a jQuery promise that is resolved with HTML and Javascript
@@ -368,6 +369,9 @@ define(['jquery', 'core/templates', 'core/notification', 'core/key_codes',
      * @param {(string|object)} value The footer string or jQuery promise
      */
     Modal.prototype.setFooter = function(value) {
+        // Make sure the footer is visible.
+        this.showFooter();
+
         var footer = this.getFooter();
 
         if (typeof value === 'string') {
@@ -396,6 +400,34 @@ define(['jquery', 'core/templates', 'core/notification', 'core/key_codes',
         }
     };
 
+    /**
+     * Check if the footer has any content in it.
+     *
+     * @method hasFooterContent
+     * @return {bool}
+     */
+    Modal.prototype.hasFooterContent = function() {
+        return this.getFooter().children().length ? true : false;
+    };
+
+    /**
+     * Hide the footer element.
+     *
+     * @method hideFooter
+     */
+    Modal.prototype.hideFooter = function() {
+        this.getFooter().addClass('hidden');
+    };
+
+    /**
+     * Show the footer element.
+     *
+     * @method showFooter
+     */
+    Modal.prototype.showFooter = function() {
+        this.getFooter().removeClass('hidden');
+    };
+
     /**
      * Mark the modal as a large modal.
      *
@@ -508,6 +540,12 @@ define(['jquery', 'core/templates', 'core/notification', 'core/key_codes',
             return;
         }
 
+        if (this.hasFooterContent()) {
+            this.showFooter();
+        } else {
+            this.hideFooter();
+        }
+
         if (!this.isAttached) {
             this.attachToDOM();
         }
index a0599fb..1faa0db 100644 (file)
@@ -109,6 +109,7 @@ class behat_partial_named_selector extends \Behat\Mink\Selector\PartialNamedSele
         'text' => 'text',
         'xpath_element' => 'xpath_element',
         'form_row' => 'form_row',
+        'autocomplete_selection' => 'autocomplete_selection',
     );
 
     /**
@@ -178,6 +179,9 @@ XPATH
 XPATH
         , 'message_area_action' => <<<XPATH
 .//div[@data-region='messaging-area']/descendant::*[@data-action = %locator%]
+XPATH
+        , 'autocomplete_selection' => <<<XPATH
+.//div[contains(concat(' ', normalize-space(@class), ' '), concat(' ', 'form-autocomplete-selection', ' '))]/span[@role='listitem'][contains(normalize-space(.), %locator%)]
 XPATH
     );
 
index 1214b49..98e7aa3 100644 (file)
@@ -70,7 +70,23 @@ class user_login_failed extends base {
     public function get_description() {
         // Note that username could be any random user input.
         $username = s($this->other['username']);
-        return "Login failed for the username '{$username}' for the reason with id '{$this->other['reason']}'.";
+        $reasonid = $this->other['reason'];
+        $loginfailed = 'Login failed for user';
+        switch ($reasonid){
+            case 1:
+                return $loginfailed." '{$username}'. User does not exist (error ID '{$reasonid}').";
+            case 2:
+                return $loginfailed." '{$username}'. User is suspended (error ID '{$reasonid}').";
+            case 3:
+                return $loginfailed." '{$username}'. Most likely the password did not match (error ID '{$reasonid}').";
+            case 4:
+                return $loginfailed." '{$username}'. User is locked out (error ID '{$reasonid}').";
+            case 5:
+                return $loginfailed." '{$username}'. User is not authorised (error ID '{$reasonid}').";
+            default:
+                return $loginfailed." '{$username}', error ID '{$reasonid}'.";
+
+        }
     }
 
     /**
index 317862e..ee7368d 100644 (file)
@@ -173,6 +173,15 @@ class site_registration_form extends \moodleform {
         $mform->addElement('hidden', 'returnurl');
         $mform->setType('returnurl', PARAM_LOCALURL);
 
+        // Prepare and set data.
+        $siteinfo['emailalertnewemail'] = !empty($siteinfo['emailalert']) && !empty($siteinfo['emailalertemail']);
+        if (empty($siteinfo['emailalertnewemail'])) {
+            $siteinfo['emailalertemail'] = '';
+        }
+        $siteinfo['commnewsnewemail'] = !empty($siteinfo['commnews']) && !empty($siteinfo['commnewsemail']);
+        if (empty($siteinfo['commnewsnewemail'])) {
+            $siteinfo['commnewsemail'] = '';
+        }
         $this->set_data($siteinfo);
     }
 
@@ -212,26 +221,6 @@ class site_registration_form extends \moodleform {
 
     }
 
-    /**
-     * Load in existing data as form defaults
-     *
-     * @param stdClass|array $defaultvalues object or array of default values
-     */
-    public function set_data($defaultvalues) {
-        if (is_object($defaultvalues)) {
-            $defaultvalues = (array)$defaultvalues;
-        }
-        $defaultvalues['emailalertnewemail'] = !empty($defaultvalues['emailalert']) && !empty($defaultvalues['emailalertemail']);
-        if (empty($defaultvalues['emailalertnewemail'])) {
-            $defaultvalues['emailalertemail'] = '';
-        }
-        $defaultvalues['commnewsnewemail'] = !empty($defaultvalues['commnews']) && !empty($defaultvalues['commnewsemail']);
-        if (empty($defaultvalues['commnewsnewemail'])) {
-            $defaultvalues['commnewsemail'] = '';
-        }
-        parent::set_data($defaultvalues);
-    }
-
     /**
      * Validation of the form data
      *
index bad766f..08e88b5 100644 (file)
@@ -145,5 +145,12 @@ abstract class icon_system {
         }
         return false;
     }
+
+    /**
+     * Clears the instance cache, for use in unit tests
+     */
+    public static function reset_caches() {
+        self::$instance = null;
+    }
 }
 
index 055857a..d7e858a 100644 (file)
@@ -195,6 +195,8 @@ class icon_system_fontawesome extends icon_system_font {
             'core:i/badge' => 'fa-shield',
             'core:i/calc' => 'fa-calculator',
             'core:i/calendar' => 'fa-calendar',
+            'core:i/calendareventdescription' => 'fa-align-left',
+            'core:i/calendareventtime' => 'fa-clock-o',
             'core:i/caution' => 'fa-exclamation text-warning',
             'core:i/checked' => 'fa-check',
             'core:i/checkpermissions' => 'fa-unlock-alt',
index 2e96754..3e2641a 100644 (file)
@@ -44,6 +44,10 @@ class refresh_mod_calendar_events_task extends adhoc_task {
      * Run the task to refresh calendar events.
      */
     public function execute() {
+        global $CFG;
+
+        require_once($CFG->dirroot . '/course/lib.php');
+
         // Specific list of plugins that need to be refreshed. If not set, then all mod plugins will be refreshed.
         $pluginstorefresh = null;
         if (isset($this->get_custom_data()->plugins)) {
index f83e69d..270d79c 100644 (file)
@@ -1593,3 +1593,14 @@ function restrict_php_version(&$result, $version) {
 function restrict_php_version_71(&$result) {
     return restrict_php_version($result, '7.1');
 }
+
+/**
+ * Check if the current PHP version is greater than or equal to
+ * PHP version 7.2.
+ *
+ * @param object $result an environment_results instance
+ * @return bool result of version check
+ */
+function restrict_php_version_72(&$result) {
+    return restrict_php_version($result, '7.2');
+}
index d32527a..bbec709 100644 (file)
@@ -58,7 +58,7 @@ class MoodleQuickForm_editor extends HTML_QuickForm_element implements templatab
     /** @var array options provided to initalize filepicker */
     protected $_options = array('subdirs' => 0, 'maxbytes' => 0, 'maxfiles' => 0, 'changeformat' => 0,
             'areamaxbytes' => FILE_AREA_MAX_BYTES_UNLIMITED, 'context' => null, 'noclean' => 0, 'trusttext' => 0,
-            'return_types' => 15, 'enable_filemanagement' => true, 'removeorphaneddrafts' => false);
+            'return_types' => 15, 'enable_filemanagement' => true, 'removeorphaneddrafts' => false, 'autosave' => true);
     // 15 is $_options['return_types'] = FILE_INTERNAL | FILE_EXTERNAL | FILE_REFERENCE | FILE_CONTROLLED_LINK.
 
     /** @var array values for editor */
index a239ebc..7c46fc3 100644 (file)
@@ -2741,7 +2741,7 @@ class global_navigation extends navigation_node {
      * @return bool True for successfull generation
      */
     public function add_front_page_course_essentials(navigation_node $coursenode, stdClass $course) {
-        global $CFG, $USER;
+        global $CFG, $USER, $COURSE, $SITE;
         require_once($CFG->dirroot . '/course/lib.php');
 
         if ($coursenode == false || $coursenode->get('frontpageloaded', navigation_node::TYPE_CUSTOM)) {
@@ -2793,8 +2793,14 @@ class global_navigation extends navigation_node {
         }
 
         if ($navoptions->calendar) {
+            $courseid = $COURSE->id;
+            $params = array('view' => 'month');
+            if ($courseid != $SITE->id) {
+                $params['course'] = $courseid;
+            }
+
             // Calendar
-            $calendarurl = new moodle_url('/calendar/view.php', array('view' => 'month'));
+            $calendarurl = new moodle_url('/calendar/view.php', $params);
             $node = $coursenode->add(get_string('calendar', 'calendar'), $calendarurl, self::TYPE_CUSTOM, null, 'calendar');
             $node->showinflatnavigation = true;
         }
index 54eb63f..8f78f4c 100644 (file)
@@ -242,7 +242,7 @@ abstract class base_testcase extends PHPUnit_Framework_TestCase {
                         }
                     } // match by exact string
                     else {
-                        if ($node->getAttribute($name) != $value) {
+                        if ($node->getAttribute($name) !== (string) $value) {
                             $invalid = true;
                         }
                     }
index c257f63..240777a 100644 (file)
@@ -217,10 +217,10 @@ class phpunit_util extends testing_util {
         core_filetypes::reset_caches();
         \core_search\manager::clear_static();
         core_user::reset_caches();
+        \core\output\icon_system::reset_caches();
         if (class_exists('core_media_manager', false)) {
             core_media_manager::reset_caches();
         }
-        \core_analytics\course::reset_caches();
 
         // Reset static unit test options.
         if (class_exists('\availability_date\condition', false)) {
index 108fc33..acce48e 100644 (file)
@@ -16,6 +16,8 @@ var require = {
       // '*' means all modules will get 'jqueryprivate'
       // for their 'jquery' dependency.
       '*': { jquery: 'jqueryprivate' },
+      // Stub module for 'process'. This is a workaround for a bug in MathJax (see MDL-60458).
+      '*': { process: 'core/first' },
 
       // 'jquery-private' wants the real jQuery module
       // though. If this line was not here, there would
index b0df977..17ce5e7 100644 (file)
@@ -1,3 +1,4 @@
 Description of import into Moodle:
 // Download from https://requirejs.org/docs/download.html
 // Put the require.js and require.min.js and LICENSE file in this folder.
+// Check if MDL-60458 workaround can be removed.
index d8c4b70..a860c75 100644 (file)
         data-region="modal"
         aria-labelledby="{{uniqid}}-modal-title"
         role="document">
-        <div class="modal-header" data-region="header">
+        <div class="modal-header {{$headerclasses}}{{headerclasses}}{{/headerclasses}}" data-region="header">
             <button type="button" class="close" data-action="hide" title="{{#str}} closebuttontitle {{/str}}"></button>
             {{$header}}
-                <h3 id="{{uniqid}}-modal-title" data-region="title" tabindex="0">
+                <h3 id="{{uniqid}}-modal-title" class="modal-title" data-region="title" tabindex="0">
                     {{$title}}{{title}}{{/title}}
                 </h3>
             {{/header}}
index e8e2e95..4716080 100644 (file)
@@ -2423,23 +2423,56 @@ function print_group_picture($group, $courseid, $large=false, $return=false, $li
         }
     }
 
+    $pictureurl = get_group_picture_url($group, $courseid, $large);
+
+    // If there is no picture, do nothing.
+    if (!isset($pictureurl)) {
+        return;
+    }
+
+    $context = context_course::instance($courseid);
+
+    $groupname = s($group->name);
+    $pictureimage = html_writer::img($pictureurl, $groupname, ['title' => $groupname]);
+
+    $output = '';
+    if ($link or has_capability('moodle/site:accessallgroups', $context)) {
+        $linkurl = new moodle_url('/user/index.php', ['id' => $courseid, 'group' => $group->id]);
+        $output .= html_writer::link($linkurl, $pictureimage);
+    } else {
+        $output .= $pictureimage;
+    }
+
+    if ($return) {
+        return $output;
+    } else {
+        echo $output;
+    }
+}
+
+/**
+ * Return the url to the group picture.
+ *
+ * @param  stdClass $group A group object.
+ * @param  int $courseid The course ID for the group.
+ * @param  bool $large A large or small group picture? Default is small.
+ * @return moodle_url Returns the url for the group picture.
+ */
+function get_group_picture_url($group, $courseid, $large = false) {
+    global $CFG;
+
     $context = context_course::instance($courseid);
 
     // If there is no picture, do nothing.
     if (!$group->picture) {
-        return '';
+        return;
     }
 
     // If picture is hidden, only show to those with course:managegroups.
     if ($group->hidepicture and !has_capability('moodle/course:managegroups', $context)) {
-        return '';
+        return;
     }
 
-    if ($link or has_capability('moodle/site:accessallgroups', $context)) {
-        $output = '<a href="'. $CFG->wwwroot .'/user/index.php?id='. $courseid .'&amp;group='. $group->id .'">';
-    } else {
-        $output = '';
-    }
     if ($large) {
         $file = 'f1';
     } else {
@@ -2448,18 +2481,7 @@ function print_group_picture($group, $courseid, $large=false, $return=false, $li
 
     $grouppictureurl = moodle_url::make_pluginfile_url($context->id, 'group', 'icon', $group->id, '/', $file);
     $grouppictureurl->param('rev', $group->picture);
-    $output .= '<img class="grouppicture" src="'.$grouppictureurl.'"'.
-        ' alt="'.s(get_string('group').' '.$group->name).'" title="'.s($group->name).'"/>';
-
-    if ($link or has_capability('moodle/site:accessallgroups', $context)) {
-        $output .= '</a>';
-    }
-
-    if ($return) {
-        return $output;
-    } else {
-        echo $output;
-    }
+    return $grouppictureurl;
 }
 
 
index 666242a..0355993 100644 (file)
     {{$headertext}}{{#str}} messages, message {{/str}}{{/headertext}}
     {{$headeractions}}
         <div class="newmessage-link">
-            {{$anchor}}
-                <a href="{{{urls.writeamessage}}}">{{#str}} newmessage, message {{/str}}
-                </a>
-            {{/anchor}}
+            <a href="{{{urls.writeamessage}}}">{{#str}} newmessage, message {{/str}}
+            </a>
         </div>
-        {{< core/hover_tooltip }}
-            {{$anchor}}
-                <a class="mark-all-read-button"
-                    href="#"
-                    role="button"
-                    title="{{#str}} markallread {{/str}}"
-                    data-action="mark-all-read">
-                    <span class="normal-icon">{{#pix}} t/markasread, core, {{#str}} markallread {{/str}} {{/pix}}</span>
-                    {{> core/loading }}
-                </a>
-            {{/anchor}}
-            {{$tooltip}}{{#str}} markallread {{/str}}{{/tooltip}}
-        {{/ core/hover_tooltip }}
-        {{< core/hover_tooltip }}
-            {{$anchor}}
-                <a href="{{{urls.preferences}}}"
-                    title="{{#str}} messagepreferences, message {{/str}}">
-                    {{#pix}} i/settings, core, {{#str}} messagepreferences, message {{/str}} {{/pix}}
-                </a>
-            {{/anchor}}
-            {{$tooltip}}{{#str}} messagepreferences, message {{/str}}{{/tooltip}}
-        {{/ core/hover_tooltip }}
+        <a class="mark-all-read-button"
+           href="#"
+           role="button"
+           title="{{#str}} markallread {{/str}}"
+           data-action="mark-all-read">
+            <span class="normal-icon">{{#pix}} t/markasread, core, {{#str}} markallread {{/str}} {{/pix}}</span>
+            {{> core/loading }}
+        </a>
+        <a href="{{{urls.preferences}}}"
+           title="{{#str}} messagepreferences, message {{/str}}">
+            {{#pix}} i/settings, core, {{#str}} messagepreferences, message {{/str}} {{/pix}}
+        </a>
     {{/headeractions}}
 
     {{$content}}
index b0684ed..97efbff 100644 (file)
 
     {{$headertext}}{{#str}} notifications, message {{/str}}{{/headertext}}
     {{$headeractions}}
-        {{< core/hover_tooltip }}
-            {{$anchor}}
-                <a class="mark-all-read-button"
-                    href="#"
-                    title="{{#str}} markallread {{/str}}"
-                    data-action="mark-all-read"
-                    role="button">
-                    <span class="normal-icon">{{#pix}} t/markasread, core, {{#str}} markallread {{/str}} {{/pix}}</span>
-                    {{> core/loading }}
-                </a>
-            {{/anchor}}
-            {{$tooltip}}{{#str}} markallread {{/str}}{{/tooltip}}
-        {{/ core/hover_tooltip }}
-        {{< core/hover_tooltip }}
-            {{$anchor}}
-                <a href="{{{urls.preferences}}}"
-                    title="{{#str}} notificationpreferences, message {{/str}}">
-                    {{#pix}} i/settings, core, {{#str}} notificationpreferences, message {{/str}} {{/pix}}
-                </a>
-            {{/anchor}}
-            {{$tooltip}}{{#str}} notificationpreferences, message {{/str}}{{/tooltip}}
-        {{/ core/hover_tooltip }}
+        <a class="mark-all-read-button"
+           href="#"
+           title="{{#str}} markallread {{/str}}"
+           data-action="mark-all-read"
+           role="button">
+            <span class="normal-icon">{{#pix}} t/markasread, core, {{#str}} markallread {{/str}} {{/pix}}</span>
+            {{> core/loading }}
+        </a>
+        <a href="{{{urls.preferences}}}"
+           title="{{#str}} notificationpreferences, message {{/str}}">
+            {{#pix}} i/settings, core, {{#str}} notificationpreferences, message {{/str}} {{/pix}}
+        </a>
     {{/headeractions}}
 
     {{$content}}
index 159e855..bc111f0 100644 (file)
Binary files a/mod/assign/amd/build/grading_navigation.min.js and b/mod/assign/amd/build/grading_navigation.min.js differ
index cf2deb2..ed1ab5c 100644 (file)
Binary files a/mod/assign/amd/build/grading_panel.min.js and b/mod/assign/amd/build/grading_panel.min.js differ
index f5a373c..f8f1758 100644 (file)
@@ -130,6 +130,7 @@ define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
         } else {
             this._selectNoUser();
         }
+        this._triggerNextUserEvent();
     };
 
     /**
@@ -233,6 +234,7 @@ define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
         } else {
             this._selectNoUser();
         }
+        this._triggerNextUserEvent();
     };
 
     /**
@@ -447,6 +449,20 @@ define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
         this._refreshCount();
     };
 
+    /**
+     * Trigger the next user event depending on the number of filtered users
+     *
+     * @private
+     * @method _triggerNextUserEvent
+     */
+    GradingNavigation.prototype._triggerNextUserEvent = function() {
+        if (this._filteredUsers.length > 1) {
+            $(document).trigger('next-user', {nextUserId: null, nextUser: true});
+        } else {
+            $(document).trigger('next-user', {nextUser: false});
+        }
+    };
+
     /**
      * Change to a different user in the grading list.
      *
index ee0ab1c..a9abbac 100644 (file)
@@ -54,6 +54,12 @@ define(['jquery', 'core/yui', 'core/notification', 'core/templates', 'core/fragm
     /** @type {JQuery} JQuery node for the page region containing the user navigation. */
     GradingPanel.prototype._region = null;
 
+     /** @type {Integer} The id of the next user in the grading list */
+    GradingPanel.prototype.nextUserId = null;
+
+     /** @type {Boolean} Next user exists in the grading list */
+    GradingPanel.prototype.nextUser = false;
+
     /**
      * Fade the dom node out, update it, and fade it back.
      *
@@ -314,6 +320,29 @@ define(['jquery', 'core/yui', 'core/notification', 'core/templates', 'core/fragm
         }.bind(this)).fail(notification.exception);
     };
 
+    /**
+     * Get next user data and store it in global variables
+     *
+     * @private
+     * @method _getNextUser
+     * @param {Event} event
+     * @param {Object} data Next user's data
+     */
+    GradingPanel.prototype._getNextUser = function(event, data) {
+        this.nextUserId = data.nextUserId;
+        this.nextUser = data.nextUser;
+    };
+
+    /**
+     * Handle the save-and-show-next event
+     *
+     * @private
+     * @method _handleSaveAndShowNext
+     */
+    GradingPanel.prototype._handleSaveAndShowNext = function() {
+        this._submitForm(null, this.nextUserId, this.nextUser);
+    };
+
     /**
      * Get the grade panel element.
      *
@@ -355,9 +384,10 @@ define(['jquery', 'core/yui', 'core/notification', 'core/templates', 'core/fragm
             e.preventDefault();
         });
 
+        docElement.on('next-user', this._getNextUser.bind(this));
         docElement.on('user-changed', this._refreshGradingPanel.bind(this));
         docElement.on('save-changes', this._submitForm.bind(this));
-        docElement.on('save-and-show-next', this._submitForm.bind(this, null, null, true));
+        docElement.on('save-and-show-next', this._handleSaveAndShowNext.bind(this));
         docElement.on('reset', this._resetForm.bind(this));
 
         docElement.on('save-form-state', this._saveFormState.bind(this));
index aeb2e12..f1b36a1 100644 (file)
     position: inherit;
 }
 /** End of base fixes **/
+
+/** Fix to YUI tree (which is a table) when displayed within grading table. **/
+.path-mod-assign table.generaltable table td.ygtvcell {
+    border: 0;
+    padding: 0;
+}
+/** End of YUI tree fix **/
index b188cbe..649a633 100644 (file)
@@ -264,7 +264,6 @@ class mod_data_external extends external_api {
             'warnings' => $warnings
         );
 
-        $groupmode = groups_get_activity_groupmode($cm);
         if (!empty($params['groupid'])) {
             $groupid = $params['groupid'];
             // Determine is the group is visible to user.
@@ -273,12 +272,9 @@ class mod_data_external extends external_api {
             }
         } else {
             // Check to see if groups are being used here.
+            $groupmode = groups_get_activity_groupmode($cm);
             if ($groupmode) {
                 $groupid = groups_get_activity_group($cm);
-                // Determine is the group is visible to user (this is particullary for the group 0 -> all groups).
-                if (!groups_group_visible($groupid, $course, $cm)) {
-                    throw new moodle_exception('notingroup');
-                }
             } else {
                 $groupid = 0;
             }
@@ -399,11 +395,8 @@ class mod_data_external extends external_api {
         } else {
             // Check to see if groups are being used here.
             if ($groupmode = groups_get_activity_groupmode($cm)) {
+                // We don't need to validate a possible groupid = 0 since it would be handled by data_search_entries.
                 $groupid = groups_get_activity_group($cm);
-                // Determine is the group is visible to user (this is particullary for the group 0 -> all groups).
-                if (!groups_group_visible($groupid, $course, $cm)) {
-                    throw new moodle_exception('notingroup');
-                }
             } else {
                 $groupid = 0;
             }
@@ -520,7 +513,7 @@ class mod_data_external extends external_api {
         $canmanageentries = has_capability('mod/data:manageentries', $context);
         data_require_time_available($database, $canmanageentries);
 
-        if ($record->groupid !== 0) {
+        if ($record->groupid != 0) {
             if (!groups_group_visible($record->groupid, $course, $cm)) {
                 throw new moodle_exception('notingroup');
             }
@@ -728,11 +721,8 @@ class mod_data_external extends external_api {
         } else {
             // Check to see if groups are being used here.
             if ($groupmode = groups_get_activity_groupmode($cm)) {
+                // We don't need to validate a possible groupid = 0 since it would be handled by data_search_entries.
                 $groupid = groups_get_activity_group($cm);
-                // Determine is the group is visible to user (this is particullary for the group 0 -> all groups).
-                if (!groups_group_visible($groupid, $course, $cm)) {
-                    throw new moodle_exception('notingroup');
-                }
             } else {
                 $groupid = 0;
             }
@@ -989,26 +979,18 @@ class mod_data_external extends external_api {
         // Check database is open in time.
         data_require_time_available($database, null, $context);
 
-        $groupmode = groups_get_activity_groupmode($cm);
-        if (!empty($params['groupid'])) {
-            $groupid = $params['groupid'];
-            // Determine is the group is visible to user.
-            if (!groups_group_visible($groupid, $course, $cm)) {
-                throw new moodle_exception('notingroup');
-            }
-        } else {
+        // Determine default group.
+        if (empty($params['groupid'])) {
             // Check to see if groups are being used here.
+            $groupmode = groups_get_activity_groupmode($cm);
             if ($groupmode) {
                 $groupid = groups_get_activity_group($cm);
-                // Determine is the group is visible to user (this is particullary for the group 0 -> all groups).
-                if (!groups_group_visible($groupid, $course, $cm)) {
-                    throw new moodle_exception('notingroup');
-                }
             } else {
                 $groupid = 0;
             }
         }
 
+        // Group is validated inside the function.
         if (!data_user_can_add_entry($database, $groupid, $groupmode, $context)) {
             throw new moodle_exception('noaccess', 'data');
         }
index dece2fe..e8b8dc9 100644 (file)
@@ -416,6 +416,8 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
         $this->setUser($this->student2);
         $entry12 = $generator->create_entry($this->database, $fieldcontents, $this->group1->id);
         $entry13 = $generator->create_entry($this->database, $fieldcontents, $this->group1->id);
+        // Entry not in group.
+        $entry14 = $generator->create_entry($this->database, $fieldcontents, 0);
 
         $this->setUser($this->student3);
         $entry21 = $generator->create_entry($this->database, $fieldcontents, $this->group2->id);
@@ -423,9 +425,10 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
         // Approve all except $entry13.
         $DB->set_field('data_records', 'approved', 1, ['id' => $entry11]);
         $DB->set_field('data_records', 'approved', 1, ['id' => $entry12]);
+        $DB->set_field('data_records', 'approved', 1, ['id' => $entry14]);
         $DB->set_field('data_records', 'approved', 1, ['id' => $entry21]);
 
-        return [$entry11, $entry12, $entry13, $entry21];
+        return [$entry11, $entry12, $entry13, $entry14, $entry21];
     }
 
     /**
@@ -433,15 +436,16 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
      */
     public function test_get_entries() {
         global $DB;
-        list($entry11, $entry12, $entry13, $entry21) = self::populate_database_with_entries();
+        list($entry11, $entry12, $entry13, $entry14, $entry21) = self::populate_database_with_entries();
 
         // First of all, expect to see only my group entries (not other users in other groups ones).
+        // We may expect entries without group also.
         $this->setUser($this->student1);
         $result = mod_data_external::get_entries($this->database->id);
         $result = external_api::clean_returnvalue(mod_data_external::get_entries_returns(), $result);
         $this->assertCount(0, $result['warnings']);
-        $this->assertCount(2, $result['entries']);
-        $this->assertEquals(2, $result['totalcount']);
+        $this->assertCount(3, $result['entries']);
+        $this->assertEquals(3, $result['totalcount']);
         $this->assertEquals($entry11, $result['entries'][0]['id']);
         $this->assertEquals($this->student1->id, $result['entries'][0]['userid']);
         $this->assertEquals($this->group1->id, $result['entries'][0]['groupid']);
@@ -450,36 +454,44 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
         $this->assertEquals($this->student2->id, $result['entries'][1]['userid']);
         $this->assertEquals($this->group1->id, $result['entries'][1]['groupid']);
         $this->assertEquals($this->database->id, $result['entries'][1]['dataid']);
+        $this->assertEquals($entry14, $result['entries'][2]['id']);
+        $this->assertEquals($this->student2->id, $result['entries'][2]['userid']);
+        $this->assertEquals(0, $result['entries'][2]['groupid']);
+        $this->assertEquals($this->database->id, $result['entries'][2]['dataid']);
         // Other user in same group.
         $this->setUser($this->student2);
         $result = mod_data_external::get_entries($this->database->id);
         $result = external_api::clean_returnvalue(mod_data_external::get_entries_returns(), $result);
         $this->assertCount(0, $result['warnings']);
-        $this->assertCount(3, $result['entries']);  // I can see my entry not approved yet.
-        $this->assertEquals(3, $result['totalcount']);
+        $this->assertCount(4, $result['entries']);  // I can see my entry not approved yet.
+        $this->assertEquals(4, $result['totalcount']);
 
-        // Now try with the user in the second group that must see only one entry.
+        // Now try with the user in the second group that must see only two entries (his group entry and the one without group).
         $this->setUser($this->student3);
         $result = mod_data_external::get_entries($this->database->id);
         $result = external_api::clean_returnvalue(mod_data_external::get_entries_returns(), $result);
         $this->assertCount(0, $result['warnings']);
-        $this->assertCount(1, $result['entries']);
-        $this->assertEquals(1, $result['totalcount']);
-        $this->assertEquals($entry21, $result['entries'][0]['id']);
-        $this->assertEquals($this->student3->id, $result['entries'][0]['userid']);
-        $this->assertEquals($this->group2->id, $result['entries'][0]['groupid']);
+        $this->assertCount(2, $result['entries']);
+        $this->assertEquals(2, $result['totalcount']);
+        $this->assertEquals($entry14, $result['entries'][0]['id']);
+        $this->assertEquals($this->student2->id, $result['entries'][0]['userid']);
+        $this->assertEquals(0, $result['entries'][0]['groupid']);
         $this->assertEquals($this->database->id, $result['entries'][0]['dataid']);
+        $this->assertEquals($entry21, $result['entries'][1]['id']);
+        $this->assertEquals($this->student3->id, $result['entries'][1]['userid']);
+        $this->assertEquals($this->group2->id, $result['entries'][1]['groupid']);
+        $this->assertEquals($this->database->id, $result['entries'][1]['dataid']);
 
         // Now, as teacher we should see all (we have permissions to view all groups).
         $this->setUser($this->teacher);
         $result = mod_data_external::get_entries($this->database->id);
         $result = external_api::clean_returnvalue(mod_data_external::get_entries_returns(), $result);
         $this->assertCount(0, $result['warnings']);
-        $this->assertCount(4, $result['entries']);  // I can see the not approved one.
-        $this->assertEquals(4, $result['totalcount']);
+        $this->assertCount(5, $result['entries']);  // I can see the not approved one.
+        $this->assertEquals(5, $result['totalcount']);
 
         $entries = $DB->get_records('data_records', array('dataid' => $this->database->id), 'id');
-        $this->assertCount(4, $entries);
+        $this->assertCount(5, $entries);
         $count = 0;
         foreach ($entries as $entry) {
             $this->assertEquals($entry->id, $result['entries'][$count]['id']);
@@ -491,17 +503,17 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
         $result = mod_data_external::get_entries($this->database->id, $this->group1->id);
         $result = external_api::clean_returnvalue(mod_data_external::get_entries_returns(), $result);
         $this->assertCount(0, $result['warnings']);
-        $this->assertCount(2, $result['entries']);
-        $this->assertEquals(2, $result['totalcount']);
+        $this->assertCount(3, $result['entries']);
+        $this->assertEquals(3, $result['totalcount']);
 
         // Test ordering (reverse).
         $this->setUser($this->student1);
         $result = mod_data_external::get_entries($this->database->id, $this->group1->id, false, null, 'DESC');
         $result = external_api::clean_returnvalue(mod_data_external::get_entries_returns(), $result);
         $this->assertCount(0, $result['warnings']);
-        $this->assertCount(2, $result['entries']);
-        $this->assertEquals(2, $result['totalcount']);
-        $this->assertEquals($entry12, $result['entries'][0]['id']);
+        $this->assertCount(3, $result['entries']);
+        $this->assertEquals(3, $result['totalcount']);
+        $this->assertEquals($entry14, $result['entries'][0]['id']);
 
         // Test pagination.
         $this->setUser($this->student1);
@@ -509,14 +521,14 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
         $result = external_api::clean_returnvalue(mod_data_external::get_entries_returns(), $result);
         $this->assertCount(0, $result['warnings']);
         $this->assertCount(1, $result['entries']);
-        $this->assertEquals(2, $result['totalcount']);
+        $this->assertEquals(3, $result['totalcount']);
         $this->assertEquals($entry11, $result['entries'][0]['id']);
 
         $result = mod_data_external::get_entries($this->database->id, $this->group1->id, false, null, null, 1, 1);
         $result = external_api::clean_returnvalue(mod_data_external::get_entries_returns(), $result);
         $this->assertCount(0, $result['warnings']);
         $this->assertCount(1, $result['entries']);
-        $this->assertEquals(2, $result['totalcount']);
+        $this->assertEquals(3, $result['totalcount']);
         $this->assertEquals($entry12, $result['entries'][0]['id']);
 
         // Now test the return contents.
@@ -525,7 +537,7 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
         $result = external_api::clean_returnvalue(mod_data_external::get_entries_returns(), $result);
         $this->assertCount(0, $result['warnings']);
         $this->assertCount(2, $result['entries']);
-        $this->assertEquals(2, $result['totalcount']);
+        $this->assertEquals(3, $result['totalcount']);
         $this->assertCount(9, $result['entries'][0]['contents']);
         $this->assertCount(9, $result['entries'][1]['contents']);
         // Search for some content.
@@ -543,7 +555,7 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
         global $DB;
 
         $DB->set_field('course', 'groupmode', VISIBLEGROUPS, ['id' => $this->course->id]);
-        list($entry11, $entry12, $entry13, $entry21) = self::populate_database_with_entries();
+        list($entry11, $entry12, $entry13, $entry14, $entry21) = self::populate_database_with_entries();
 
         // Check I can see my approved group entries.
         $this->setUser($this->student1);
@@ -566,7 +578,7 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
      */
     public function test_get_entry_separated_groups() {
         global $DB;
-        list($entry11, $entry12, $entry13, $entry21) = self::populate_database_with_entries();
+        list($entry11, $entry12, $entry13, $entry14, $entry21) = self::populate_database_with_entries();
 
         // Check I can see my approved group entries.
         $this->setUser($this->student1);
@@ -633,7 +645,7 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
      * Test get_entry from other group in separated groups.
      */
     public function test_get_entry_other_group_separated_groups() {
-        list($entry11, $entry12, $entry13, $entry21) = self::populate_database_with_entries();
+        list($entry11, $entry12, $entry13, $entry14, $entry21) = self::populate_database_with_entries();
 
         // We should not be able to view other gropu entries (in separated groups).
         $this->setUser($this->student1);
@@ -646,7 +658,7 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
      */
     public function test_get_fields() {
         global $DB;
-        list($entry11, $entry12, $entry13, $entry21) = self::populate_database_with_entries();
+        list($entry11, $entry12, $entry13, $entry14, $entry21) = self::populate_database_with_entries();
 
         $this->setUser($this->student1);
         $result = mod_data_external::get_fields($this->database->id);
@@ -676,14 +688,14 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
      */
     public function test_search_entries() {
         global $DB;
-        list($entry11, $entry12, $entry13, $entry21) = self::populate_database_with_entries();
+        list($entry11, $entry12, $entry13, $entry14, $entry21) = self::populate_database_with_entries();
 
         $this->setUser($this->student1);
         // Empty search, it should return all the visible entries.
         $result = mod_data_external::search_entries($this->database->id, 0, false);
         $result = external_api::clean_returnvalue(mod_data_external::search_entries_returns(), $result);
-        $this->assertCount(2, $result['entries']);
-        $this->assertEquals(2, $result['totalcount']);
+        $this->assertCount(3, $result['entries']);
+        $this->assertEquals(3, $result['totalcount']);
 
         // Search for something that does not exists.
         $result = mod_data_external::search_entries($this->database->id, 0, false, 'abc');
@@ -694,17 +706,17 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
         // Search by text matching all the entries.
         $result = mod_data_external::search_entries($this->database->id, 0, false, 'text');
         $result = external_api::clean_returnvalue(mod_data_external::search_entries_returns(), $result);
-        $this->assertCount(2, $result['entries']);
-        $this->assertEquals(2, $result['totalcount']);
-        $this->assertEquals(2, $result['maxcount']);
+        $this->assertCount(3, $result['entries']);
+        $this->assertEquals(3, $result['totalcount']);
+        $this->assertEquals(3, $result['maxcount']);
 
         // Now as the other student I should receive my not approved entry. Apply ordering here.
         $this->setUser($this->student2);
         $result = mod_data_external::search_entries($this->database->id, 0, false, 'text', [], DATA_APPROVED, 'ASC');
         $result = external_api::clean_returnvalue(mod_data_external::search_entries_returns(), $result);
-        $this->assertCount(3, $result['entries']);
-        $this->assertEquals(3, $result['totalcount']);
-        $this->assertEquals(3, $result['maxcount']);
+        $this->assertCount(4, $result['entries']);
+        $this->assertEquals(4, $result['totalcount']);
+        $this->assertEquals(4, $result['maxcount']);
         // The not approved one should be the first.
         $this->assertEquals($entry13, $result['entries'][0]['id']);
 
@@ -712,26 +724,27 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
         $this->setUser($this->student3);
         $result = mod_data_external::search_entries($this->database->id, 0, false, 'text');
         $result = external_api::clean_returnvalue(mod_data_external::search_entries_returns(), $result);
-        $this->assertCount(1, $result['entries']);
-        $this->assertEquals(1, $result['totalcount']);
-        $this->assertEquals(1, $result['maxcount']);
-        $this->assertEquals($this->student3->id, $result['entries'][0]['userid']);
+        $this->assertCount(2, $result['entries']);
+        $this->assertEquals(2, $result['totalcount']);
+        $this->assertEquals(2, $result['maxcount']);
+        $this->assertEquals($this->student2->id, $result['entries'][0]['userid']);
+        $this->assertEquals($this->student3->id, $result['entries'][1]['userid']);
 
         // Same normal text search as teacher.
         $this->setUser($this->teacher);
         $result = mod_data_external::search_entries($this->database->id, 0, false, 'text');
         $result = external_api::clean_returnvalue(mod_data_external::search_entries_returns(), $result);
-        $this->assertCount(4, $result['entries']);  // I can see all groups and non approved.
-        $this->assertEquals(4, $result['totalcount']);
-        $this->assertEquals(4, $result['maxcount']);
+        $this->assertCount(5, $result['entries']);  // I can see all groups and non approved.
+        $this->assertEquals(5, $result['totalcount']);
+        $this->assertEquals(5, $result['maxcount']);
 
         // Pagination.
         $this->setUser($this->teacher);
         $result = mod_data_external::search_entries($this->database->id, 0, false, 'text', [], DATA_TIMEADDED, 'ASC', 0, 2);
         $result = external_api::clean_returnvalue(mod_data_external::search_entries_returns(), $result);
         $this->assertCount(2, $result['entries']);  // Only 2 per page.
-        $this->assertEquals(4, $result['totalcount']);
-        $this->assertEquals(4, $result['maxcount']);
+        $this->assertEquals(5, $result['totalcount']);
+        $this->assertEquals(5, $result['maxcount']);
 
         // Now advanced search or not dinamic fields (user firstname for example).
         $this->setUser($this->student1);
@@ -740,9 +753,9 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
         ];
         $result = mod_data_external::search_entries($this->database->id, 0, false, '', $advsearch);
         $result = external_api::clean_returnvalue(mod_data_external::search_entries_returns(), $result);
-        $this->assertCount(1, $result['entries']);
-        $this->assertEquals(1, $result['totalcount']);
-        $this->assertEquals(2, $result['maxcount']);
+        $this->assertCount(2, $result['entries']);
+        $this->assertEquals(2, $result['totalcount']);
+        $this->assertEquals(3, $result['maxcount']);
         $this->assertEquals($this->student2->id, $result['entries'][0]['userid']);  // I only found mine!
 
         // Advanced search for fields.
@@ -752,9 +765,9 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
         ];
         $result = mod_data_external::search_entries($this->database->id, 0, false, '', $advsearch);
         $result = external_api::clean_returnvalue(mod_data_external::search_entries_returns(), $result);
-        $this->assertCount(2, $result['entries']);  // Found two entries matching this.
-        $this->assertEquals(2, $result['totalcount']);
-        $this->assertEquals(2, $result['maxcount']);
+        $this->assertCount(3, $result['entries']);  // Found two entries matching this.
+        $this->assertEquals(3, $result['totalcount']);
+        $this->assertEquals(3, $result['maxcount']);
 
         // Combined search.
         $field2 = $DB->get_record('data_fields', array('type' => 'number'));
@@ -765,9 +778,9 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
         ];
         $result = mod_data_external::search_entries($this->database->id, 0, false, '', $advsearch);
         $result = external_api::clean_returnvalue(mod_data_external::search_entries_returns(), $result);
-        $this->assertCount(1, $result['entries']);  // Only one matching everything.
-        $this->assertEquals(1, $result['totalcount']);
-        $this->assertEquals(2, $result['maxcount']);
+        $this->assertCount(2, $result['entries']);  // Only one matching everything.
+        $this->assertEquals(2, $result['totalcount']);
+        $this->assertEquals(3, $result['maxcount']);
 
         // Combined search (no results).
         $field2 = $DB->get_record('data_fields', array('type' => 'number'));
@@ -779,7 +792,7 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
         $result = external_api::clean_returnvalue(mod_data_external::search_entries_returns(), $result);
         $this->assertCount(0, $result['entries']);  // Only one matching everything.
         $this->assertEquals(0, $result['totalcount']);
-        $this->assertEquals(2, $result['maxcount']);
+        $this->assertEquals(3, $result['maxcount']);
     }
 
     /**
@@ -787,7 +800,7 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
      */
     public function test_approve_entry() {
         global $DB;
-        list($entry11, $entry12, $entry13, $entry21) = self::populate_database_with_entries();
+        list($entry11, $entry12, $entry13, $entry14, $entry21) = self::populate_database_with_entries();
 
         $this->setUser($this->teacher);
         $this->assertEquals(0, $DB->get_field('data_records', 'approved', array('id' => $entry13)));
@@ -801,7 +814,7 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
      */
     public function test_unapprove_entry() {
         global $DB;
-        list($entry11, $entry12, $entry13, $entry21) = self::populate_database_with_entries();
+        list($entry11, $entry12, $entry13, $entry14, $entry21) = self::populate_database_with_entries();
 
         $this->setUser($this->teacher);
         $this->assertEquals(1, $DB->get_field('data_records', 'approved', array('id' => $entry11)));
@@ -815,7 +828,7 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
      */
     public function test_approve_entry_missing_permissions() {
         global $DB;
-        list($entry11, $entry12, $entry13, $entry21) = self::populate_database_with_entries();
+        list($entry11, $entry12, $entry13, $entry14, $entry21) = self::populate_database_with_entries();
 
         $this->setUser($this->student1);
         $this->expectException('moodle_exception');
@@ -827,7 +840,7 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
      */
     public function test_delete_entry_as_teacher() {
         global $DB;
-        list($entry11, $entry12, $entry13, $entry21) = self::populate_database_with_entries();
+        list($entry11, $entry12, $entry13, $entry14, $entry21) = self::populate_database_with_entries();
 
         $this->setUser($this->teacher);
         $result = mod_data_external::delete_entry($entry11);
@@ -845,7 +858,7 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
      */
     public function test_delete_entry_as_student() {
         global $DB;
-        list($entry11, $entry12, $entry13, $entry21) = self::populate_database_with_entries();
+        list($entry11, $entry12, $entry13, $entry14, $entry21) = self::populate_database_with_entries();
 
         $this->setUser($this->student1);
         $result = mod_data_external::delete_entry($entry11);
@@ -858,7 +871,7 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
      */
     public function test_delete_entry_as_student_in_read_only_period() {
         global $DB;
-        list($entry11, $entry12, $entry13, $entry21) = self::populate_database_with_entries();
+        list($entry11, $entry12, $entry13, $entry14, $entry21) = self::populate_database_with_entries();
         // Set a time period.
         $this->database->timeviewfrom = time() - HOURSECS;
         $this->database->timeviewto = time() + HOURSECS;
@@ -874,7 +887,7 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
      */
     public function test_delete_entry_missing_permissions() {
         global $DB;
-        list($entry11, $entry12, $entry13, $entry21) = self::populate_database_with_entries();
+        list($entry11, $entry12, $entry13, $entry14, $entry21) = self::populate_database_with_entries();
 
         $this->setUser($this->student1);
         $this->expectException('moodle_exception');
@@ -887,7 +900,7 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
     public function test_add_entry() {
         global $DB;
         // First create the record structure and add some entries.
-        list($entry11, $entry12, $entry13, $entry21) = self::populate_database_with_entries();
+        list($entry11, $entry12, $entry13, $entry14, $entry21) = self::populate_database_with_entries();
 
         $this->setUser($this->student1);
         $newentrydata = [];
@@ -1038,7 +1051,7 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
      */
     public function test_add_entry_read_only_period() {
         global $DB;
-        list($entry11, $entry12, $entry13, $entry21) = self::populate_database_with_entries();
+        list($entry11, $entry12, $entry13, $entry14, $entry21) = self::populate_database_with_entries();
         // Set a time period.
         $this->database->timeviewfrom = time() - HOURSECS;
         $this->database->timeviewto = time() + HOURSECS;
@@ -1055,7 +1068,7 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
      */
     public function test_add_entry_max_num_entries() {
         global $DB;
-        list($entry11, $entry12, $entry13, $entry21) = self::populate_database_with_entries();
+        list($entry11, $entry12, $entry13, $entry14, $entry21) = self::populate_database_with_entries();
         // Set a time period.
         $this->database->maxentries = 1;
         $DB->update_record('data', $this->database);
@@ -1072,7 +1085,7 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
     public function test_update_entry() {
         global $DB;
         // First create the record structure and add some entries.
-        list($entry11, $entry12, $entry13, $entry21) = self::populate_database_with_entries();
+        list($entry11, $entry12, $entry13, $entry14, $entry21) = self::populate_database_with_entries();
 
         $this->setUser($this->student1);
         $newentrydata = [];
@@ -1212,7 +1225,7 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
      * Test update_entry sending empty data.
      */
     public function test_update_entry_empty_data() {
-        list($entry11, $entry12, $entry13, $entry21) = self::populate_database_with_entries();
+        list($entry11, $entry12, $entry13, $entry14, $entry21) = self::populate_database_with_entries();
 
         $this->setUser($this->student1);
         $result = mod_data_external::update_entry($entry11, []);
@@ -1228,7 +1241,7 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
      */
     public function test_update_entry_read_only_period() {
         global $DB;
-        list($entry11, $entry12, $entry13, $entry21) = self::populate_database_with_entries();
+        list($entry11, $entry12, $entry13, $entry14, $entry21) = self::populate_database_with_entries();
         // Set a time period.
         $this->database->timeviewfrom = time() - HOURSECS;
         $this->database->timeviewto = time() + HOURSECS;
@@ -1245,7 +1258,7 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
      */
     public function test_update_entry_other_user() {
         // Try to update other user entry.
-        list($entry11, $entry12, $entry13, $entry21) = self::populate_database_with_entries();
+        list($entry11, $entry12, $entry13, $entry14, $entry21) = self::populate_database_with_entries();
         $this->setUser($this->student2);
         $this->expectExceptionMessage(get_string('noaccess', 'data'));
         $this->expectException('moodle_exception');
@@ -1261,7 +1274,7 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
 
         $DB->set_field('data', 'assessed', RATING_AGGREGATE_SUM, array('id' => $this->database->id));
         $DB->set_field('data', 'scale', 100, array('id' => $this->database->id));
-        list($entry11, $entry12, $entry13, $entry21) = self::populate_database_with_entries();
+        list($entry11, $entry12, $entry13, $entry14, $entry21) = self::populate_database_with_entries();
 
         $user1 = self::getDataGenerator()->create_user();
         $user2 = self::getDataGenerator()->create_user();
index 07bd9a3..dbb0595 100644 (file)
@@ -146,17 +146,20 @@ function xmldb_feedback_upgrade($oldversion) {
         // Related values in feedback_value won't be deleted (they won't be used and can be kept there as a backup).
         $sql = "SELECT MAX(id) as maxid, userid, feedback, courseid
                   FROM {feedback_completed}
-                 WHERE userid <> 0
+                 WHERE userid <> 0 AND anonymous_response = :notanonymous
               GROUP BY userid, feedback, courseid
                 HAVING COUNT(id) > 1";
+        $params = ['notanonymous' => 2]; // FEEDBACK_ANONYMOUS_NO.
 
-        $duplicatedrows = $DB->get_recordset_sql($sql);
+        $duplicatedrows = $DB->get_recordset_sql($sql, $params);
         foreach ($duplicatedrows as $row) {
-            $DB->delete_records_select('feedback_completed', 'userid = ? AND feedback = ? AND courseid = ? AND id <> ?', array(
-                $row->userid,
-                $row->feedback,
-                $row->courseid,
-                $row->maxid,
+            $DB->delete_records_select('feedback_completed', 'userid = ? AND feedback = ? AND courseid = ? AND id <> ?'.
+                                                           ' AND anonymous_response = ?', array(
+                                           $row->userid,
+                                           $row->feedback,
+                                           $row->courseid,
+                                           $row->maxid,
+                                           2, // FEEDBACK_ANONYMOUS_NO.
             ));
         }
         $duplicatedrows->close();
index a892fd2..fec23df 100644 (file)
@@ -30,6 +30,9 @@ require_once ($CFG->dirroot.'/course/moodleform_mod.php');
 class mod_label_mod_form extends moodleform_mod {
 
     function definition() {
+        global $PAGE;
+
+        $PAGE->force_settings_menu();
 
         $mform = $this->_form;
 
index 9e3329e..4bc9b93 100644 (file)
@@ -223,6 +223,7 @@ $string['eventquestionanswered'] = 'Question answered';
 $string['eventquestionviewed'] = 'Question viewed';
 $string['false'] = 'False';
 $string['fileformat'] = 'File format';
+$string['finalwrong'] = 'Not quite.';
 $string['finish'] = 'Finish';
 $string['firstanswershould'] = 'First answer should jump to the "Correct" page';
 $string['firstwrong'] = 'You have answered incorrectly. Would you like to attempt the question again? (If you now answer the question correctly, it will not count towards your final score.)';
index 1253979..5eb0341 100644 (file)
@@ -4100,7 +4100,11 @@ abstract class lesson_page extends lesson_base {
                     if ($qattempts == 1) {
                         $result->feedback = $OUTPUT->box(get_string("firstwrong", "lesson"), 'feedback');
                     } else {
-                        $result->feedback = $OUTPUT->box(get_string("secondpluswrong", "lesson"), 'feedback');
+                        if (!$result->maxattemptsreached) {
+                            $result->feedback = $OUTPUT->box(get_string("secondpluswrong", "lesson"), 'feedback');
+                        } else {
+                            $result->feedback = $OUTPUT->box(get_string("finalwrong", "lesson"), 'feedback');
+                        }
                     }
                 } else {
                     $result->feedback = '';
index 1cf58c5..547ea0e 100644 (file)
@@ -33,6 +33,7 @@ require_once($CFG->dirroot . '/mod/lti/locallib.php');
 $response = new \mod_lti\local\ltiservice\response();
 
 $isget = $response->get_request_method() == 'GET';
+$isdelete = $response->get_request_method() == 'DELETE';
 
 if ($isget) {
     $response->set_accept(isset($_SERVER['HTTP_ACCEPT']) ? $_SERVER['HTTP_ACCEPT'] : '');
@@ -51,7 +52,7 @@ foreach ($services as $service) {
     foreach ($resources as $resource) {
         if (($isget && !empty($accept) && (strpos($accept, '*/*') === false) &&
              !in_array($accept, $resource->get_formats())) ||
-            (!$isget && !in_array($response->get_content_type(), $resource->get_formats()))) {
+            ((!$isget && !$isdelete) && !in_array($response->get_content_type(), $resource->get_formats()))) {
             continue;
         }
         $template = $resource->get_template();
index 260512a..8b10f1e 100644 (file)
@@ -161,8 +161,9 @@ class mod_page_external extends external_api {
                                                                 $page->introformat, $context->id, 'mod_page', 'intro', null);
                 $page->introfiles = external_util::get_area_files($context->id, 'mod_page', 'intro', false, false);
 
+                $options = array('noclean' => true);
                 list($page->content, $page->contentformat) = external_format_text($page->content, $page->contentformat,
-                                                                $context->id, 'mod_page', 'content', $page->revision);
+                                                                $context->id, 'mod_page', 'content', $page->revision, $options);
                 $page->contentfiles = external_util::get_area_files($context->id, 'mod_page', 'content');
 
                 $returnedpages[] = $page;
index 3156b1b..7b75829 100644 (file)
@@ -1093,6 +1093,18 @@ class quiz_attempt {
         return $activeslots;
     }
 
+    /**
+     * Helper method for unit tests. Get the underlying question usage object.
+     * @return question_usage_by_activity the usage.
+     */
+    public function get_question_usage() {
+        if (!PHPUNIT_TEST) {
+            throw new coding_exception('get_question_usage is only for use in unit tests. ' .
+                    'For other operations, use the quiz_attempt api, or extend it properly.');
+        }
+        return $this->quba;
+    }
+
     /**
      * Get the question_attempt object for a particular question in this attempt.
      * @param int $slot the number used to identify this question within this attempt.
index f3db929..2f67383 100644 (file)
@@ -167,6 +167,7 @@ class custom_view extends \core_question\bank\view {
             $params = array(
                     'type' => 'submit',
                     'name' => 'add',
+                    'class' => 'btn btn-primary',
                     'value' => get_string('addselectedquestionstoquiz', 'quiz'),
             );
             if ($cmoptions->hasattempts) {
index 4545a70..06614eb 100644 (file)
@@ -138,6 +138,48 @@ abstract class quiz_attempts_report extends quiz_default_report {
         return array($currentgroup, $studentsjoins, $groupstudentsjoins, $groupstudentsjoins);
     }
 
+    /**
+     * Outputs the things you commonly want at the top of a quiz report.
+     *
+     * Calls through to {@link print_header_and_tabs()} and then
+     * outputs the standard group selector, number of attempts summary,
+     * and messages to cover common cases when the report can't be shown.
+     *
+     * @param stdClass $cm the course_module information.
+     * @param stdClass $course the course settings.
+     * @param stdClass $quiz the quiz settings.
+     * @param mod_quiz_attempts_report_options $options the current report settings.
+     * @param int $currentgroup the current group.
+     * @param bool $hasquestions whether there are any questions in the quiz.
+     * @param bool $hasstudents whether there are any relevant students.
+     */
+    protected function print_standard_header_and_messages($cm, $course, $quiz,
+            $options, $currentgroup, $hasquestions, $hasstudents) {
+        global $OUTPUT;
+
+        $this->print_header_and_tabs($cm, $course, $quiz, $this->mode);
+
+        if (groups_get_activity_groupmode($cm)) {
+            // Groups are being used, so output the group selector if we are not downloading.
+            groups_print_activity_menu($cm, $options->get_url());
+        }
+
+        // Print information on the number of existing attempts.
+        if ($strattemptnum = quiz_num_attempt_summary($quiz, $cm, true, $currentgroup)) {
+            echo '<div class="quizattemptcounts">' . $strattemptnum . '</div>';
+        }
+
+        if (!$hasquestions) {
+            echo quiz_no_questions_message($quiz, $cm, $this->context);
+        } else if ($currentgroup == self::NO_GROUPS_ALLOWED) {
+            echo $OUTPUT->notification(get_string('notingroup'));
+        } else if (!$hasstudents) {
+            echo $OUTPUT->notification(get_string('nostudentsyet'));
+        } else if ($currentgroup && !$this->hasgroupstudents) {
+            echo $OUTPUT->notification(get_string('nostudentsingroup'));
+        }
+    }
+
     /**
      * Add all the user-related columns to the $columns and $headers arrays.
      * @param table_sql $table the table being constructed.
index 539df5a..109c487 100644 (file)
@@ -467,6 +467,35 @@ abstract class quiz_attempts_report_table extends table_sql {
         return array($fields, $from, $where, $params);
     }
 
+    /**
+     * A chance for subclasses to modify the SQL after the count query has been generated,
+     * and before the full query is constructed.
+     * @param string $fields SELECT list.
+     * @param string $from JOINs part of the SQL.
+     * @param string $where WHERE clauses.
+     * @param array $params Query params.
+     * @return array with 4 elements ($fields, $from, $where, $params) as from base_sql.
+     */
+    protected function update_sql_after_count($fields, $from, $where, $params) {
+        return [$fields, $from, $where, $params];
+    }
+
+    /**
+     * Set up the SQL queries (count rows, and get data).
+     *
+     * @param \core\dml\sql_join $allowedjoins (joins, wheres, params) defines allowed users for the report.
+     */
+    public function setup_sql_queries($allowedjoins) {
+        list($fields, $from, $where, $params) = $this->base_sql($allowedjoins);
+
+        // The WHERE clause is vital here, because some parts of tablelib.php will expect to
+        // add bits like ' AND x = 1' on the end, and that needs to leave to valid SQL.
+        $this->set_count_sql("SELECT COUNT(1) FROM (SELECT $fields FROM $from WHERE $where) temp WHERE 1 = 1", $params);
+
+        list($fields, $from, $where, $params) = $this->update_sql_after_count($fields, $from, $where, $params);
+        $this->set_sql($fields, $from, $where, $params);
+    }
+
     /**
      * Add the information about the latest state of the question with slot
      * $slot to the query.
@@ -515,8 +544,12 @@ abstract class quiz_attempts_report_table extends table_sql {
 
         if ($this->is_downloading()) {
             // We want usages for all attempts.
-            return new qubaid_join($this->sql->from, 'quiza.uniqueid',
-                    $this->sql->where, $this->sql->params);
+            return new qubaid_join("(
+                SELECT DISTINCT quiza.uniqueid
+                  FROM " . $this->sql->from . "
+                 WHERE " . $this->sql->where . "
+                    ) quizasubquery", 'quizasubquery.uniqueid',
+                    "1 = 1", $this->sql->params);
         }
 
         $qubaids = array();
index 2f73e0a..ecb0355 100644 (file)
@@ -421,7 +421,7 @@ class quiz_grading_report extends quiz_default_report {
         }
 
         echo html_writer::tag('div', html_writer::empty_tag('input', array(
-                'type' => 'submit', 'value' => get_string('saveandnext', 'quiz_grading'))),
+                'type' => 'submit', 'class' => 'btn btn-primary', 'value' => get_string('saveandnext', 'quiz_grading'))),
                 array('class' => 'mdl-align')) .
                 html_writer::end_tag('div') . html_writer::end_tag('form');
     }
index 616e1ff..6e0e5cc 100644 (file)
@@ -295,6 +295,22 @@ class quiz_overview_table extends quiz_attempts_report_table {
         }
     }
 
+    protected function update_sql_after_count($fields, $from, $where, $params) {
+        $fields .= ", COALESCE((
+                                SELECT MAX(qqr.regraded)
+                                  FROM {quiz_overview_regrades} qqr
+                                 WHERE qqr.questionusageid = quiza.uniqueid
+                          ), -1) AS regraded";
+        if ($this->options->onlyregraded) {
+            $where .= " AND COALESCE((
+                                    SELECT MAX(qqr.regraded)
+                                      FROM {quiz_overview_regrades} qqr
+                                     WHERE qqr.questionusageid = quiza.uniqueid
+                                ), -1) <> -1";
+        }
+        return [$fields, $from, $where, $params];
+    }
+
     protected function requires_latest_steps_loaded() {
         return $this->options->slotmarks;
     }
index 9f6ebc2..e5c0e93 100644 (file)
@@ -98,36 +98,13 @@ class quiz_overview_report extends quiz_attempts_report {
         $this->course = $course; // Hack to make this available in process_actions.
         $this->process_actions($quiz, $cm, $currentgroup, $groupstudentsjoins, $allowedjoins, $options->get_url());
 
+        $hasquestions = quiz_has_questions($quiz->id);
+
         // Start output.
         if (!$table->is_downloading()) {
             // Only print headers if not asked to download data.
-            $this->print_header_and_tabs($cm, $course, $quiz, $this->mode);
-        }
-
-        if ($groupmode = groups_get_activity_groupmode($cm)) {
-            // Groups are being used, so output the group selector if we are not downloading.
-            if (!$table->is_downloading()) {
-                groups_print_activity_menu($cm, $options->get_url());
-            }
-        }
-
-        // Print information on the number of existing attempts.
-        if (!$table->is_downloading()) {
-            // Do not print notices when downloading.
-            if ($strattemptnum = quiz_num_attempt_summary($quiz, $cm, true, $currentgroup)) {
-                echo '<div class="quizattemptcounts">' . $strattemptnum . '</div>';
-            }
-        }
-
-        $hasquestions = quiz_has_questions($quiz->id);
-        if (!$table->is_downloading()) {
-            if (!$hasquestions) {
-                echo quiz_no_questions_message($quiz, $cm, $this->context);
-            } else if (!$hasstudents) {
-                echo $OUTPUT->notification(get_string('nostudentsyet'));
-            } else if ($currentgroup && !$this->hasgroupstudents) {
-                echo $OUTPUT->notification(get_string('nostudentsingroup'));
-            }
+            $this->print_standard_header_and_messages($cm, $course, $quiz,
+                    $options, $currentgroup, $hasquestions, $hasstudents);
 
             // Print the display options.
             $this->form->display();
@@ -136,26 +113,7 @@ class quiz_overview_report extends quiz_attempts_report {
         $hasstudents = $hasstudents && (!$currentgroup || $this->hasgroupstudents);
         if ($hasquestions && ($hasstudents || $options->attempts == self::ALL_WITH)) {
             // Construct the SQL.
-            list($fields, $from, $where, $params) = $table->base_sql($allowedjoins);
-
-            // The WHERE clause is vital here, because some parts of tablelib.php will expect to
-            // add bits like ' AND x = 1' on the end, and that needs to leave to valid SQL.
-            $table->set_count_sql("SELECT COUNT(1) FROM (SELECT $fields FROM $from WHERE $where) temp WHERE 1 = 1", $params);
-
-            // Test to see if there are any regraded attempts to be listed.
-            $fields .= ", COALESCE((
-                                SELECT MAX(qqr.regraded)
-                                  FROM {quiz_overview_regrades} qqr
-                                 WHERE qqr.questionusageid = quiza.uniqueid
-                          ), -1) AS regraded";
-            if ($options->onlyregraded) {
-                $where .= " AND COALESCE((
-                                    SELECT MAX(qqr.regraded)
-                                      FROM {quiz_overview_regrades} qqr
-                                     WHERE qqr.questionusageid = quiza.uniqueid
-                                ), -1) <> -1";
-            }
-            $table->set_sql($fields, $from, $where, $params);
+            $table->setup_sql_queries($allowedjoins);
 
             if (!$table->is_downloading()) {
                 // Output the regrade buttons.
@@ -220,7 +178,7 @@ class quiz_overview_report extends quiz_attempts_report {
             $this->add_grade_columns($quiz, $options->usercanseegrades, $columns, $headers, false);
 
             if (!$table->is_downloading() && has_capability('mod/quiz:regrade', $this->context) &&
-                    $this->has_regraded_questions($from, $where, $params)) {
+                    $this->has_regraded_questions($table->sql->from, $table->sql->where, $table->sql->params)) {
                 $columns[] = 'regraded';
                 $headers[] = get_string('regrade', 'quiz_overview');
             }
index 8612ffd..8799c79 100644 (file)
@@ -1,4 +1,4 @@
-@mod @mod_quiz
+@mod @mod_quiz @quiz @quiz_overview
 Feature: Basic use of the Grades report
   In order to easily get an overview of quiz attempts
   As a teacher
index 81aece2..0438155 100644 (file)
@@ -39,10 +39,26 @@ require_once($CFG->dirroot . '/mod/quiz/report/overview/report.php');
  */
 class quiz_overview_report_testcase extends advanced_testcase {
 
-    public function test_report_sql() {
+    /**
+     * Data provider for test_report_sql.
+     *
+     * @return array the data for the test sub-cases.
+     */
+    public function report_sql_cases() {
+        return [[null], ['csv']]; // Only need to test on or off, not all download types.
+    }
+
+    /**
+     * Test how the report queries the database.
+     *
+     * @param bool $isdownloading a download type, or null.
+     * @dataProvider report_sql_cases
+     */
+    public function test_report_sql($isdownloading) {
         global $DB;
         $this->resetAfterTest(true);
 
+        // Create a course and a quiz.
         $generator = $this->getDataGenerator();
         $course = $generator->create_course();
         $quizgenerator = $generator->get_plugin_generator('mod_quiz');
@@ -50,54 +66,90 @@ class quiz_overview_report_testcase extends advanced_testcase {
                 'grademethod' => QUIZ_GRADEHIGHEST, 'grade' => 100.0, 'sumgrades' => 10.0,
                 'attempts' => 10));
 
+        // Add one question.
+        $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
+        $cat = $questiongenerator->create_question_category();
+        $q = $questiongenerator->create_question('essay', 'plain', ['category' => $cat->id]);
+        quiz_add_quiz_question($q->id, $quiz, 0 , 10);
+
+        // Create some students and enrol them in the course.
         $student1 = $generator->create_user();
         $student2 = $generator->create_user();
         $student3 = $generator->create_user();
         $generator->enrol_user($student1->id, $course->id);
         $generator->enrol_user($student2->id, $course->id);
         $generator->enrol_user($student3->id, $course->id);
-
-        $timestamp = 1234567890;
+        // This line is not really necessary for the test asserts below,
+        // but what it does is add an extra user row returned by
+        // get_enrolled_with_capabilities_join because of a second enrolment.
+        // The extra row returned used to make $table->query_db complain
+        // about duplicate records. So this is really a test that an extra
+        // student enrolment does not cause duplicate records in this query.
+        $generator->enrol_user($student2->id, $course->id, null, 'self');
 
         // The test data.
+        $timestamp = 1234567890;
         $fields = array('quiz', 'userid', 'attempt', 'sumgrades', 'state');
         $attempts = array(
-            array($quiz->id, $student1->id, 1, 0.0,  quiz_attempt::FINISHED),
-            array($quiz->id, $student1->id, 2, 5.0,  quiz_attempt::FINISHED),
-            array($quiz->id, $student1->id, 3, 8.0,  quiz_attempt::FINISHED),
-            array($quiz->id, $student1->id, 4, null, quiz_attempt::ABANDONED),
-            array($quiz->id, $student1->id, 5, null, quiz_attempt::IN_PROGRESS),
-            array($quiz->id, $student2->id, 1, null, quiz_attempt::ABANDONED),
-            array($quiz->id, $student2->id, 2, null, quiz_attempt::ABANDONED),
-            array($quiz->id, $student2->id, 3, 7.0,  quiz_attempt::FINISHED),
-            array($quiz->id, $student2->id, 4, null, quiz_attempt::ABANDONED),
-            array($quiz->id, $student2->id, 5, null, quiz_attempt::ABANDONED),
+            array($quiz, $student1, 1, 0.0,  quiz_attempt::FINISHED),
+            array($quiz, $student1, 2, 5.0,  quiz_attempt::FINISHED),
+            array($quiz, $student1, 3, 8.0,  quiz_attempt::FINISHED),
+            array($quiz, $student1, 4, null, quiz_attempt::ABANDONED),
+            array($quiz, $student1, 5, null, quiz_attempt::IN_PROGRESS),
+            array($quiz, $student2, 1, null, quiz_attempt::ABANDONED),
+            array($quiz, $student2, 2, null, quiz_attempt::ABANDONED),
+            array($quiz, $student2, 3, 7.0,  quiz_attempt::FINISHED),
+            array($quiz, $student2, 4, null, quiz_attempt::ABANDONED),
+            array($quiz, $student2, 5, null, quiz_attempt::ABANDONED),
         );
 
         // Load it in to quiz attempts table.
-        $uniqueid = 1;
-        foreach ($attempts as $attempt) {
-            $data = array_combine($fields, $attempt);
-            $data['timestart'] = $timestamp + 3600 * $data['attempt'];
-            $data['timemodifed'] = $data['timestart'];
-            if ($data['state'] == quiz_attempt::FINISHED) {
-                $data['timefinish'] = $data['timestart'] + 600;
-                $data['timemodifed'] = $data['timefinish'];
+        foreach ($attempts as $attemptdata) {
+            list($quiz, $student, $attemptnumber, $sumgrades, $state) = $attemptdata;
+            $timestart = $timestamp + $attemptnumber * 3600;
+
+            $quizobj = quiz::create($quiz->id, $student->id);
+            $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
+            $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
+
+            // Create the new attempt and initialize the question sessions.
+            $attempt = quiz_create_attempt($quizobj, $attemptnumber, null, $timestart, false, $student->id);
+
+            $attempt = quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timestamp);
+            $attempt = quiz_attempt_save_started($quizobj, $quba, $attempt);
+
+            // Process some responses from the student.
+            $attemptobj = quiz_attempt::create($attempt->id);
+            switch ($state) {
+                case quiz_attempt::ABANDONED:
+                    $attemptobj->process_abandon($timestart + 300, false);
+                    break;
+
+                case quiz_attempt::IN_PROGRESS:
+                    // Do nothing.
+                    break;
+
+                case quiz_attempt::FINISHED:
+                    // Save answer and finish attempt.
+                    $attemptobj->process_submitted_actions($timestart + 300, false, [
+                            1 => ['answer' => 'My essay by ' . $student->firstname, 'answerformat' => FORMAT_PLAIN]]);
+                    $attemptobj->process_finish($timestart + 600, false);
+
+                    // Manually grade it.
+                    $quba = $attemptobj->get_question_usage();
+                    $quba->get_question_attempt(1)->manual_grade(
+                            'Comment', $sumgrades, FORMAT_HTML, $timestart + 1200);
+                    question_engine::save_questions_usage_by_activity($quba);
+                    $update = new stdClass();
+                    $update->id = $attemptobj->get_attemptid();
+                    $update->timemodified = $timestart + 1200;
+                    $update->sumgrades = $quba->get_total_mark();
+                    $DB->update_record('quiz_attempts', $update);
+                    quiz_save_best_grade($attemptobj->get_quiz(), $student->id);
+                    break;
             }
-            $data['layout'] = ''; // Not used, but cannot be null.
-            $data['uniqueid'] = $uniqueid++;
-            $data['preview'] = 0;
-            $DB->insert_record('quiz_attempts', $data);
         }
 
-        // This line is not really necessary for the test asserts below,
-        // but what it does is add an extra user row returned by
-        // get_enrolled_with_capabilities_join because of a second enrolment.
-        // The extra row returned used to make $table->query_db complain
-        // about duplicate records. So this is really a test that an extra
-        // student enrolment does not cause duplicate records in this query.
-        $generator->enrol_user($student2->id, $course->id, null, 'self');
-
         // Actually getting the SQL to run is quite hard. Do a minimal set up of
         // some objects.
         $context = context_module::instance($quiz->cmid);
@@ -114,18 +166,23 @@ class quiz_overview_report_testcase extends advanced_testcase {
 
         // Now do a minimal set-up of the table class.
         $table = new quiz_overview_table($quiz, $context, $qmsubselect, $reportoptions,
-                $empty, $studentsjoins, array(1), null);
+                $empty, $studentsjoins, array(1 => $q), null);
+        $table->download = $isdownloading; // Cannot call the is_downloading API, because it gives errors.
         $table->define_columns(array('fullname'));
         $table->sortable(true, 'uniqueid');
         $table->define_baseurl(new moodle_url('/mod/quiz/report.php'));
         $table->setup();
 
         // Run the query.
-        list($fields, $from, $where, $params) = $table->base_sql($studentsjoins);
-        $table->set_sql($fields, $from, $where, $params);
-        $table->set_count_sql("SELECT COUNT(1) FROM (SELECT $fields FROM $from WHERE $where) temp WHERE 1 = 1", $params);
+        $table->setup_sql_queries($studentsjoins);
         $table->query_db(30, false);
 
+        // Should be 4 rows, matching count($table->rawdata) tested below.
+        // The count is only done if not downloading.
+        if (!$isdownloading) {
+            $this->assertEquals(4, $table->totalrows);
+        }
+
         // Verify what was returned: Student 1's best and in progress attempts.
         // Student 2's finshed attempt, and Student 3 with no attempt.
         // The array key is {student id}#{attempt number}.
index 4a90a08..4f2600a 100644 (file)
@@ -68,6 +68,15 @@ class quiz_first_or_all_responses_table extends quiz_last_responses_table {
         // Insert an extra field in attempt data and extra rows where necessary.
         $newrawdata = array();
         foreach ($this->rawdata as $attempt) {
+            if (!isset($this->questionusagesbyactivity[$attempt->usageid])) {
+                // This is a user without attempts.
+                $attempt->try = 0;
+                $attempt->lasttryforallparts = true;
+                $newrawdata[] = $attempt;
+                continue;
+            }
+
+            // We have an attempt, which may require several rows.
             $maxtriesinanyslot = 1;
             foreach ($this->questionusagesbyactivity[$attempt->usageid]->get_slots() as $slot) {
                 $tries = $this->get_no_of_tries($attempt, $slot);
@@ -230,7 +239,7 @@ class quiz_first_or_all_responses_table extends quiz_last_responses_table {
      * @return string   What to put in the cell for this column, for this row data.
      */
     public function col_email($tablerow) {
-        if ($tablerow->try != 1) {
+        if ($tablerow->try > 1) {
             return '';
         } else {
             return $tablerow->email;
@@ -244,18 +253,27 @@ class quiz_first_or_all_responses_table extends quiz_last_responses_table {
      * @return string   What to put in the cell for this column, for this row data.
      */
     public function col_sumgrades($tablerow) {
-        if (!$tablerow->lasttryforallparts) {
+        if ($tablerow->try == 0) {
+            // We are showing a user without a quiz attempt.
+            return '-';
+        } else if (!$tablerow->lasttryforallparts) {
+            // There are more rows to come for this quiz attempt, so we will show this later.
             return '';
         } else {
+            // Last row for this attempt. Now is the time to show attempt-related data.
             return parent::col_sumgrades($tablerow);
         }
     }
 
-
     public function col_state($tablerow) {
-        if (!$tablerow->lasttryforallparts) {
+        if ($tablerow->try == 0) {
+            // We are showing a user without a quiz attempt.
+            return '-';
+        } else if (!$tablerow->lasttryforallparts) {
+            // There are more rows to come for this quiz attempt, so we will show this later.
             return '';
         } else {
+            // Last row for this attempt. Now is the time to show attempt-related data.
             return parent::col_state($tablerow);
         }
     }
index 45f393a..693029e 100644 (file)
@@ -111,36 +111,13 @@ class quiz_responses_report extends quiz_attempts_report {
 
         $this->process_actions($quiz, $cm, $currentgroup, $groupstudentsjoins, $allowedjoins, $options->get_url());
 
+        $hasquestions = quiz_has_questions($quiz->id);
+
         // Start output.
         if (!$table->is_downloading()) {
             // Only print headers if not asked to download data.
-            $this->print_header_and_tabs($cm, $course, $quiz, $this->mode);
-        }
-
-        if ($groupmode = groups_get_activity_groupmode($cm)) {
-            // Groups are being used, so output the group selector if we are not downloading.
-            if (!$table->is_downloading()) {
-                groups_print_activity_menu($cm, $options->get_url());
-            }
-        }
-
-        // Print information on the number of existing attempts.
-        if (!$table->is_downloading()) {
-            // Do not print notices when downloading.
-            if ($strattemptnum = quiz_num_attempt_summary($quiz, $cm, true, $currentgroup)) {
-                echo '<div class="quizattemptcounts">' . $strattemptnum . '</div>';
-            }
-        }
-
-        $hasquestions = quiz_has_questions($quiz->id);
-        if (!$table->is_downloading()) {
-            if (!$hasquestions) {
-                echo quiz_no_questions_message($quiz, $cm, $this->context);
-            } else if (!$hasstudents) {
-                echo $OUTPUT->notification(get_string('nostudentsyet'));
-            } else if ($currentgroup && !$this->hasgroupstudents) {
-                echo $OUTPUT->notification(get_string('nostudentsingroup'));
-            }
+            $this->print_standard_header_and_messages($cm, $course, $quiz,
+                    $options, $currentgroup, $hasquestions, $hasstudents);
 
             // Print the display options.
             $this->form->display();
@@ -149,13 +126,7 @@ class quiz_responses_report extends quiz_attempts_report {
         $hasstudents = $hasstudents && (!$currentgroup || $this->hasgroupstudents);
         if ($hasquestions && ($hasstudents || $options->attempts == self::ALL_WITH)) {
 
-            list($fields, $from, $where, $params) = $table->base_sql($allowedjoins);
-
-            // The WHERE clause is vital here, because some parts of tablelib.php will expect to
-            // add bits like ' AND x = 1' on the end, and that needs to leave to valid SQL.
-            $table->set_count_sql("SELECT COUNT(1) FROM (SELECT $fields FROM $from WHERE $where) temp WHERE 1 = 1", $params);
-
-            $table->set_sql($fields, $from, $where, $params);
+            $table->setup_sql_queries($allowedjoins);
 
             if (!$table->is_downloading()) {
                 // Print information on the grading method.
index 461dcd2..4fdbe90 100644 (file)
@@ -70,6 +70,7 @@ class quiz_responses_settings_form extends mod_quiz_attempts_report_form {
                                            question_attempt::ALL_TRIES    => get_string('alltries', 'question'))
             );
             $mform->setDefault('whichtries', question_attempt::LAST_TRY);
+            $mform->disabledIf('whichtries', 'attempts', 'eq', quiz_attempts_report::ENROLLED_WITHOUT);
         }
     }
 }
diff --git a/mod/quiz/report/responses/tests/behat/basic.feature b/mod/quiz/report/responses/tests/behat/basic.feature
new file mode 100644 (file)
index 0000000..b8344ea
--- /dev/null
@@ -0,0 +1,91 @@
+@mod @mod_quiz @quiz @quiz_reponses
+Feature: Basic use of the Responses report
+  In order to see how my students are progressing
+  As a teacher
+  I need to see all their quiz responses
+
+  Background: Using the Responses report
+    Given the following "users" exist:
+      | username | firstname | lastname |
+      | teacher  | The       | Teacher  |
+      | student1 | Student   | One      |
+      | student2 | Student   | Two      |
+    And the following "courses" exist:
+      | fullname | shortname |
+      | Course 1 | C1        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher  | C1     | editingteacher |
+      | student1 | C1     | student        |
+      | student2 | C1     | student        |
+    And the following "question categories" exist:
+      | contextlevel | reference | name           |
+      | Course       | C1        | Test questions |
+    And the following "activities" exist:
+      | activity   | name   | intro              | course | idnumber | preferredbehaviour |
+      | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    | interactive        |
+    And the following "questions" exist:
+      | questioncategory | qtype     | name | template |
+      | Test questions   | numerical | NQ   | pi3tries |
+    And quiz "Quiz 1" contains the following questions:
+      | question | page | maxmark |
+      | NQ       | 1    | 3.0     |
+
+  @javascript
+  Scenario: Report works when there are no attempts
+    When I log in as "teacher"
+    And I am on "Course 1" course homepage
+    And I follow "Quiz 1"
+    And I navigate to "Results > Responses" in current page administration
+    Then I should see "Attempts: 0"
+    And I should see "Nothing to display"
+    And I set the field "Attempts from" to "enrolled users who have not attempted the quiz"
+    And I press "Show report"
+    And "Student One" row "State" column of "responses" table should contain "-"
+
+  @javascript
+  Scenario: Report works when there are attempts
+    # Add an attempt
+    Given I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I follow "Quiz 1"
+    And I press "Attempt quiz now"
+    And I set the field "Answer" to "1.0"
+    And I press "Check"
+    And I press "Try again"
+    And I set the field "Answer" to "3.0"
+    And I press "Check"
+    And I press "Try again"
+    And I set the field "Answer" to "3.14"
+    And I press "Check"
+    And I press "Finish attempt ..."
+    And I press "Submit all and finish"
+    And I click on "Submit all and finish" "button" in the "Confirmation" "dialogue"
+    And I log out
+
+    When I log in as "teacher"
+    And I am on "Course 1" course homepage
+    And I follow "Quiz 1"
+    And I navigate to "Results > Responses" in current page administration
+    Then I should see "Attempts: 1"
+    And I should see "Student One"
+    And I should not see "Student Two"
+    And I set the field "Attempts from" to "enrolled users who have, or have not, attempted the quiz"
+    And I set the field "Which tries" to "All tries"
+    And I press "Show report"
+    And "Student OneReview attempt" row "Response 1Sort by Response 1 Ascending" column of "responses" table should contain "1.0"
+    And "Student OneReview attempt" row "State" column of "responses" table should contain ""
+    And "Finished" row "Grade/100.00Sort by Grade/100.00 Ascending" column of "responses" table should contain "33.33"
+    And "Finished" row "Response 1Sort by Response 1 Ascending" column of "responses" table should contain "3.14"
+    And "Student Two" row "State" column of "responses" table should contain "-"
+    And "Student Two" row "Response 1Sort by Response 1 Ascending" column of "responses" table should contain "-"
+
+  @javascript
+  Scenario: Report does not allow strange combinations of options
+    When I log in as "teacher"
+    And I am on "Course 1" course homepage
+    And I follow "Quiz 1"
+    And I navigate to "Results > Responses" in current page administration
+    And the "Which tries" "select" should be enabled
+    And I set the field "Attempts from" to "enrolled users who have not attempted the quiz"
+    Then the "Which tries" "select" should be disabled
index f8bbf1c..41b9771 100644 (file)
@@ -1066,6 +1066,11 @@ table#categoryquestions {
     padding-right: 0.3em;
 }
 
+.questionbankformforpopup .modulespecificbuttonscontainer {
+    padding-top: 10px;
+    padding-left: 0;
+}
+
 .quizquestionlistcontrols {
     text-align: center;
 }
index 829f0d3..7657fb6 100644 (file)
@@ -1728,6 +1728,18 @@ class mod_workshop_external extends external_api {
         $feedbackform = $workshop->get_feedbackreviewer_form(null, $assessment, $options);
 
         $errors = $feedbackform->validation((array) $data, array());
+        // Extra checks for the new grade and weight.
+        $possibleweights = workshop::available_assessment_weights_list();
+        if ($data->weight < 0 || $data->weight > max(array_keys($possibleweights))) {
+            $errors['weight'] = 'The new weight must be higher or equal to 0 and cannot be higher than the maximum weight for
+                assessment.';
+        }
+        if (is_numeric($data->gradinggradeover) &&
+                ($data->gradinggradeover < 0 || $data->gradinggradeover > $workshop->gradinggrade)) {
+            $errors['gradinggradeover'] = 'The new grade must be higher or equal to 0 and cannot be higher than the maximum grade
+                for assessment.';
+        }
+
         // We can get several errors, return them in warnings.
         if (!empty($errors)) {
             $status = false;
index 8d26175..a725af5 100644 (file)
@@ -1612,13 +1612,13 @@ class mod_workshop_external_testcase extends externallib_advanced_testcase {
         $submissionid = $workshopgenerator->create_submission($this->workshop->id, $this->student->id);
         $assessmentid = $workshopgenerator->create_assessment($submissionid, $this->anotherstudentg1->id, array(
             'weight' => 3,
-            'grade' => 95,
+            'grade' => 20,
         ));
 
         $this->setUser($this->teacher);
         $feedbacktext = 'The feedback';
         $feedbackformat = FORMAT_MOODLE;
-        $weight = 25;
+        $weight = 10;
         $gradinggradeover = 10;
         $result = mod_workshop_external::evaluate_assessment($assessmentid, $feedbacktext, $feedbackformat, $weight,
             $gradinggradeover);
@@ -1627,7 +1627,23 @@ class mod_workshop_external_testcase extends externallib_advanced_testcase {
 
         $assessment = $DB->get_record('workshop_assessments', array('id' => $assessmentid));
         $this->assertEquals('The feedback', $assessment->feedbackreviewer);
-        $this->assertEquals(25, $assessment->weight);
+        $this->assertEquals(10, $assessment->weight);
+
+        // Now test passing incorrect weight and grade values.
+        $weight = 17;
+        $gradinggradeover = 100;
+        $result = mod_workshop_external::evaluate_assessment($assessmentid, $feedbacktext, $feedbackformat, $weight,
+            $gradinggradeover);
+        $result = external_api::clean_returnvalue(mod_workshop_external::evaluate_assessment_returns(), $result);
+        $this->assertFalse($result['status']);
+        $this->assertCount(2, $result['warnings']);
+        $found = 0;
+        foreach ($result['warnings'] as $warning) {
+            if ($warning['item'] == 'weight' || $warning['item'] == 'gradinggradeover') {
+                $found++;
+            }
+        }
+        $this->assertEquals(2, $found);
     }
 
     /**
@@ -1638,7 +1654,7 @@ class mod_workshop_external_testcase extends externallib_advanced_testcase {
         $submissionid = $workshopgenerator->create_submission($this->workshop->id, $this->student->id);
         $assessmentid = $workshopgenerator->create_assessment($submissionid, $this->anotherstudentg1->id, array(
             'weight' => 3,
-            'grade' => 95,
+            'grade' => 20,
         ));
 
         assign_capability('mod/workshop:allocate', CAP_PROHIBIT, $this->teacherrole->id, $this->context->id);
@@ -1648,8 +1664,8 @@ class mod_workshop_external_testcase extends externallib_advanced_testcase {
         $this->setUser($this->teacher);
         $feedbacktext = 'The feedback';
         $feedbackformat = FORMAT_MOODLE;
-        $weight = 25;
-        $gradinggradeover = 1000;
+        $weight = 10;
+        $gradinggradeover = 19;
         $result = mod_workshop_external::evaluate_assessment($assessmentid, $feedbacktext, $feedbackformat, $weight,
             $gradinggradeover);
         $result = external_api::clean_returnvalue(mod_workshop_external::evaluate_assessment_returns(), $result);
@@ -1657,7 +1673,7 @@ class mod_workshop_external_testcase extends externallib_advanced_testcase {
 
         $result = mod_workshop_external::get_assessment($assessmentid);
         $result = external_api::clean_returnvalue(mod_workshop_external::get_assessment_returns(), $result);
-        $this->assertNotEquals(25, $result['assessment']['weight']);
+        $this->assertNotEquals(10, $result['assessment']['weight']);
     }
 
     /**
@@ -1668,13 +1684,13 @@ class mod_workshop_external_testcase extends externallib_advanced_testcase {
         $submissionid = $workshopgenerator->create_submission($this->workshop->id, $this->student->id);
         $assessmentid = $workshopgenerator->create_assessment($submissionid, $this->anotherstudentg1->id, array(
             'weight' => 3,
-            'grade' => 95,
+            'grade' => 20,
         ));
 
         $this->setUser($this->student);
         $feedbacktext = 'The feedback';
         $feedbackformat = FORMAT_MOODLE;
-        $weight = 25;
+        $weight = 10;
         $gradinggradeover = 50;
         $this->expectException('moodle_exception');
         mod_workshop_external::evaluate_assessment($assessmentid, $feedbacktext, $feedbackformat, $weight, $gradinggradeover);
index ecc7d01..3a79c54 100644 (file)
@@ -22,6 +22,6 @@
     "xpath": "0.0.23"
   },
   "engines": {
-    "node": ">=4"
+    "node": "8.9"
   }
 }
diff --git a/pix/i/calendareventdescription.png b/pix/i/calendareventdescription.png
new file mode 100644 (file)
index 0000000..5ab349c
Binary files /dev/null and b/pix/i/calendareventdescription.png differ
diff --git a/pix/i/calendareventdescription.svg b/pix/i/calendareventdescription.svg
new file mode 100644 (file)
index 0000000..986b0a2
--- /dev/null
@@ -0,0 +1,3 @@
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
+       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
+]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 -1 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M0 12h16v2H0v-2zm10-3H0v2h10V9zM0 8h16V6H0v2zm10-5H0v2h10V3zm6-1V0H0v2h16z" fill="#999"/></svg>
\ No newline at end of file
diff --git a/pix/i/calendareventtime.png b/pix/i/calendareventtime.png
new file mode 100644 (file)
index 0000000..944f36b
Binary files /dev/null and b/pix/i/calendareventtime.png differ
diff --git a/pix/i/calendareventtime.svg b/pix/i/calendareventtime.svg
new file mode 100644 (file)
index 0000000..94f355b
--- /dev/null
@@ -0,0 +1,3 @@
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
+       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
+]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M11.9 13.4c.3-.2.5-.4.8-.7 1.3-1.3 2-2.9 2-4.8s-.7-3.4-2-4.8c-1.3-1.3-2.9-2-4.8-2s-3.4.7-4.8 2c-1.3 1.3-2 2.9-2 4.8s.7 3.4 2 4.8c.3.3.5.5.8.7l-.8 1.8c-.1.2 0 .5.2.7.2.1.5 0 .7-.2l.9-1.7c.9.5 2 .7 3.1.7s2.2-.2 3.1-.7l.8 1.7c.1.2.4.4.7.2.2-.1.4-.4.2-.7l-.9-1.8zm-3.9.3c-1.6 0-2.9-.5-4-1.7-1.1-1.1-1.7-2.4-1.7-4S2.8 5.1 4 4c1.1-1.1 2.4-1.7 4-1.7s2.9.5 4 1.7c1.1 1.1 1.7 2.4 1.7 4s-.5 2.9-1.7 4c-1.1 1.2-2.4 1.7-4 1.7zm0-6l2.6 2.1-.7.8-2.8-2.2c-.1-.1-.2-.2-.2-.4V4.6H8v3.1zm3.2-6.2L12 0c1.8.9 3.1 2.2 4 4l-1.5.8c-.7-1.5-1.8-2.6-3.3-3.3zM1.5 4.8L0 4C.9 2.2 2.2.9 4 0l.8 1.5c-1.5.7-2.6 1.8-3.3 3.3z" fill="#999"/></svg>
\ No newline at end of file
index d63030c..c47e205 100644 (file)
@@ -18,8 +18,7 @@
  * This file contains tests that walks a question through the manual graded
  * behaviour.
  *
- * @package    qbehaviour
- * @subpackage manualgraded
+ * @package    qbehaviour_manualgraded
  * @copyright  2009 The Open University
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
@@ -622,4 +621,40 @@ class qbehaviour_manualgraded_walkthrough_testcase extends qbehaviour_walkthroug
             new question_pattern_expectation($preg)
         );
     }
+
+    public function test_manual_grading_reshows_exactly_the_mark_input() {
+        global $PAGE;
+
+        // The current text editor depends on the users profile setting - so it needs a valid user.
+        $this->setAdminUser();
+        // Required to init a text editor.
+        $PAGE->set_url('/');
+
+        // Create an essay question graded out of 15 and attempt it.
+        $essay = test_question_maker::make_an_essay_question();
+        $this->start_attempt_at_question($essay, 'deferredfeedback', 15);
+        $this->process_submission(array('answer' => 'This is my wonderful essay!', 'answerformat' => FORMAT_HTML));
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$needsgrading);
+        $this->check_current_mark(null);
+        $this->assertEquals('This is my wonderful essay!',
+                $this->quba->get_response_summary($this->slot));
+
+        // Try to process a grade where the score will be stored rounded.
+        $this->manual_grade('Comment', '5.0', FORMAT_HTML);
+
+        // Verify.
+        $this->check_current_state(question_state::$mangrpartial);
+        $this->check_current_mark(5);
+        $this->displayoptions->manualcomment = question_display_options::EDITABLE;
+        $this->render();
+        $this->check_output_contains_text_input('-mark', '5.0');
+
+        // Rescale what the question is worth, and verify the display.
+        $this->get_question_attempt()->set_max_mark(1);
+        $this->render();
+        $this->check_output_contains_text_input('-mark', '0.3333333');
+    }
 }
index ea6a187..b474887 100644 (file)
@@ -126,9 +126,6 @@ abstract class qbehaviour_renderer extends plugin_renderer_base {
             if (!is_null($currentmark)) {
                 $attributes['value'] = $currentmark;
             }
-            $a = new stdClass();
-            $a->max = $qa->format_max_mark($options->markdp);
-            $a->mark = html_writer::empty_tag('input', $attributes);
 
             $markrange = html_writer::empty_tag('input', array(
                 'type' => 'hidden',
@@ -152,6 +149,9 @@ abstract class qbehaviour_renderer extends plugin_renderer_base {
                         array('class' => 'error')) . html_writer::empty_tag('br');
             }
 
+            $a = new stdClass();
+            $a->max = $qa->format_max_mark($options->markdp);
+            $a->mark = html_writer::empty_tag('input', $attributes);
             $mark = html_writer::tag('div', html_writer::tag('div',
                         html_writer::tag('label', get_string('mark', 'question'),
                         array('for' => $markfield)),
index e486e46..0ef5816 100644 (file)
@@ -525,15 +525,11 @@ ORDER BY
     qas.sequencenumber
     ", $qubaids->usage_id_in_params());
 
-        if (!$records->valid()) {
-            throw new coding_exception('Failed to load questions_usages_by_activity for qubaid_condition :' . $qubaids);
-        }
-
         $qubas = array();
-        do {
+        while ($records->valid()) {
             $record = $records->current();
             $qubas[$record->qubaid] = question_usage_by_activity::load_from_records($records, $record->qubaid);
-        } while ($records->valid());
+        }
 
         $records->close();
 
index fa110dd..d295170 100644 (file)
@@ -649,17 +649,29 @@ class question_attempt {
 
     /**
      * This is used by the manual grading code, particularly in association with
-     * validation. If there is a mark submitted in the request, then use that,
-     * otherwise use the latest mark for this question.
-     * @return number the current manual mark for this question, formatted for display.
+     * validation. It gets the current manual mark for a question, in exactly the string
+     * form that the teacher entered it, if possible. This may come from the current
+     * POST request, if there is one, otherwise from the database.
+     *
+     * @return string the current manual mark for this question, in the format the teacher typed,
+     *     if possible.
      */
     public function get_current_manual_mark() {
+        // Is there a current value in the current POST data? If so, use that.
         $mark = $this->get_submitted_var($this->get_behaviour_field_name('mark'), PARAM_RAW_TRIMMED);
-        if (is_null($mark)) {
-            return format_float($this->get_mark(), 7, true, true);
-        } else {
+        if ($mark !== null) {
             return $mark;
         }
+
+        // Otherwise, use the stored value.
+        // If the question max mark has not changed, use the stored value that was input.
+        $storedmaxmark = $this->get_last_behaviour_var('maxmark');
+        if ($storedmaxmark !== null && ($storedmaxmark - $this->get_max_mark()) < 0.0000005) {
+            return $this->get_last_behaviour_var('mark');
+        }
+
+        // The max mark for this question has changed so we must re-scale the current mark.
+        return format_float($this->get_mark(), 7, true, true);
     }
 
     /**
index 7157dfb..650674b 100644 (file)
@@ -35,7 +35,7 @@ defined('MOODLE_INTERNAL') || die();
  */
 class qtype_numerical_test_helper extends question_test_helper {
     public function get_test_questions() {
-        return array('pi', 'unit', 'currency');
+        return array('pi', 'unit', 'currency', 'pi3tries');
     }
 
     /**
@@ -71,6 +71,15 @@ class qtype_numerical_test_helper extends question_test_helper {
         return $num;
     }
 
+    /**
+     * Get the form data that corresponds to saving a numerical question.
+     *
+     * This question asks for Pi to two decimal places. It has feedback
+     * for various wrong responses. There is hint data there, but
+     * it is all blank, so no hints are created if this question is saved.
+     *
+     * @return stdClass simulated question form data.
+     */
     public function get_numerical_question_form_data_pi() {
         $form = new stdClass();
         $form->name = 'Pi to two d.p.';
@@ -156,6 +165,22 @@ class qtype_numerical_test_helper extends question_test_helper {
         return $form;
     }
 
+    /**
+     * Get the form data that corresponds to saving a numerical question.
+     *
+     * Like {@link get_numerical_question_form_data_pi()}, but
+     * this time with two hints, making this suitable for use
+     * with the Interactive with multiple tries behaviour.
+     *
+     * @return stdClass simulated question form data.
+     */
+    public function get_numerical_question_form_data_pi3tries() {
+        $form = $this->get_numerical_question_form_data_pi();
+        $form->hint[0]['text'] = 'First hint';
+        $form->hint[1]['text'] = 'Second hint';
+        return $form;
+    }
+
     public function get_numerical_question_data_pi() {
         $q = new stdClass();
         $q->name = 'Pi to two d.p.';
index de5fff2..a3eed8d 100644 (file)
@@ -220,7 +220,7 @@ abstract class engine {
             // Stop if we have exceeded the time limit (and there are still more items). Always
             // do at least one second's worth of documents otherwise it will never make progress.
             if ($lastindexeddoc !== $firstindexeddoc &&
-                    !empty($options['stopat']) && microtime(true) >= $options['stopat']) {
+                    !empty($options['stopat']) && manager::get_current_time() >= $options['stopat']) {
                 $partial = true;
                 break;
             }
index ea95108..8760bf4 100644 (file)
@@ -97,6 +97,13 @@ class manager {
      */
     protected $engine = null;
 
+    /**
+     * Note: This should be removed once possible (see MDL-60644).
+     *
+     * @var float Fake current time for use in PHPunit tests
+     */
+    protected static $phpunitfaketime = 0;
+
     /**
      * Constructor, use \core_search\manager::instance instead to get a class instance.
      *
@@ -669,7 +676,7 @@ class manager {
             });
 
             // Decide time to stop.
-            $stopat = microtime(true) + $timelimit;
+            $stopat = self::get_current_time() + $timelimit;
         }
 
         foreach ($searchareas as $areaid => $searcharea) {
@@ -680,7 +687,7 @@ class manager {
             $this->engine->area_index_starting($searcharea, $fullindex);
 
             $indexingstart = time();
-            $elapsed = microtime(true);
+            $elapsed = self::get_current_time();
 
             // This is used to store this component config.
             list($componentconfigname, $varname) = $searcharea->get_config_var_name();
@@ -730,7 +737,7 @@ class manager {
             }
 
             if ($numdocs > 0) {
-                $elapsed = round((microtime(true) - $elapsed), 3);
+                $elapsed = round((self::get_current_time() - $elapsed), 3);
                 $progress->output('Processed ' . $numrecords . ' records containing ' . $numdocs .
                         ' documents, in ' . $elapsed . ' seconds' .
                         ($partial ? ' (not complete)' : '') . '.', 1);
@@ -760,7 +767,7 @@ class manager {
                 $progress->output('Engine reported error.');
             }
 
-            if ($timelimit && (microtime(true) >= $stopat)) {
+            if ($timelimit && (self::get_current_time() >= $stopat)) {
                 $progress->output('Stopping indexing due to time limit.');
                 break;
             }
@@ -803,7 +810,7 @@ class manager {
         // Work out time to stop, if limited.
         if ($timelimit) {
             // Decide time to stop.
-            $stopat = microtime(true) + $timelimit;
+            $stopat = self::get_current_time() + $timelimit;
         }
 
         // No PHP time limit.
@@ -840,7 +847,7 @@ class manager {
 
             $progress->output('Processing area: ' . $searcharea->get_visible_name());
 
-            $elapsed = microtime(true);
+            $elapsed = self::get_current_time();
 
             // Get the recordset of all documents from the area for this context.
             $recordset = $searcharea->get_document_recordset($referencestarttime, $context);
@@ -881,7 +888,7 @@ class manager {
             }
 
             if ($numdocs > 0) {
-                $elapsed = round((microtime(true) - $elapsed), 3);
+                $elapsed = round((self::get_current_time() - $elapsed), 3);
                 $progress->output('Processed ' . $numrecords . ' records containing ' . $numdocs .
                         ' documents, in ' . $elapsed . ' seconds' .
                         ($partial ? ' (not complete)' : '') . '.', 1);
@@ -895,7 +902,7 @@ class manager {
                 $progress->output('Engine reported error.', 1);
             }
 
-            if ($partial && $timelimit && (microtime(true) >= $stopat)) {
+            if ($partial && $timelimit && (self::get_current_time() >= $stopat)) {
                 $progress->output('Stopping indexing due to time limit.');
                 break;
             }
@@ -1107,7 +1114,7 @@ class manager {
         }
 
         $complete = false;
-        $before = microtime(true);
+        $before = self::get_current_time();
         if ($timelimit) {
             $stopat = $before + $timelimit;
         }
@@ -1125,7 +1132,7 @@ class manager {
 
             // Calculate remaining time.
             $remainingtime = 0;
-            $beforeindex = microtime(true);
+            $beforeindex = self::get_current_time();
             if ($timelimit) {
                 $remainingtime = $stopat - $beforeindex;
             }
@@ -1143,7 +1150,7 @@ class manager {
                     $progress, $request->partialarea, $request->partialtime);
 
             // Work out shared part of message.
-            $endmessage = $contextname . ' (' . round(microtime(true) - $beforeindex, 1) . 's)';
+            $endmessage = $contextname . ' (' . round(self::get_current_time() - $beforeindex, 1) . 's)';
 
             // Update database table and continue/stop as appropriate.
             if ($result->complete) {
@@ -1163,4 +1170,17 @@ class manager {
         }
     }
 
+    /**
+     * Gets current time for use in search system.
+     *
+     * Note: This should be replaced with generic core functionality once possible (see MDL-60644).
+     *
+     * @return float Current time in seconds (with decimals)
+     */
+    public static function get_current_time() {
+        if (PHPUNIT_TEST && self::$phpunitfaketime) {
+            return self::$phpunitfaketime;
+        }
+        return microtime(true);
+    }
 }
index b8d7e12..6addfea 100644 (file)
@@ -28,7 +28,8 @@ $string['errorcreatingschema'] = 'Error creating the Solr schema: {$a}';
 $string['errorvalidatingschema'] = 'Error validating Solr schema: field {$a->fieldname} does not exist. Please <a href="{$a->setupurl}">follow this link</a> to set up the required fields.';
 $string['extensionerror'] = 'The Apache Solr PHP extension is not installed. Please check the documentation.';
 $string['fileindexing'] = 'Enable file indexing';
-$string['fileindexing_help'] = 'If your Solr install supports it, this feature allows Moodle to send files to be indexed.';
+$string['fileindexing_help'] = 'If your Solr install supports it, this feature allows Moodle to send files to be indexed.<br/>
+You will need to reindex all site contents after enabling this option for all files to be added.';
 $string['fileindexsettings'] = 'File indexing settings';
 $string['maxindexfilekb'] = 'Maximum file size to index (kB)';
 $string['maxindexfilekb_help'] = 'Files larger than this number of kilobytes will not be included in search indexing. If set to zero, files of any size will be indexed.';
index ad63e84..35ea923 100644 (file)
@@ -25,6 +25,8 @@ namespace mock_search;
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+use core_search\manager;
+
 defined('MOODLE_INTERNAL') || die;
 
 class engine extends \core_search\engine {
@@ -45,7 +47,7 @@ class engine extends \core_search\engine {
 
     public function add_document($document, $fileindexing = false) {
         if ($this->adddelay) {
-            usleep($this->adddelay);
+            \testable_core_search::fake_current_time(manager::get_current_time() + $this->adddelay);
         }
         $this->added[] = $document;
         return true;
index 9414531..65ae8ce 100644 (file)
@@ -108,4 +108,14 @@ class testable_core_search extends \core_search\manager {
         return parent::is_search_area($classname);
     }
 
+    /**
+     * Fakes the current time for PHPunit. Turns off faking time if called with default parameter.
+     *
+     * Note: This should be replaced with core functionality once possible (see MDL-60644).
+     *
+     * @param float $faketime Current time
+     */
+    public static function fake_current_time($faketime = 0.0) {
+        static::$phpunitfaketime = $faketime;
+    }
 }
index 8db3d7b..6ad9121 100644 (file)
@@ -46,6 +46,12 @@ class search_manager_testcase extends advanced_testcase {
         $this->mycoursesareaid = \core_search\manager::generate_areaid('core_course', 'mycourse');
     }
 
+    protected function tearDown() {
+        // Stop it from faking time in the search manager (if set by test).
+        testable_core_search::fake_current_time();
+        parent::tearDown();
+    }
+
     public function test_search_enabled() {
 
         $this->resetAfterTest();
@@ -220,6 +226,9 @@ class search_manager_testcase extends advanced_testcase {
         // Make the search engine delay while indexing each document.
         $search->get_engine()->set_add_delay(1.2);
 
+        // Use fake time, starting from now.
+        testable_core_search::fake_current_time(time());
+
         // Index with a limit of 2 seconds - it should index 2 of the documents (after the second
         // one, it will have taken 2.4 seconds so it will stop).
         $search->index(false, 2);
@@ -235,6 +244,7 @@ class search_manager_testcase extends advanced_testcase {
         // Wait to next second (so as to not reindex the label more than once, as it will now
         // be timed before the indexing run).
         $this->waitForSecond();
+        testable_core_search::fake_current_time(time());
 
         // Next index with 1 second limit should do the label and not the forum - the logic is,
         // if it spent ages indexing an area last time, do that one last on next run.
@@ -850,6 +860,7 @@ class search_manager_testcase extends advanced_testcase {
 
         // Do the processing again with a time limit and indexing delay. The time limit is too
         // small; because of the way the logic works, this means it will index 2 activities.
+        testable_core_search::fake_current_time(time());
         $search->get_engine()->set_add_delay(0.2);
         $search->process_index_requests(0.1, $progress);
         $out = $progress->get_buffer();
index 29ffb50..3e3cb04 100644 (file)
@@ -96,9 +96,7 @@ $calendarEventUserColor: #dce7ec !default; // Pale blue.
         .calendarmonth {
             width: 98%;
             margin: 10px auto;
-        }
 
-        .calendarmonth {
             ul {
                 margin: 0;
                 padding: 0;
@@ -110,6 +108,14 @@ $calendarEventUserColor: #dce7ec !default; // Pale blue.
                         @include text-truncate;
                         max-width: 100%;
                         display: inline-block;
+
+                        &:hover {
+                            text-decoration: $link-decoration;
+
+                            .eventname {
+                                text-decoration: $link-hover-decoration;
+                            }
+                        }
                     }
 
                     .icon {
@@ -367,6 +373,16 @@ $calendarEventUserColor: #dce7ec !default; // Pale blue.