Merge branch 'MDL-59084-master' of git://github.com/andrewnicols/moodle
authorDan Poltawski <dan@moodle.com>
Tue, 12 Sep 2017 14:28:26 +0000 (15:28 +0100)
committerDan Poltawski <dan@moodle.com>
Tue, 12 Sep 2017 14:28:26 +0000 (15:28 +0100)
183 files changed:
.eslintrc
.travis.yml
Gruntfile.js
admin/roles/classes/capability_table_with_risks.php
admin/tool/lp/amd/src/planactions.js
analytics/classes/local/target/base.php
analytics/classes/model.php
analytics/classes/prediction.php
analytics/classes/prediction_action.php
analytics/tests/prediction_actions_test.php [new file with mode: 0644]
blocks/activity_results/block_activity_results.php
blocks/calendar_month/block_calendar_month.php
blocks/calendar_month/tests/behat/block_calendar_month.feature
blocks/myoverview/amd/src/event_list.js
blocks/participants/block_participants.php
calendar/amd/build/calendar.min.js
calendar/amd/build/calendar_filter.min.js [new file with mode: 0644]
calendar/amd/build/calendar_mini.min.js [new file with mode: 0644]
calendar/amd/build/event_form.min.js
calendar/amd/build/events.min.js
calendar/amd/build/modal_event_form.min.js
calendar/amd/build/selectors.min.js [new file with mode: 0644]
calendar/amd/build/view_manager.min.js
calendar/amd/src/calendar.js
calendar/amd/src/calendar_filter.js [new file with mode: 0644]
calendar/amd/src/calendar_mini.js [new file with mode: 0644]
calendar/amd/src/event_form.js
calendar/amd/src/events.js
calendar/amd/src/modal_event_form.js
calendar/amd/src/selectors.js [new file with mode: 0644]
calendar/amd/src/view_manager.js
calendar/classes/external/calendar_event_exporter.php
calendar/classes/external/day_exporter.php
calendar/classes/external/event_exporter_base.php
calendar/classes/external/month_exporter.php
calendar/classes/external/week_day_exporter.php [new file with mode: 0644]
calendar/classes/external/week_exporter.php
calendar/classes/local/event/forms/create.php
calendar/classes/local/event/mappers/create_update_form_mapper.php
calendar/externallib.php
calendar/lib.php
calendar/renderer.php
calendar/set.php [deleted file]
calendar/templates/calendar_mini.mustache [new file with mode: 0644]
calendar/templates/calendar_month.mustache [new file with mode: 0644]
calendar/templates/event_filter.mustache [new file with mode: 0644]
calendar/templates/event_filter_key.mustache [new file with mode: 0644]
calendar/templates/minicalendar_day_link.mustache
calendar/templates/modal_event_form.mustache
calendar/templates/month_detailed.mustache
calendar/templates/month_mini.mustache [new file with mode: 0644]
calendar/tests/behat/behat_calendar.php
calendar/tests/externallib_test.php
calendar/view.php
course/amd/src/actions.js
course/classes/search/mycourse.php
course/lib.php
course/recent_form.php
course/tests/courselib_test.php
enrol/externallib.php
lang/en/access.php
lang/en/analytics.php
lang/en/calendar.php
lang/en/deprecated.txt
lang/en/moodle.php
lib/amd/build/form-autocomplete.min.js
lib/amd/src/form-autocomplete.js
lib/amd/src/templates.js
lib/authlib.php
lib/classes/analytics/target/course_dropout.php
lib/classes/analytics/target/no_teaching.php
lib/classes/event/insights_viewed.php [new file with mode: 0644]
lib/db/install.xml
lib/db/upgrade.php
lib/editor/atto/plugins/media/yui/build/moodle-atto_media-button/moodle-atto_media-button-debug.js
lib/editor/atto/plugins/media/yui/build/moodle-atto_media-button/moodle-atto_media-button-min.js
lib/editor/atto/plugins/media/yui/build/moodle-atto_media-button/moodle-atto_media-button.js
lib/editor/atto/plugins/media/yui/src/button/meta/button.json
lib/filebrowser/file_browser.php
lib/filebrowser/file_info_context_course.php
lib/filebrowser/file_info_context_coursecat.php
lib/filebrowser/file_info_context_module.php
lib/filebrowser/file_info_context_system.php
lib/filebrowser/tests/file_browser_test.php [new file with mode: 0644]
lib/form/templatable_form_element.php
lib/formslib.php
lib/navigationlib.php
lib/templates/loginform.mustache
lib/upgrade.txt
message/classes/api.php
message/classes/search/message_received.php
message/classes/search/message_sent.php
message/output/popup/amd/src/notification_area_control_area.js
mod/assign/classes/search/activity.php
mod/assign/locallib.php
mod/assign/tests/behat/allow_another_attempt.feature
mod/assign/upgrade.txt
mod/book/classes/search/activity.php
mod/book/classes/search/chapter.php
mod/book/lib.php
mod/chat/classes/search/activity.php
mod/choice/classes/search/activity.php
mod/data/classes/external.php
mod/data/classes/search/activity.php
mod/data/tests/externallib_test.php
mod/data/upgrade.txt
mod/feedback/classes/complete_form.php
mod/feedback/classes/search/activity.php
mod/folder/classes/search/activity.php
mod/forum/classes/search/activity.php
mod/forum/classes/search/post.php
mod/forum/externallib.php
mod/forum/lib.php
mod/forum/tests/externallib_test.php
mod/forum/upgrade.txt
mod/glossary/classes/external.php
mod/glossary/classes/search/activity.php
mod/glossary/classes/search/entry.php
mod/glossary/tests/external_test.php
mod/glossary/upgrade.txt
mod/imscp/classes/search/activity.php
mod/label/classes/search/activity.php
mod/lesson/classes/search/activity.php
mod/lti/classes/search/activity.php
mod/page/classes/search/activity.php
mod/quiz/classes/search/activity.php
mod/resource/classes/search/activity.php
mod/resource/tests/search_test.php
mod/scorm/classes/search/activity.php
mod/scorm/mod_form.php
mod/survey/classes/search/activity.php
mod/url/classes/search/activity.php
mod/wiki/classes/search/activity.php
mod/wiki/classes/search/collaborative_page.php
mod/workshop/classes/external.php
mod/workshop/classes/search/activity.php
mod/workshop/db/services.php
mod/workshop/locallib.php
mod/workshop/submission.php
mod/workshop/submission_form.php
mod/workshop/tests/external_test.php
mod/workshop/version.php
notes/delete.php
notes/edit.php
notes/index.php
npm-shrinkwrap.json
package.json
phpunit.xml.dist
rating/classes/external/util.php [new file with mode: 0644]
report/insights/action.php
report/insights/amd/build/actions.min.js [new file with mode: 0644]
report/insights/amd/src/actions.js [new file with mode: 0644]
report/insights/classes/external.php [new file with mode: 0644]
report/insights/classes/output/insights_list.php
report/insights/db/services.php [new file with mode: 0644]
report/insights/insights.php
report/insights/version.php
repository/local/lib.php
search/classes/base.php
search/classes/base_activity.php
search/classes/base_mod.php
search/classes/manager.php
search/tests/base_activity_test.php [new file with mode: 0644]
search/tests/base_test.php [new file with mode: 0644]
search/tests/behat/behat_search.php [new file with mode: 0644]
search/tests/behat/search_query.feature [new file with mode: 0644]
theme/boost/scss/moodle/calendar.scss
theme/boost/scss/moodle/forms.scss
theme/boost/templates/core_calendar/minicalendar_day_link.mustache
theme/boost/templates/core_form/element-template.mustache
theme/bootstrapbase/less/moodle/calendar.less
theme/bootstrapbase/less/moodle/forms.less
theme/bootstrapbase/less/moodle/modules.less
theme/bootstrapbase/style/moodle.css
theme/bootstrapbase/templates/core_calendar/minicalendar_day_link.mustache
user/classes/search/user.php
user/externallib.php
user/index.php
user/messageselect.php
user/renderer.php
user/templates/unified_filter.mustache
version.php
webservice/rest/simpleserver.php

index c50d2bb..961467d 100644 (file)
--- a/.eslintrc
+++ b/.eslintrc
@@ -16,6 +16,7 @@
 
     // === Possible Errors ===
     'comma-dangle': 'off',
+    'no-compare-neg-zero': 'error',
     'no-cond-assign': 'error',
     'no-console': 'error',
     'no-constant-condition': 'error',
@@ -67,7 +68,6 @@
     'no-fallthrough': 'error',
     'no-floating-decimal': 'warn',
     'no-global-assign': 'warn',
-    // Enabled by grunt for AMD modules: 'no-implicit-globals': 'error',
     'no-implied-eval': 'error',
     'no-invalid-this': 'error',
     'no-iterator': 'error',
     'block-spacing': 'warn',
     'brace-style': ['warn', '1tbs'],
     'camelcase': 'warn',
+    'capitalized-comments': ['warn', 'always', { 'ignoreConsecutiveComments': true }],
     'comma-spacing': ['warn', { 'before': false, 'after': true }],
     'comma-style': ['warn', 'last'],
     'computed-property-spacing': 'error',
         'message': 'Use AMD module "core/str" or M.util.get_string()'
     }],
 
-  }
+  },
+  overrides: [
+    {
+      files: ["**/yui/src/**/*.js"],
+      // Disable some rules which we can't safely define for YUI rollups.
+      rules: {
+        'no-undef': 'off',
+        'no-unused-vars': 'off',
+        'no-unused-expressions': 'off'
+      }
+    },
+    {
+      files: ["**/amd/src/*.js"],
+      // Check AMD with some slightly stricter rules.
+      rules: {
+        'no-unused-vars': 'error',
+        'no-implicit-globals': 'error'
+      }
+    }
+  ]
 }
index 54198fd..9205889 100644 (file)
@@ -17,7 +17,7 @@ php:
     - 7.0
 
 addons:
-  postgresql: "9.3"
+  postgresql: "9.6"
   packages:
     - mysql-server-5.6
     - mysql-client-core-5.6
@@ -91,7 +91,7 @@ install:
             sudo service postgresql stop
             sudo mv /var/lib/postgresql /mnt/ramdisk
             sudo ln -s /mnt/ramdisk/postgresql /var/lib/postgresql
-            sudo service postgresql start 9.3
+            sudo service postgresql start 9.6
         fi
     - >
         if [ "$TASK" = 'PHPUNIT' ];
index 2026c23..e0afa2d 100644 (file)
@@ -112,26 +112,9 @@ module.exports = function(grunt) {
             // Even though warnings dont stop the build we don't display warnings by default because
             // at this moment we've got too many core warnings.
             options: {quiet: !grunt.option('show-lint-warnings')},
-            amd: {
-              src: amdSrc,
-              // Check AMD with some slightly stricter rules.
-              rules: {
-                'no-unused-vars': 'error',
-                'no-implicit-globals': 'error'
-              }
-            },
+            amd: {src: amdSrc},
             // Check YUI module source files.
-            yui: {
-               src: ['**/yui/src/**/*.js', '!*/**/yui/src/*/meta/*.js'],
-               options: {
-                   // Disable some rules which we can't safely define for YUI rollups.
-                   rules: {
-                     'no-undef': 'off',
-                     'no-unused-vars': 'off',
-                     'no-unused-expressions': 'off'
-                   }
-               }
-            }
+            yui: {src: ['**/yui/src/**/*.js', '!*/**/yui/src/*/meta/*.js']}
         },
         uglify: {
             amd: {
index b62ad19..db7e16b 100644 (file)
@@ -183,9 +183,14 @@ abstract class core_role_capability_table_with_risks extends core_role_capabilit
     public function get_risk_icon($type) {
         global $OUTPUT;
 
-        $text = $OUTPUT->pix_icon('i/' . str_replace('risk', 'risk_', $type), get_string($type . 'short', 'admin'));
+        $alt = get_string("{$type}short", "admin");
+        $title = get_string($type, "admin");
+
+        $text = $OUTPUT->pix_icon('i/' . str_replace('risk', 'risk_', $type), $alt, 'moodle', [
+                'title' => $title,
+            ]);
         $action = new popup_action('click', $this->risksurl, 'docspopup');
-        $riskicon = $OUTPUT->action_link($this->risksurl, $text, $action, array('title'=>get_string($type, 'admin')));
+        $riskicon = $OUTPUT->action_link($this->risksurl, $text, $action);
 
         return $riskicon;
     }
index be39396..44a335d 100644 (file)
@@ -229,7 +229,7 @@ define(['jquery',
                 notification.confirm(
                     strings[0], // Confirm.
                     strings[1], // Reopen plan X?
-                    strings[2], // reopen.
+                    strings[2], // Reopen.
                     strings[3], // Cancel.
                     function() {
                         self._doReopenPlan(planData);
index 1a4f978..5b741d2 100644 (file)
@@ -112,18 +112,42 @@ abstract class base extends \core_analytics\calculable {
      * @return \core_analytics\prediction_action[]
      */
     public function prediction_actions(\core_analytics\prediction $prediction, $includedetailsaction = false) {
+        global $PAGE;
+
+        $predictionid = $prediction->get_prediction_data()->id;
+
+        $PAGE->requires->js_call_amd('report_insights/actions', 'init', array($predictionid));
+
         $actions = array();
 
         if ($includedetailsaction) {
 
             $predictionurl = new \moodle_url('/report/insights/prediction.php',
-                array('id' => $prediction->get_prediction_data()->id));
+                array('id' => $predictionid));
 
-            $actions['predictiondetails'] = new \core_analytics\prediction_action('predictiondetails', $prediction,
+            $actions[] = new \core_analytics\prediction_action(\core_analytics\prediction::ACTION_PREDICTION_DETAILS, $prediction,
                 $predictionurl, new \pix_icon('t/preview', get_string('viewprediction', 'analytics')),
                 get_string('viewprediction', 'analytics'));
         }
 
+        // Flag as fixed / solved.
+        $fixedattrs = array(
+            'data-prediction-id' => $predictionid,
+            'data-prediction-methodname' => 'report_insights_set_fixed_prediction'
+        );
+        $actions[] = new \core_analytics\prediction_action(\core_analytics\prediction::ACTION_FIXED,
+            $prediction, new \moodle_url(''), new \pix_icon('t/check', get_string('fixedack', 'analytics')),
+            get_string('fixedack', 'analytics'), false, $fixedattrs);
+
+        // Flag as not useful.
+        $notusefulattrs = array(
+            'data-prediction-id' => $predictionid,
+            'data-prediction-methodname' => 'report_insights_set_notuseful_prediction'
+        );
+        $actions[] = new \core_analytics\prediction_action(\core_analytics\prediction::ACTION_NOT_USEFUL,
+            $prediction, new \moodle_url(''), new \pix_icon('t/delete', get_string('notuseful', 'analytics')),
+            get_string('notuseful', 'analytics'), false, $notusefulattrs);
+
         return $actions;
     }
 
index 8120dd6..dbee08a 100644 (file)
@@ -1041,15 +1041,29 @@ class model {
     /**
      * Get the contexts with predictions.
      *
+     * @param bool $skiphidden Skip hidden predictions
      * @return \stdClass[]
      */
-    public function get_predictions_contexts() {
-        global $DB;
+    public function get_predictions_contexts($skiphidden = true) {
+        global $DB, $USER;
 
         $sql = "SELECT DISTINCT ap.contextid FROM {analytics_predictions} ap
                   JOIN {context} ctx ON ctx.id = ap.contextid
-                 WHERE ap.modelid = ?";
-        return $DB->get_records_sql($sql, array($this->model->id));
+                 WHERE ap.modelid = :modelid";
+        $params = array('modelid' => $this->model->id);
+
+        if ($skiphidden) {
+            $sql .= " AND NOT EXISTS (
+              SELECT 1
+                FROM {analytics_prediction_actions} apa
+               WHERE apa.predictionid = ap.id AND apa.userid = :userid AND (apa.actionname = :fixed OR apa.actionname = :notuseful)
+            )";
+            $params['userid'] = $USER->id;
+            $params['fixed'] = \core_analytics\prediction::ACTION_FIXED;
+            $params['notuseful'] = \core_analytics\prediction::ACTION_NOT_USEFUL;
+        }
+
+        return $DB->get_records_sql($sql, $params);
     }
 
     /**
@@ -1096,12 +1110,13 @@ class model {
      * Gets the predictions for this context.
      *
      * @param \context $context
+     * @param bool $skiphidden Skip hidden predictions
      * @param int $page The page of results to fetch. False for all results.
      * @param int $perpage The max number of results to fetch. Ignored if $page is false.
      * @return array($total, \core_analytics\prediction[])
      */
-    public function get_predictions(\context $context, $page = false, $perpage = 100) {
-        global $DB;
+    public function get_predictions(\context $context, $skiphidden = true, $page = false, $perpage = 100) {
+        global $DB, $USER;
 
         \core_analytics\manager::check_can_list_insights($context);
 
@@ -1111,12 +1126,27 @@ class model {
                   JOIN (
                     SELECT sampleid, max(rangeindex) AS rangeindex
                       FROM {analytics_predictions}
-                     WHERE modelid = ? and contextid = ?
+                     WHERE modelid = :modelidsubap and contextid = :contextidsubap
                     GROUP BY sampleid
                   ) apsub
                   ON ap.sampleid = apsub.sampleid AND ap.rangeindex = apsub.rangeindex
-                 WHERE ap.modelid = ? and ap.contextid = ?";
-        $params = array($this->model->id, $context->id, $this->model->id, $context->id);
+                WHERE ap.modelid = :modelid and ap.contextid = :contextid";
+
+        $params = array('modelid' => $this->model->id, 'contextid' => $context->id,
+            'modelidsubap' => $this->model->id, 'contextidsubap' => $context->id);
+
+        if ($skiphidden) {
+            $sql .= " AND NOT EXISTS (
+              SELECT 1
+                FROM {analytics_prediction_actions} apa
+               WHERE apa.predictionid = ap.id AND apa.userid = :userid AND (apa.actionname = :fixed OR apa.actionname = :notuseful)
+            )";
+            $params['userid'] = $USER->id;
+            $params['fixed'] = \core_analytics\prediction::ACTION_FIXED;
+            $params['notuseful'] = \core_analytics\prediction::ACTION_NOT_USEFUL;
+        }
+
+        $sql .= " ORDER BY ap.timecreated DESC";
         if (!$predictions = $DB->get_records_sql($sql, $params)) {
             return array();
         }
index f738f69..549795e 100644 (file)
@@ -35,6 +35,21 @@ defined('MOODLE_INTERNAL') || die();
  */
 class prediction {
 
+    /**
+     * Prediction details (one of the default prediction actions)
+     */
+    const ACTION_PREDICTION_DETAILS = 'predictiondetails';
+
+    /**
+     * Prediction not useful (one of the default prediction actions)
+     */
+    const ACTION_NOT_USEFUL = 'notuseful';
+
+    /**
+     * Prediction already fixed (one of the default prediction actions)
+     */
+    const ACTION_FIXED = 'fixed';
+
     /**
      * @var \stdClass
      */
@@ -97,6 +112,51 @@ class prediction {
         return $this->calculations;
     }
 
+    /**
+     * Stores the executed action.
+
+     * Prediction instances should be retrieved using \core_analytics\manager::get_prediction,
+     * It is the caller responsability to check that the user can see the prediction.
+     *
+     * @param string $actionname
+     * @param \core_analytics\local\target\base $target
+     */
+    public function action_executed($actionname, \core_analytics\local\target\base $target) {
+        global $USER, $DB;
+
+        $context = \context::instance_by_id($this->get_prediction_data()->contextid, IGNORE_MISSING);
+        if (!$context) {
+            throw new \moodle_exception('errorpredictioncontextnotavailable', 'analytics');
+        }
+
+        // Check that the provided action exists.
+        $actions = $target->prediction_actions($this, true);
+        foreach ($actions as $action) {
+            if ($action->get_action_name() === $actionname) {
+                $found = true;
+            }
+        }
+        if (empty($found)) {
+            throw new \moodle_exception('errorunknownaction', 'analytics');
+        }
+
+        $predictionid = $this->get_prediction_data()->id;
+
+        $action = new \stdClass();
+        $action->predictionid = $predictionid;
+        $action->userid = $USER->id;
+        $action->actionname = $actionname;
+        $action->timecreated = time();
+        $DB->insert_record('analytics_prediction_actions', $action);
+
+        $eventdata = array (
+            'context' => $context,
+            'objectid' => $predictionid,
+            'other' => array('actionname' => $actionname)
+        );
+        \core\event\prediction_action_started::create($eventdata)->trigger();
+    }
+
     /**
      * format_calculations
      *
index db7e50a..2fb4281 100644 (file)
@@ -35,36 +35,54 @@ defined('MOODLE_INTERNAL') || die();
  */
 class prediction_action {
 
+    /**
+     * @var string
+     */
+    protected $actionname = null;
+
     /**
      * @var \action_menu_link
      */
     protected $actionlink = null;
 
     /**
-     * __construct
+     * Prediction action constructor.
      *
-     * @param string $actionname
+     * @param string $actionname They should match a-zA-Z_0-9-, as we apply a PARAM_ALPHANUMEXT filter
      * @param \core_analytics\prediction $prediction
      * @param \moodle_url $actionurl
-     * @param \pix_icon $icon
-     * @param string $text
-     * @param bool $primary
+     * @param \pix_icon $icon Link icon
+     * @param string $text Link text
+     * @param bool $primary Primary button or secondary.
+     * @param array $attributes Link attributes
      * @return void
      */
-    public function __construct($actionname, \core_analytics\prediction $prediction, \moodle_url $actionurl, \pix_icon $icon, $text, $primary = false) {
+    public function __construct($actionname, \core_analytics\prediction $prediction, \moodle_url $actionurl, \pix_icon $icon,
+                                $text, $primary = false, $attributes = array()) {
+
+        $this->actionname = $actionname;
 
         // We want to track how effective are our suggested actions, we pass users through a script that will log these actions.
-        $params = array('action' => $actionname, 'predictionid' => $prediction->get_prediction_data()->id,
+        $params = array('action' => $this->actionname, 'predictionid' => $prediction->get_prediction_data()->id,
             'forwardurl' => $actionurl->out(false));
         $url = new \moodle_url('/report/insights/action.php', $params);
 
         if ($primary === false) {
-            $this->actionlink = new \action_menu_link_secondary($url, $icon, $text);
+            $this->actionlink = new \action_menu_link_secondary($url, $icon, $text, $attributes);
         } else {
-            $this->actionlink = new \action_menu_link_primary($url, $icon, $text);
+            $this->actionlink = new \action_menu_link_primary($url, $icon, $text, $attributes);
         }
     }
 
+    /**
+     * Returns the action name.
+     *
+     * @return string
+     */
+    public function get_action_name() {
+        return $this->actionname;
+    }
+
     /**
      * Returns the link to the action.
      *
diff --git a/analytics/tests/prediction_actions_test.php b/analytics/tests/prediction_actions_test.php
new file mode 100644 (file)
index 0000000..3acd0a1
--- /dev/null
@@ -0,0 +1,140 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for prediction actions.
+ *
+ * @package   core_analytics
+ * @copyright 2017 David MonllaĆ³ {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once(__DIR__ . '/fixtures/test_indicator_max.php');
+require_once(__DIR__ . '/fixtures/test_target_shortname.php');
+
+/**
+ * Unit tests for prediction actions.
+ *
+ * @package   core_analytics
+ * @copyright 2017 David MonllaĆ³ {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class analytics_prediction_actions_testcase extends advanced_testcase {
+
+    /**
+     * Common startup tasks
+     */
+    public function setUp() {
+        global $DB;
+
+        $this->setAdminUser();
+        $target = \core_analytics\manager::get_target('test_target_shortname');
+        $indicators = array('test_indicator_max');
+        foreach ($indicators as $key => $indicator) {
+            $indicators[$key] = \core_analytics\manager::get_indicator($indicator);
+        }
+
+        $this->model = \core_analytics\model::create($target, $indicators);
+        $this->modelobj = $this->model->get_model_obj();
+        $this->model->enable('\core\analytics\time_splitting\single_range');
+
+        $this->resetAfterTest(true);
+
+        $course1 = $this->getDataGenerator()->create_course();
+        $course2 = $this->getDataGenerator()->create_course();
+        $this->context = \context_course::instance($course1->id);
+
+        $this->teacher1 = $this->getDataGenerator()->create_user();
+        $this->teacher2 = $this->getDataGenerator()->create_user();
+
+        $this->getDataGenerator()->enrol_user($this->teacher1->id, $course1->id, 'editingteacher');
+        $this->getDataGenerator()->enrol_user($this->teacher2->id, $course1->id, 'editingteacher');
+
+        // The only relevant fields are modelid, contextid and sampleid. I'm cheating and setting
+        // contextid as the course context so teachers can access these predictions.
+        $pred = new \stdClass();
+        $pred->modelid = $this->model->get_id();
+        $pred->contextid = $this->context->id;
+        $pred->sampleid = $course1->id;
+        $pred->rangeindex = 1;
+        $pred->prediction = 1;
+        $pred->predictionscore = 1;
+        $pred->calculations = json_encode(array('test_indicator_max' => 1));
+        $pred->timecreated = time();
+        $DB->insert_record('analytics_predictions', $pred);
+
+        $pred->sampleid = $course2->id;
+        $DB->insert_record('analytics_predictions', $pred);
+    }
+
+    /**
+     * test_get_predictions
+     */
+    public function test_action_executed() {
+        global $DB;
+
+        $this->assertEquals(0, $DB->count_records('analytics_prediction_actions'));
+
+        // Teacher 2 flags a prediction (it doesn't matter which one) as fixed.
+        $this->setUser($this->teacher2);
+        list($ignored, $predictions) = $this->model->get_predictions($this->context, true);
+        $prediction = reset($predictions);
+        $prediction->action_executed(\core_analytics\prediction::ACTION_FIXED, $this->model->get_target());
+
+        $this->assertEquals(1, $DB->count_records('analytics_prediction_actions'));
+        $action = $DB->get_record('analytics_prediction_actions', array('userid' => $this->teacher2->id));
+        $this->assertEquals(\core_analytics\prediction::ACTION_FIXED, $action->actionname);
+
+        $prediction->action_executed(\core_analytics\prediction::ACTION_NOT_USEFUL, $this->model->get_target());
+        $this->assertEquals(2, $DB->count_records('analytics_prediction_actions'));
+    }
+
+    /**
+     * test_get_predictions
+     */
+    public function test_get_predictions() {
+
+        // Already logged in as admin.
+        list($ignored, $predictions) = $this->model->get_predictions($this->context, true);
+        $this->assertCount(2, $predictions);
+
+        $this->setUser($this->teacher1);
+        list($ignored, $predictions) = $this->model->get_predictions($this->context, true);
+        $this->assertCount(2, $predictions);
+
+        $this->setUser($this->teacher2);
+        list($ignored, $predictions) = $this->model->get_predictions($this->context, false);
+        $this->assertCount(2, $predictions);
+
+        // Teacher 2 flags a prediction (it doesn't matter which one) as fixed.
+        $prediction = reset($predictions);
+        $prediction->action_executed(\core_analytics\prediction::ACTION_FIXED, $this->model->get_target());
+
+        list($ignored, $predictions) = $this->model->get_predictions($this->context, true);
+        $this->assertCount(1, $predictions);
+        list($ignored, $predictions) = $this->model->get_predictions($this->context, false);
+        $this->assertCount(2, $predictions);
+
+        // Teacher 1 can still see both predictions.
+        $this->setUser($this->teacher1);
+        list($ignored, $predictions) = $this->model->get_predictions($this->context, true);
+        $this->assertCount(2, $predictions);
+        list($ignored, $predictions) = $this->model->get_predictions($this->context, false);
+        $this->assertCount(2, $predictions);
+    }
+}
index de9b9ed..52fb8e9 100644 (file)
@@ -26,6 +26,7 @@
 defined('MOODLE_INTERNAL') || die();
 
 require_once($CFG->dirroot . '/lib/grade/constants.php');
+require_once($CFG->dirroot . '/course/lib.php');
 
 define('B_ACTIVITYRESULTS_NAME_FORMAT_FULL', 1);
 define('B_ACTIVITYRESULTS_NAME_FORMAT_ID',   2);
@@ -328,7 +329,7 @@ class block_activity_results extends block_base {
                 if ($nameformat == B_ACTIVITYRESULTS_NAME_FORMAT_FULL) {
                     if (has_capability('moodle/course:managegroups', $context)) {
                         $grouplink = $CFG->wwwroot.'/group/overview.php?id='.$courseid.'&amp;group=';
-                    } else if (has_capability('moodle/course:viewparticipants', $context)) {
+                    } else if (course_can_view_participants($context)) {
                         $grouplink = $CFG->wwwroot.'/user/index.php?id='.$courseid.'&amp;group=';
                     } else {
                         $grouplink = '';
index cc3d6f6..18a57d7 100644 (file)
@@ -70,29 +70,26 @@ class block_calendar_month extends block_base {
         if ($issite) {
             // Being displayed at site level. This will cause the filter to fall back to auto-detecting
             // the list of courses it will be grabbing events from.
-            $filtercourse = calendar_get_default_courses();
+            $course = get_site();
+            $courses = calendar_get_default_courses();
         } else {
             // Forcibly filter events to include only those from the particular course we are in.
-            $filtercourse = array($courseid => $this->page->course);
+            $course = $this->page->course;
+            $courses = [$course->id => $course];
         }
 
-        list($courses, $group, $user) = calendar_set_filters($filtercourse);
-        if ($issite) {
-            // For the front page.
-            $this->content->text .= calendar_get_mini($courses, $group, $user, false, false,
-                'frontpage', $courseid, $time);
-            // No filters for now.
-        } else {
-            // For any other course.
-            $this->content->text .= calendar_get_mini($courses, $group, $user, false, false,
-                'course', $courseid, $time);
-            $this->content->text .= '<h3 class="eventskey">'.get_string('eventskey', 'calendar').'</h3>';
-            $this->content->text .= '<div class="filters calendar_filters">' .
-                calendar_filter_controls($this->page->url) . '</div>';
+        $renderer = $this->page->get_renderer('core_calendar');
+
+        $calendar = new calendar_information(0, 0, 0, $time);
+        $calendar->prepare_for_view($course, $courses);
+
+        list($data, $template) = calendar_get_view($calendar, 'mini');
+        $this->content->text .= $renderer->render_from_template($template, $data);
+
+        if (!$issite) {
+            $this->content->text .= $renderer->event_filter();
         }
 
         return $this->content;
     }
 }
-
-
index 8753167..bc34346 100644 (file)
@@ -23,7 +23,7 @@ Feature: Enable the calendar block in a course and test it's functionality
     Given I log in as "teacher1"
     And I am on "Course 1" course homepage with editing mode on
     When I add the "Calendar" block
-    Then I should see "Events key" in the "Calendar" "block"
+    Then "Calendar" "block" should exist
 
   @javascript
   Scenario: View a global event in the calendar block
@@ -136,9 +136,14 @@ Feature: Enable the calendar block in a course and test it's functionality
     And I press "Save and display"
     And I turn editing mode on
     And I add the "Calendar" block
-    And I create a calendar event with form data:
+    And I click on "This month" "link"
+    And I click on "New event" "button"
+    And I set the following fields to these values:
       | id_eventtype | Group |
       | id_name | Group Event |
+    And I set the following fields to these values:
+      | Group | Group 1 |
+    And I press "Save"
     And I log out
     Then I log in as "student1"
     And I am on "Course 1" course homepage
@@ -173,9 +178,14 @@ Feature: Enable the calendar block in a course and test it's functionality
       | id_eventtype | Course |
       | id_name | Course Event 1 |
     And I am on "Course 1" course homepage
-    And I create a calendar event with form data:
+    And I click on "This month" "link"
+    And I click on "New event" "button"
+    And I set the following fields to these values:
       | id_eventtype | Group |
       | id_name | Group Event 1 |
+    And I set the following fields to these values:
+      | Group | Group 1 |
+    And I press "Save"
     And I log out
     Then I log in as "student1"
     And I am on "Course 1" course homepage
index 5d1d42d..9d96fc5 100644 (file)
@@ -369,7 +369,7 @@ define(['jquery', 'core/notification', 'core/templates',
             // Render the events.
             return render(root, calendarEvents).then(function(renderCount) {
                 if (renderCount < calendarEvents.length) {
-                    // if the number of events that was rendered is less than
+                    // If the number of events that was rendered is less than
                     // the number we sent for rendering we can assume that there
                     // are no groups to add them in. Since the ordering of the
                     // events is guaranteed it means that any future requests will
index f8214df..b1b433b 100644 (file)
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->dirroot . '/course/lib.php');
+
+/**
+ * Participants block
+ *
+ * @package    block_participants
+ * @copyright  1999 onwards Martin Dougiamas (http://dougiamas.com)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
 class block_participants extends block_list {
     function init() {
         $this->title = get_string('pluginname', 'block_participants');
@@ -48,12 +59,12 @@ class block_participants extends block_list {
             $this->content = '';
             return $this->content;
         } else if ($this->page->course->id == SITEID) {
-            if (!has_capability('moodle/site:viewparticipants', context_system::instance())) {
+            if (!course_can_view_participants(context_system::instance())) {
                 $this->content = '';
                 return $this->content;
             }
         } else {
-            if (!has_capability('moodle/course:viewparticipants', $currentcontext)) {
+            if (!course_can_view_participants($currentcontext)) {
                 $this->content = '';
                 return $this->content;
             }
index aac1c29..1999702 100644 (file)
Binary files a/calendar/amd/build/calendar.min.js and b/calendar/amd/build/calendar.min.js differ
diff --git a/calendar/amd/build/calendar_filter.min.js b/calendar/amd/build/calendar_filter.min.js
new file mode 100644 (file)
index 0000000..ef94d15
Binary files /dev/null and b/calendar/amd/build/calendar_filter.min.js differ
diff --git a/calendar/amd/build/calendar_mini.min.js b/calendar/amd/build/calendar_mini.min.js
new file mode 100644 (file)
index 0000000..979e92e
Binary files /dev/null and b/calendar/amd/build/calendar_mini.min.js differ
index 3a3c976..1f6e3ff 100644 (file)
Binary files a/calendar/amd/build/event_form.min.js and b/calendar/amd/build/event_form.min.js differ
index 762a824..77b2153 100644 (file)
Binary files a/calendar/amd/build/events.min.js and b/calendar/amd/build/events.min.js differ
index b27c823..4d52804 100644 (file)
Binary files a/calendar/amd/build/modal_event_form.min.js and b/calendar/amd/build/modal_event_form.min.js differ
diff --git a/calendar/amd/build/selectors.min.js b/calendar/amd/build/selectors.min.js
new file mode 100644 (file)
index 0000000..8dbeb18
Binary files /dev/null and b/calendar/amd/build/selectors.min.js differ
index d502824..301ef42 100644 (file)
Binary files a/calendar/amd/build/view_manager.min.js and b/calendar/amd/build/view_manager.min.js differ
index 947bac0..2958c83 100644 (file)
@@ -296,9 +296,7 @@ define([
     /**
      * Register event listeners for the module.
      */
-    var registerEventListeners = function() {
-        var root = $(SELECTORS.ROOT);
-
+    var registerEventListeners = function(root) {
         // Bind click events to event links.
         root.on('click', SELECTORS.EVENT_ITEM, function(e) {
             e.preventDefault();
@@ -350,9 +348,11 @@ define([
     };
 
     return {
-        init: function() {
-            CalendarViewManager.init();
-            registerEventListeners();
+        init: function(root) {
+            root = $(root);
+
+            CalendarViewManager.init(root);
+            registerEventListeners(root);
         }
     };
 });
diff --git a/calendar/amd/src/calendar_filter.js b/calendar/amd/src/calendar_filter.js
new file mode 100644 (file)
index 0000000..5352a7c
--- /dev/null
@@ -0,0 +1,119 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This module is responsible for the calendar filter.
+ *
+ * @module     core_calendar/calendar_filter
+ * @package    core_calendar
+ * @copyright  2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([
+    'jquery',
+    'core_calendar/selectors',
+    'core_calendar/events',
+    'core/str',
+    'core/templates',
+],
+function(
+    $,
+    CalendarSelectors,
+    CalendarEvents,
+    Str,
+    Templates
+) {
+
+    var registerEventListeners = function(root) {
+        root.on('click', CalendarSelectors.eventFilterItem, function(e) {
+            var target = $(e.currentTarget);
+
+            toggleFilter(target);
+
+            e.preventDefault();
+        });
+
+        $('body').on(CalendarEvents.viewUpdated, function() {
+            var filters = root.find(CalendarSelectors.eventFilterItem);
+
+            filters.each(function(i, filter) {
+                filter = $(filter);
+                if (filter.data('eventtype-hidden')) {
+                    var data = getFilterData(filter);
+                    fireFilterChangedEvent(data);
+                }
+            });
+        });
+    };
+
+    var toggleFilter = function(target) {
+        var data = getFilterData(target);
+
+        // Toggle the hidden. We need to render the template before we change the value.
+        data.hidden = !data.hidden;
+
+        return Str.get_string('eventtype' + data.type, 'calendar')
+        .then(function(nameStr) {
+            data.name = nameStr;
+
+            return data;
+        })
+        .then(function(context) {
+            return Templates.render('core_calendar/event_filter_key', context);
+        })
+        .then(function(html, js) {
+            return Templates.replaceNode(target, html, js);
+        })
+        .then(function() {
+            fireFilterChangedEvent(data);
+            return;
+        });
+    };
+
+    /**
+     * Fire the filterChanged event for the specified data.
+     *
+     * @param   {object} data The data to include
+     */
+    var fireFilterChangedEvent = function(data) {
+        M.util.js_pending("month-mini-filterChanged");
+        $('body').trigger(CalendarEvents.filterChanged, {
+            type: data.type,
+            hidden: data.hidden,
+        });
+        M.util.js_complete("month-mini-filterChanged");
+    };
+
+    /**
+     * Get the filter data for the specified target.
+     *
+     * @param   {jQuery} target The target node
+     * @return  {Object}
+     */
+    var getFilterData = function(target) {
+        return {
+            type: target.data('eventtype'),
+            hidden: target.data('eventtype-hidden'),
+        };
+    };
+
+    return {
+        init: function(root) {
+            root = $(root);
+
+            registerEventListeners(root);
+        }
+    };
+});
diff --git a/calendar/amd/src/calendar_mini.js b/calendar/amd/src/calendar_mini.js
new file mode 100644 (file)
index 0000000..48a4f44
--- /dev/null
@@ -0,0 +1,57 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This module is the highest level module for the calendar. It is
+ * responsible for initialising all of the components required for
+ * the calendar to run. It also coordinates the interaction between
+ * components by listening for and responding to different events
+ * triggered within the calendar UI.
+ *
+ * @module     core_calendar/calendar
+ * @package    core_calendar
+ * @copyright  2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([
+    'jquery',
+    'core_calendar/selectors',
+    'core_calendar/events',
+    'core_calendar/view_manager',
+],
+function(
+    $,
+    CalendarSelectors,
+    CalendarEvents,
+    CalendarViewManager
+) {
+
+    var registerEventListeners = function(root) {
+        $('body').on(CalendarEvents.filterChanged, function(e, data) {
+            var daysWithEvent = root.find(CalendarSelectors.eventType[data.type]);
+
+            daysWithEvent.toggleClass('calendar_event_' + data.type, !data.hidden);
+        });
+    };
+
+    return {
+        init: function(root) {
+            root = $(root);
+
+            CalendarViewManager.init(root);
+            registerEventListeners(root);
+        }
+    };
+});
index ea3e417..f537f6d 100644 (file)
  * @copyright  2017 Ryan Wyllie <ryan@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['jquery', 'core/templates'], function($, Templates) {
+define(['jquery'], function($) {
 
     var SELECTORS = {
-        EVENT_TYPE: '[name="eventtype"]',
-        EVENT_COURSE_ID: '[name="courseid"]',
         EVENT_GROUP_COURSE_ID: '[name="groupcourseid"]',
         EVENT_GROUP_ID: '[name="groupid"]',
-        FORM_GROUP: '.form-group',
         SELECT_OPTION: 'option',
-        ADVANCED_ELEMENT: '.fitem.advanced',
-        FIELDSET_ADVANCED_ELEMENTS: 'fieldset.containsadvancedelements',
-        MORELESS_TOGGLE: '.moreless-actions'
-    };
-
-    var EVENTS = {
-        SHOW_ADVANCED: 'event_form-show-advanced',
-        HIDE_ADVANCED: 'event_form-hide-advanced',
-        ADVANCED_SHOWN: 'event_form-advanced-shown',
-        ADVANCED_HIDDEN: 'event_form-advanced-hidden',
-    };
-
-    /**
-     * Find the old show more / show less toggle added by the mform and destroy it.
-     * We are handling the visibility of the advanced fields with the more/less button
-     * in the footer of the modal that this form is rendered within.
-     *
-     * @method destroyOldMoreLessToggle
-     * @param {object} formElement The root form element
-     */
-    var destroyOldMoreLessToggle = function(formElement) {
-        formElement.find(SELECTORS.FIELDSET_ADVANCED_ELEMENTS).removeClass('containsadvancedelements');
-        var element = formElement.find(SELECTORS.MORELESS_TOGGLE);
-        Templates.replaceNode(element, '', '');
-    };
-
-    /**
-     * Find each of the advanced form elements and make them visible.
-     *
-     * This function triggers the ADVANCED_SHOWN event for any other
-     * component to handle (e.g. the event form modal).
-     *
-     * @method destroyOldMoreLessToggle
-     * @param {object} formElement The root form element
-     */
-    var showAdvancedElements = function(formElement) {
-        formElement.find(SELECTORS.ADVANCED_ELEMENT).removeClass('hidden');
-        formElement.trigger(EVENTS.ADVANCED_SHOWN);
-    };
-
-    /**
-     * Find each of the advanced form elements and hide them.
-     *
-     * This function triggers the ADVANCED_HIDDEN event for any other
-     * component to handle (e.g. the event form modal).
-     *
-     * @method hideAdvancedElements
-     * @param {object} formElement The root form element
-     */
-    var hideAdvancedElements = function(formElement) {
-        formElement.find(SELECTORS.ADVANCED_ELEMENT).addClass('hidden');
-        formElement.trigger(EVENTS.ADVANCED_HIDDEN);
-    };
-
-    /**
-     * Listen for any events telling this module to show or hide it's
-     * advanced elements.
-     *
-     * This function listens for SHOW_ADVANCED and HIDE_ADVANCED.
-     *
-     * @method listenForShowHideEvents
-     * @param {object} formElement The root form element
-     */
-    var listenForShowHideEvents = function(formElement) {
-        formElement.on(EVENTS.SHOW_ADVANCED, function() {
-            showAdvancedElements(formElement);
-        });
-
-        formElement.on(EVENTS.HIDE_ADVANCED, function() {
-            hideAdvancedElements(formElement);
-        });
     };
 
     /**
@@ -177,28 +103,15 @@ define(['jquery', 'core/templates'], function($, Templates) {
      *
      * @method init
      * @param {string} formId The value of the form's id attribute
-     * @param {bool} hasError If the form has errors rendered form the server.
      */
-    var init = function(formId, hasError) {
+    var init = function(formId) {
         var formElement = $('#' + formId);
 
-        listenForShowHideEvents(formElement);
-        destroyOldMoreLessToggle(formElement);
         parseGroupSelect(formElement);
         addCourseGroupSelectListeners(formElement);
-
-        // If we know that the form has been rendered with server side
-        // errors then we need to display all of the elements in the form
-        // in case one of those elements has the error.
-        if (hasError) {
-            showAdvancedElements(formElement);
-        } else {
-            hideAdvancedElements(formElement);
-        }
     };
 
     return {
         init: init,
-        events: EVENTS,
     };
 });
index 95e0968..29b5fd2 100644 (file)
@@ -31,6 +31,8 @@ define([], function() {
         editActionEvent: 'calendar-events:edit_action_event',
         eventMoved: 'calendar-events:event_moved',
         monthChanged: 'calendar-events:month_changed',
-        moveEvent: 'calendar-events:move_event'
+        moveEvent: 'calendar-events:move_event',
+        filterChanged: 'calendar-events:filter_changed',
+        viewUpdated: 'calendar-events:view_updated',
     };
 });
index 034e98d..24f7b84 100644 (file)
@@ -33,8 +33,7 @@ define([
             'core/modal_registry',
             'core/fragment',
             'core_calendar/events',
-            'core_calendar/repository',
-            'core_calendar/event_form'
+            'core_calendar/repository'
         ],
         function(
             $,
@@ -47,13 +46,11 @@ define([
             ModalRegistry,
             Fragment,
             CalendarEvents,
-            Repository,
-            EventForm
+            Repository
         ) {
 
     var registered = false;
     var SELECTORS = {
-        MORELESS_BUTTON: '[data-action="more-less-toggle"]',
         SAVE_BUTTON: '[data-action="save"]',
         LOADING_ICON_CONTAINER: '[data-region="loading-icon-container"]',
     };
@@ -71,7 +68,6 @@ define([
         this.reloadingBody = false;
         this.reloadingTitle = false;
         this.saveButton = this.getFooter().find(SELECTORS.SAVE_BUTTON);
-        this.moreLessButton = this.getFooter().find(SELECTORS.MORELESS_BUTTON);
     };
 
     ModalEventForm.TYPE = 'core_calendar-modal_event_form';
@@ -185,7 +181,6 @@ define([
      */
     ModalEventForm.prototype.disableButtons = function() {
         this.saveButton.prop('disabled', true);
-        this.moreLessButton.prop('disabled', true);
     };
 
     /**
@@ -195,53 +190,6 @@ define([
      */
     ModalEventForm.prototype.enableButtons = function() {
         this.saveButton.prop('disabled', false);
-        this.moreLessButton.prop('disabled', false);
-    };
-
-    /**
-     * Set the more/less button in the footer to the "more"
-     * state.
-     *
-     * @method setMoreButton
-     */
-    ModalEventForm.prototype.setMoreButton = function() {
-        this.moreLessButton.attr('data-collapsed', 'true');
-        Str.get_string('more', 'calendar').then(function(string) {
-            this.moreLessButton.text(string);
-            return;
-        }.bind(this));
-    };
-
-    /**
-     * Set the more/less button in the footer to the "less"
-     * state.
-     *
-     * @method setLessButton
-     */
-    ModalEventForm.prototype.setLessButton = function() {
-        this.moreLessButton.attr('data-collapsed', 'false');
-        Str.get_string('less', 'calendar').then(function(string) {
-            this.moreLessButton.text(string);
-            return;
-        }.bind(this));
-    };
-
-    /**
-     * Toggle the more/less button in the footer from the current
-     * state to it's opposite state.
-     *
-     * @method toggleMoreLessButton
-     */
-    ModalEventForm.prototype.toggleMoreLessButton = function() {
-        var form = this.getForm();
-
-        if (this.moreLessButton.attr('data-collapsed') == 'true') {
-            form.trigger(EventForm.events.SHOW_ADVANCED);
-            this.setLessButton();
-        } else {
-            form.trigger(EventForm.events.HIDE_ADVANCED);
-            this.setMoreButton();
-        }
     };
 
     /**
@@ -288,10 +236,9 @@ define([
      *
      * @method reloadBodyContent
      * @param {string} formData The serialised form data
-     * @param {bool} hasError True if we know the form data is erroneous
      * @return {object} A promise resolved with the fragment html and js from
      */
-    ModalEventForm.prototype.reloadBodyContent = function(formData, hasError) {
+    ModalEventForm.prototype.reloadBodyContent = function(formData) {
         if (this.reloadingBody) {
             return this.bodyPromise;
         }
@@ -318,8 +265,6 @@ define([
             args.formdata = formData;
         }
 
-        args.haserror = (typeof hasError == 'undefined') ? false : hasError;
-
         this.bodyPromise = Fragment.loadFragment('calendar', 'event_form', contextId, args);
 
         this.setBody(this.bodyPromise);
@@ -416,7 +361,7 @@ define([
                     // If there was a server side validation error then
                     // we need to re-request the rendered form from the server
                     // in order to display the error for the user.
-                    return this.reloadBodyContent(formData, true);
+                    return this.reloadBodyContent(formData);
                 } else {
                     // No problemo! Our work here is done.
                     this.hide();
@@ -469,28 +414,6 @@ define([
             e.preventDefault();
             e.stopPropagation();
         }.bind(this));
-
-        // Toggle the state of the more/less button in the footer.
-        this.getModal().on(CustomEvents.events.activate, SELECTORS.MORELESS_BUTTON, function(e, data) {
-            this.toggleMoreLessButton();
-
-            data.originalEvent.preventDefault();
-            e.stopPropagation();
-        }.bind(this));
-
-        // When the event form tells us that the advanced fields are shown
-        // then the more/less button should be set to less to allow the user
-        // to hide the advanced fields.
-        this.getModal().on(EventForm.events.ADVANCED_SHOWN, function() {
-            this.setLessButton();
-        }.bind(this));
-
-        // When the event form tells us that the advanced fields are hidden
-        // then the more/less button should be set to more to allow the user
-        // to show the advanced fields.
-        this.getModal().on(EventForm.events.ADVANCED_HIDDEN, function() {
-            this.setMoreButton();
-        }.bind(this));
     };
 
     // Automatically register with the modal registry the first time this module is imported so that you can create modals
diff --git a/calendar/amd/src/selectors.js b/calendar/amd/src/selectors.js
new file mode 100644 (file)
index 0000000..89bb65a
--- /dev/null
@@ -0,0 +1,40 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This module is responsible for the calendar filter.
+ *
+ * @module     core_calendar/calendar_selectors
+ * @package    core_calendar
+ * @copyright  2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([], function() {
+    return {
+        eventFilterItem: "[data-action='filter-event-type']",
+        eventType: {
+            site: "[data-eventtype-site]",
+            course: "[data-eventtype-course]",
+            group: "[data-eventtype-group]",
+            user: "[data-eventtype-user]",
+        },
+        popoverType: {
+            site: "[data-popover-eventtype-site]",
+            course: "[data-popover-eventtype-course]",
+            group: "[data-popover-eventtype-group]",
+            user: "[data-popover-eventtype-user]",
+        },
+    };
+});
index 5604d6e..f8636ef 100644 (file)
@@ -42,7 +42,7 @@ define(['jquery', 'core/templates', 'core/notification', 'core_calendar/reposito
             root.on('click', SELECTORS.CALENDAR_NAV_LINK, function(e) {
                 var courseId = $(root).find(SELECTORS.CALENDAR_MONTH_WRAPPER).data('courseid');
                 var link = $(e.currentTarget);
-                changeMonth(link.attr('href'), link.data('time'), courseId);
+                changeMonth(root, link.attr('href'), link.data('time'), courseId);
 
                 e.preventDefault();
             });
@@ -55,17 +55,19 @@ define(['jquery', 'core/templates', 'core/notification', 'core_calendar/reposito
          * @param {Number} courseid The id of the course whose events are shown
          * @return {promise}
          */
-        var refreshMonthContent = function(time, courseid) {
-            var root = $(SELECTORS.ROOT);
-
+        var refreshMonthContent = function(root, time, courseid) {
             startLoading(root);
 
             return CalendarRepository.getCalendarMonthData(time, courseid)
                 .then(function(context) {
-                    return Templates.render('core_calendar/month_detailed', context);
+                    return Templates.render(root.attr('data-template'), context);
                 })
                 .then(function(html, js) {
-                    return Templates.replaceNode(SELECTORS.CALENDAR_MONTH_WRAPPER, html, js);
+                    return Templates.replaceNode(root.find(SELECTORS.CALENDAR_MONTH_WRAPPER), html, js);
+                })
+                .then(function() {
+                    $('body').trigger(CalendarEvents.viewUpdated);
+                    return;
                 })
                 .always(function() {
                     return stopLoading(root);
@@ -81,10 +83,12 @@ define(['jquery', 'core/templates', 'core/notification', 'core_calendar/reposito
          * @param {Number} courseid The id of the course whose events are shown
          * @return {promise}
          */
-        var changeMonth = function(url, time, courseid) {
-            return refreshMonthContent(time, courseid)
+        var changeMonth = function(root, url, time, courseid) {
+            return refreshMonthContent(root, time, courseid)
                 .then(function() {
-                    window.history.pushState({}, '', url);
+                    if (url.length && url !== '#') {
+                        window.history.pushState({}, '', url);
+                    }
                     return arguments;
                 })
                 .then(function() {
@@ -106,7 +110,7 @@ define(['jquery', 'core/templates', 'core/notification', 'core_calendar/reposito
             if (!courseId) {
                 courseId = root.find(SELECTORS.CALENDAR_MONTH_WRAPPER).data('courseid');
             }
-            return refreshMonthContent(time, courseId);
+            return refreshMonthContent(root, time, courseId);
         };
 
         /**
@@ -134,8 +138,8 @@ define(['jquery', 'core/templates', 'core/notification', 'core_calendar/reposito
         };
 
         return {
-            init: function() {
-                registerEventListeners(SELECTORS.ROOT);
+            init: function(root) {
+                registerEventListeners(root);
             },
             reloadCurrentMonth: reloadCurrentMonth,
             changeMonth: changeMonth,
index 7ba5805..f83f273 100644 (file)
@@ -47,6 +47,16 @@ class calendar_event_exporter extends event_exporter_base {
 
         $values = parent::define_other_properties();
         $values['url'] = ['type' => PARAM_URL];
+        $values['islastday'] = [
+            'type' => PARAM_BOOL,
+            'default' => false,
+        ];
+        $values['calendareventtype'] = [
+            'type' => PARAM_TEXT,
+        ];
+        $values['popupname'] = [
+            'type' => PARAM_RAW,
+        ];
 
         return $values;
     }
@@ -58,6 +68,8 @@ class calendar_event_exporter extends event_exporter_base {
      * @return array Keys are the property names, values are their values.
      */
     protected function get_other_values(renderer_base $output) {
+        global $CFG;
+
         $values = parent::get_other_values($output);
 
         $eventid = $this->event->get_id();
@@ -65,6 +77,55 @@ class calendar_event_exporter extends event_exporter_base {
         $url = new \moodle_url($this->related['daylink'], [], "event_{$eventid}");
         $values['url'] = $url->out(false);
 
+        $values['islastday'] = false;
+        $today = $this->related['type']->timestamp_to_date_array($this->related['today']);
+
+        $values['popupname'] = $this->event->get_name();
+
+        $times = $this->event->get_times();
+        if ($duration = $times->get_duration()) {
+            $enddate = $this->related['type']->timestamp_to_date_array($times->get_end_time()->getTimestamp());
+            $values['islastday'] = true;
+            $values['islastday'] = $values['islastday'] && $enddate['year'] == $today['year'];
+            $values['islastday'] = $values['islastday'] && $enddate['mon'] == $today['mon'];
+            $values['islastday'] = $values['islastday'] && $enddate['mday'] == $today['mday'];
+        }
+
+        $subscription = $this->event->get_subscription();
+        if ($subscription && !empty($subscription->get('id')) && $CFG->calendar_showicalsource) {
+            $a = (object) [
+                'name' => $values['popupname'],
+                'source' => $subscription->get('name'),
+            ];
+            $values['popupname'] = get_string('namewithsource', 'calendar', $a);
+        } else {
+            if ($values['islastday']) {
+                $startdate = $this->related['type']->timestamp_to_date_array($times->get_start_time()->getTimestamp());
+                $samedate = true;
+                $samedate = $samedate && $startdate['mon'] == $enddate['mon'];
+                $samedate = $samedate && $startdate['year'] == $enddate['year'];
+                $samedate = $samedate && $startdate['mday'] == $enddate['mday'];
+
+                if (!$samedate) {
+                    $values['popupname'] = get_string('eventendtimewrapped', 'calendar', $values['popupname']);
+                }
+            }
+        }
+
+        // Include course's shortname into the event name, if applicable.
+        $course = $this->event->get_course();
+        if ($course && $course->get('id') && $course->get('id') !== SITEID) {
+            $eventnameparams = (object) [
+                'name' => $values['popupname'],
+                'course' => format_string($course->get('shortname'), true, [
+                        'context' => $this->related['context'],
+                    ])
+            ];
+            $values['popupname'] = get_string('eventnameandcourse', 'calendar', $eventnameparams);
+        }
+
+        $values['calendareventtype'] = $this->get_calendar_event_type();
+
         return $values;
     }
 
@@ -76,7 +137,24 @@ class calendar_event_exporter extends event_exporter_base {
     protected static function define_related() {
         $related = parent::define_related();
         $related['daylink'] = \moodle_url::class;
+        $related['type'] = '\core_calendar\type_base';
+        $related['today'] = 'int';
 
         return $related;
     }
+
+    /**
+     * Return the normalised event type.
+     * Activity events are normalised to be course events.
+     *
+     * @return string
+     */
+    public function get_calendar_event_type() {
+        $type = $this->event->get_type();
+        if ($type == 'open' || $type == 'close') {
+            $type = 'course';
+        }
+
+        return $type;
+    }
 }
index 4a713bc..0f5b92b 100644 (file)
@@ -39,6 +39,24 @@ use moodle_url;
  */
 class day_exporter extends exporter {
 
+    /**
+     * @var \calendar_information $calendar The calendar being displayed.
+     */
+    protected $calendar;
+
+    /**
+     * Constructor.
+     *
+     * @param \calendar_information $calendar The calendar information for the period being displayed
+     * @param mixed $data Either an stdClass or an array of values.
+     * @param array $related Related objects.
+     */
+    public function __construct(\calendar_information $calendar, $data, $related) {
+        $this->calendar = $calendar;
+
+        parent::__construct($data, $related);
+    }
+
     /**
      * Return the list of properties.
      *
@@ -69,6 +87,15 @@ class day_exporter extends exporter {
             'yday' => [
                 'type' => PARAM_INT,
             ],
+            // These are additional params.
+            'istoday' => [
+                'type' => PARAM_BOOL,
+                'default' => false,
+            ],
+            'isweekend' => [
+                'type' => PARAM_BOOL,
+                'default' => false,
+            ],
         ];
     }
 
@@ -85,14 +112,6 @@ class day_exporter extends exporter {
             'neweventtimestamp' => [
                 'type' => PARAM_INT,
             ],
-            'istoday' => [
-                'type' => PARAM_BOOL,
-                'default' => false,
-            ],
-            'isweekend' => [
-                'type' => PARAM_BOOL,
-                'default' => false,
-            ],
             'viewdaylink' => [
                 'type' => PARAM_URL,
                 'optional' => true,
@@ -100,7 +119,19 @@ class day_exporter extends exporter {
             'events' => [
                 'type' => calendar_event_exporter::read_properties_definition(),
                 'multiple' => true,
-            ]
+            ],
+            'calendareventtypes' => [
+                'type' => PARAM_RAW,
+                'multiple' => true,
+            ],
+            'popovertitle' => [
+                'type' => PARAM_RAW,
+                'default' => '',
+            ],
+            'haslastdayofevent' => [
+                'type' => PARAM_BOOL,
+                'default' => false,
+            ],
         ];
     }
 
@@ -131,22 +162,46 @@ class day_exporter extends exporter {
         $url = new moodle_url('/calendar/view.php', [
                 'view' => 'day',
                 'time' => $timestamp,
+                'course' => $this->calendar->course->id,
             ]);
         $return['viewdaylink'] = $url->out(false);
 
         $cache = $this->related['cache'];
-        $return['events'] = array_map(function($event) use ($cache, $output, $url) {
+        $eventexporters = array_map(function($event) use ($cache, $output, $url) {
             $context = $cache->get_context($event);
             $course = $cache->get_course($event);
             $exporter = new calendar_event_exporter($event, [
                 'context' => $context,
                 'course' => $course,
                 'daylink' => $url,
+                'type' => $this->related['type'],
+                'today' => $this->data[0],
             ]);
 
-            return $exporter->export($output);
+            return $exporter;
         }, $this->related['events']);
 
+        $return['events'] = array_map(function($exporter) use ($output) {
+            return $exporter->export($output);
+        }, $eventexporters);
+
+        if ($popovertitle = $this->get_popover_title()) {
+            $return['popovertitle'] = $popovertitle;
+        }
+
+        $return['calendareventtypes'] = array_map(function($exporter) {
+            return $exporter->get_calendar_event_type();
+        }, $eventexporters);
+        $return['calendareventtypes'] = array_values(array_unique($return['calendareventtypes']));
+
+        $return['haslastdayofevent'] = false;
+        foreach ($return['events'] as $event) {
+            if ($event->islastday) {
+                $return['haslastdayofevent'] = true;
+                break;
+            }
+        }
+
         return $return;
     }
 
@@ -162,4 +217,26 @@ class day_exporter extends exporter {
             'type' => '\core_calendar\type_base',
         ];
     }
+
+    /**
+     * Get the title for this popover.
+     *
+     * @return string
+     */
+    protected function get_popover_title() {
+        $title = null;
+
+        $userdate = userdate($this->data[0], get_string('strftimedayshort'));
+        if (count($this->related['events'])) {
+            $title = get_string('eventsfor', 'calendar', $userdate);
+        } else if ($this->data['istoday']) {
+            $title = $userdate;
+        }
+
+        if ($this->data['istoday']) {
+            $title = get_string('todayplustitle', 'calendar', $userdate);
+        }
+
+        return $title;
+    }
 }
index cc7ef38..813cb58 100644 (file)
@@ -27,6 +27,7 @@ namespace core_calendar\external;
 defined('MOODLE_INTERNAL') || die();
 
 require_once($CFG->dirroot . "/calendar/lib.php");
+require_once($CFG->libdir . "/filelib.php");
 
 use \core\external\exporter;
 use \core_calendar\local\event\container;
@@ -66,7 +67,14 @@ class event_exporter_base extends exporter {
         $data = new \stdClass();
         $data->id = $event->get_id();
         $data->name = $event->get_name();
-        $data->description = $event->get_description()->get_value();
+        $data->description = file_rewrite_pluginfile_urls(
+            $event->get_description()->get_value(),
+            'pluginfile.php',
+            $related['context']->id,
+            'calendar',
+            'event_description',
+            $event->get_id()
+        );
         $data->descriptionformat = $event->get_description()->get_format();
         $data->groupid = $groupid;
         $data->userid = $userid;
index 9b99498..6d180aa 100644 (file)
@@ -76,7 +76,19 @@ class month_exporter extends exporter {
 
         $related['type'] = $type;
 
-        parent::__construct([], $related);
+        $data = [
+            'url' => $this->url->out(false),
+        ];
+
+        parent::__construct($data, $related);
+    }
+
+    protected static function define_properties() {
+        return [
+            'url' => [
+                'type' => PARAM_URL,
+            ],
+        ];
     }
 
     /**
@@ -106,15 +118,38 @@ class month_exporter extends exporter {
             'view' => [
                 'type' => PARAM_ALPHA,
             ],
+            'time' => [
+                'type' => PARAM_INT,
+            ],
+            'periodname' => [
+                // Note: We must use RAW here because the calendar type returns the formatted month name based on a
+                // calendar format.
+                'type' => PARAM_RAW,
+            ],
             'previousperiod' => [
                 'type' => PARAM_INT,
             ],
+            'previousperiodname' => [
+                // Note: We must use RAW here because the calendar type returns the formatted month name based on a
+                // calendar format.
+                'type' => PARAM_RAW,
+            ],
             'nextperiod' => [
                 'type' => PARAM_INT,
             ],
-            'time' => [
-                'type' => PARAM_INT,
-            ]
+            'nextperiodname' => [
+                // Note: We must use RAW here because the calendar type returns the formatted month name based on a
+                // calendar format.
+                'type' => PARAM_RAW,
+            ],
+            'larrow' => [
+                // The left arrow defined by the theme.
+                'type' => PARAM_RAW,
+            ],
+            'rarrow' => [
+                // The right arrow defined by the theme.
+                'type' => PARAM_RAW,
+            ],
         ];
     }
 
@@ -125,16 +160,24 @@ class month_exporter extends exporter {
      * @return array Keys are the property names, values are their values.
      */
     protected function get_other_values(renderer_base $output) {
+        $previousperiod = $this->get_previous_month_timestamp();
+        $nextperiod = $this->get_next_month_timestamp();
+
         return [
             'courseid' => $this->calendar->courseid,
-            'view' => 'month',
-            'previousperiod' => $this->get_previous_month_timestamp(),
-            'nextperiod' => $this->get_next_month_timestamp(),
             'filter_selector' => $this->get_course_filter_selector($output),
             'navigation' => $this->get_navigation($output),
             'weeks' => $this->get_weeks($output),
             'daynames' => $this->get_day_names($output),
-            'time' => $this->calendar->time
+            'view' => 'month',
+            'time' => $this->calendar->time,
+            'periodname' => userdate($this->calendar->time, get_string('strftimemonthyear')),
+            'previousperiod' => $previousperiod,
+            'previousperiodname' => userdate($previousperiod, get_string('strftimemonthyear')),
+            'nextperiod' => $nextperiod,
+            'nextperiodname' => userdate($nextperiod, get_string('strftimemonthyear')),
+            'larrow' => $output->larrow(),
+            'rarrow' => $output->rarrow(),
         ];
     }
 
@@ -213,13 +256,13 @@ class month_exporter extends exporter {
         $prepadding = ($firstdayno + $daysinweek - 1) % $daysinweek;
         $daysinfirstweek = $daysinweek - $prepadding;
         $days = array_slice($alldays, 0, $daysinfirstweek);
-        $week = new week_exporter($days, $prepadding, ($daysinweek - count($days) - $prepadding), $this->related);
+        $week = new week_exporter($this->calendar, $days, $prepadding, ($daysinweek - count($days) - $prepadding), $this->related);
         $weeks[] = $week->export($output);
 
         // Now chunk up the remaining day. and turn them into weeks.
         $daychunks = array_chunk(array_slice($alldays, $daysinfirstweek), $daysinweek);
         foreach ($daychunks as $days) {
-            $week = new week_exporter($days, 0, ($daysinweek - count($days)), $this->related);
+            $week = new week_exporter($this->calendar, $days, 0, ($daysinweek - count($days)), $this->related);
             $weeks[] = $week->export($output);
         }
 
@@ -279,7 +322,7 @@ class month_exporter extends exporter {
      */
     protected function get_next_month_timestamp() {
         $date = $this->related['type']->timestamp_to_date_array($this->calendar->time);
-        $month = calendar_sub_month($date['mon'], $date['year']);
+        $month = calendar_add_month($date['mon'], $date['year']);
         $monthtime = $this->related['type']->convert_to_gregorian($month[1], $month[0], 1);
 
         return make_timestamp($monthtime['year'], $monthtime['month'], $monthtime['day'], $monthtime['hour'], $monthtime['minute']);
diff --git a/calendar/classes/external/week_day_exporter.php b/calendar/classes/external/week_day_exporter.php
new file mode 100644 (file)
index 0000000..67442f5
--- /dev/null
@@ -0,0 +1,180 @@
+<?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/>.
+
+/**
+ * Contains event class for displaying the day on month view.
+ *
+ * @package   core_calendar
+ * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_calendar\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+use renderer_base;
+use moodle_url;
+
+/**
+ * Class for displaying the day on month view.
+ *
+ * @package   core_calendar
+ * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class week_day_exporter extends day_exporter {
+
+    /**
+     * Return the list of additional properties.
+     *
+     * @return array
+     */
+    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;
+    }
+
+    /**
+     * Get the additional values to inject while exporting.
+     *
+     * @param renderer_base $output The renderer.
+     * @return array Keys are the property names, values are their values.
+     */
+    protected function get_other_values(renderer_base $output) {
+        $timestamp = $this->data[0];
+        // Need to account for user's timezone.
+        $usernow = usergetdate(time());
+        $today = new \DateTimeImmutable();
+        // The start time should use the day's date but the current
+        // time of the day (adjusted for user's timezone).
+        $neweventstarttime = $today->setTimestamp($timestamp)->setTime(
+            $usernow['hours'],
+            $usernow['minutes'],
+            $usernow['seconds']
+        );
+
+        $return = [
+            'timestamp' => $timestamp,
+            'neweventtimestamp' => $neweventstarttime->getTimestamp()
+        ];
+
+        $url = new moodle_url('/calendar/view.php', [
+                'view' => 'day',
+                'time' => $timestamp,
+                'course' => $this->calendar->course->id,
+            ]);
+        $return['viewdaylink'] = $url->out(false);
+
+        $cache = $this->related['cache'];
+        $eventexporters = array_map(function($event) use ($cache, $output, $url) {
+            $context = $cache->get_context($event);
+            $course = $cache->get_course($event);
+            $exporter = new calendar_event_exporter($event, [
+                'context' => $context,
+                'course' => $course,
+                'daylink' => $url,
+                'type' => $this->related['type'],
+                'today' => $this->data[0],
+            ]);
+
+            return $exporter;
+        }, $this->related['events']);
+
+        $return['events'] = array_map(function($exporter) use ($output) {
+            return $exporter->export($output);
+        }, $eventexporters);
+
+        if ($popovertitle = $this->get_popover_title()) {
+            $return['popovertitle'] = $popovertitle;
+        }
+
+        $return['calendareventtypes'] = array_map(function($exporter) {
+            return $exporter->get_calendar_event_type();
+        }, $eventexporters);
+        $return['calendareventtypes'] = array_values(array_unique($return['calendareventtypes']));
+
+        $return['haslastdayofevent'] = false;
+        foreach ($return['events'] as $event) {
+            if ($event->islastday) {
+                $return['haslastdayofevent'] = true;
+                break;
+            }
+        }
+
+        return $return;
+    }
+
+    /**
+     * Returns a list of objects that are related.
+     *
+     * @return array
+     */
+    protected static function define_related() {
+        return [
+            'events' => '\core_calendar\local\event\entities\event_interface[]',
+            'cache' => '\core_calendar\external\events_related_objects_cache',
+            'type' => '\core_calendar\type_base',
+        ];
+    }
+
+    /**
+     * Get the title for this popover.
+     *
+     * @return string
+     */
+    protected function get_popover_title() {
+        $title = null;
+
+        $userdate = userdate($this->data[0], get_string('strftimedayshort'));
+        if (count($this->related['events'])) {
+            $title = get_string('eventsfor', 'calendar', $userdate);
+        } else if ($this->data['istoday']) {
+            $title = $userdate;
+        }
+
+        if ($this->data['istoday']) {
+            $title = get_string('todayplustitle', 'calendar', $userdate);
+        }
+
+        return $title;
+    }
+}
index afc92b2..ff75a35 100644 (file)
@@ -53,18 +53,25 @@ class week_exporter extends exporter {
      */
     protected $postpadding = 0;
 
+    /**
+     * @var \calendar_information $calendar The calendar being displayed.
+     */
+    protected $calendar;
+
     /**
      * Constructor.
      *
+     * @param \calendar_information $calendar The calendar information for the period being displayed
      * @param mixed $days An array of day_exporter objects.
      * @param int $prepadding The number of pre-padding days at the start of the week.
      * @param int $postpadding The number of post-padding days at the start of the week.
      * @param array $related Related objects.
      */
-    public function __construct($days, $prepadding, $postpadding, $related) {
+    public function __construct(\calendar_information $calendar, $days, $prepadding, $postpadding, $related) {
         $this->days = $days;
         $this->prepadding = $prepadding;
         $this->postpadding = $postpadding;
+        $this->calendar = $calendar;
 
         parent::__construct([], $related);
     }
@@ -85,7 +92,7 @@ class week_exporter extends exporter {
                 'multiple' => true,
             ],
             'days' => [
-                'type' => day_exporter::read_properties_definition(),
+                'type' => week_day_exporter::read_properties_definition(),
                 'multiple' => true,
             ],
         ];
@@ -98,6 +105,7 @@ class week_exporter extends exporter {
      * @return array Keys are the property names, values are their values.
      */
     protected function get_other_values(renderer_base $output) {
+        global $CFG;
         $return = [
             'prepadding' => [],
             'postpadding' => [],
@@ -112,6 +120,14 @@ class week_exporter extends exporter {
         }
 
         $return['days'] = [];
+        $today = $this->related['type']->timestamp_to_date_array(time());
+
+        $weekend = CALENDAR_DEFAULT_WEEKEND;
+        if (isset($CFG->calendar_weekend)) {
+            $weekend = intval($CFG->calendar_weekend);
+        }
+        $numberofdaysinweek = $this->related['type']->get_num_weekdays();
+
         foreach ($this->days as $daydata) {
             $events = [];
             foreach ($this->related['events'] as $event) {
@@ -132,7 +148,14 @@ class week_exporter extends exporter {
                 $events[] = $event;
             }
 
-            $day = new day_exporter($daydata, [
+            $istoday = true;
+            $istoday = $istoday && $today['year'] == $daydata['year'];
+            $istoday = $istoday && $today['yday'] == $daydata['yday'];
+            $daydata['istoday'] = $istoday;
+
+            $daydata['isweekend'] = !!($weekend & (1 << ($daydata['wday'] % $numberofdaysinweek)));
+
+            $day = new week_day_exporter($this->calendar, $daydata, [
                 'events' => $events,
                 'cache' => $this->related['cache'],
                 'type' => $this->related['type'],
index 165b145..0a6b757 100644 (file)
@@ -34,6 +34,24 @@ require_once($CFG->dirroot.'/lib/formslib.php');
  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class create extends \moodleform {
+
+    /**
+     * Build the editor options using the given context.
+     *
+     * @param \context $context A Moodle context
+     * @return array
+     */
+    public static function build_editor_options(\context $context) {
+        global $CFG;
+
+        return [
+            'context' => $context,
+            'maxfiles' => EDITOR_UNLIMITED_FILES,
+            'maxbytes' => $CFG->maxbytes,
+            'noclean' => true
+        ];
+    }
+
     /**
      * The form definition
      */
@@ -41,8 +59,8 @@ class create extends \moodleform {
         global $PAGE;
 
         $mform = $this->_form;
-        $haserror = !empty($this->_customdata['haserror']);
         $starttime = isset($this->_customdata['starttime']) ? $this->_customdata['starttime'] : 0;
+        $editoroptions = !(empty($this->_customdata['editoroptions'])) ? $this->_customdata['editoroptions'] : null;
         $eventtypes = calendar_get_all_allowed_types();
 
         if (empty($eventtypes)) {
@@ -71,7 +89,7 @@ class create extends \moodleform {
         // Start of advanced elements.
         // Advanced elements are not visible to the user by default.
         // They are displayed through the user of a show more / less button.
-        $mform->addElement('editor', 'description', get_string('eventdescription', 'calendar'), ['rows' => 3]);
+        $mform->addElement('editor', 'description', get_string('eventdescription', 'calendar'), ['rows' => 3], $editoroptions);
         $mform->setType('description', PARAM_RAW);
         $mform->setAdvanced('description');
 
@@ -82,8 +100,7 @@ class create extends \moodleform {
         $this->add_event_repeat_elements($mform);
 
         // Add the javascript required to enhance this mform.
-        // Including the show/hide of advanced elements and the display of the correct select elements for event types.
-        $PAGE->requires->js_call_amd('core_calendar/event_form', 'init', [$mform->getAttribute('id'), $haserror]);
+        $PAGE->requires->js_call_amd('core_calendar/event_form', 'init', [$mform->getAttribute('id')]);
     }
 
     /**
index b0bb691..75a688f 100644 (file)
@@ -52,7 +52,7 @@ class create_update_form_mapper implements create_update_form_mapper_interface {
      */
     public function from_legacy_event_to_data(\calendar_event $legacyevent) {
         $legacyevent->count_repeats();
-        $data = $legacyevent->properties(true);
+        $data = $legacyevent->properties();
         $data->timedurationuntil = $legacyevent->timestart + $legacyevent->timeduration;
         $data->duration = (empty($legacyevent->timeduration)) ? 0 : 1;
 
@@ -62,6 +62,17 @@ class create_update_form_mapper implements create_update_form_mapper_interface {
             $data->groupcourseid = $legacyevent->courseid;
         }
 
+        $data->description = [
+            'text' => $data->description,
+            'format' => $data->format
+        ];
+
+        // We don't want to return the context because it's not a
+        // form value and breaks the validation.
+        if (isset($data->context)) {
+            unset($data->context);
+        }
+
         return $data;
     }
 
index 6000a25..afad7dd 100644 (file)
@@ -779,6 +779,7 @@ class core_calendar_external extends external_api {
     public static function submit_create_update_form($formdata) {
         global $CFG, $USER, $PAGE;
         require_once($CFG->dirroot."/calendar/lib.php");
+        require_once($CFG->libdir."/filelib.php");
 
         // Parameter validation.
         $params = self::validate_parameters(self::submit_create_update_form_parameters(), ['formdata' => $formdata]);
@@ -816,6 +817,31 @@ class core_calendar_external extends external_api {
             }
 
             $legacyevent->update($properties);
+            $eventcontext = $legacyevent->context;
+
+            file_remove_editor_orphaned_files($validateddata->description);
+
+            // Take any files added to the description draft file area and
+            // convert them into the proper event description file area. Also
+            // parse the description text and replace the URLs to the draft files
+            // with the @@PLUGIN_FILE@@ placeholder to be persisted in the DB.
+            $description = file_save_draft_area_files(
+                $validateddata->description['itemid'],
+                $eventcontext->id,
+                'calendar',
+                'event_description',
+                $legacyevent->id,
+                create_event_form::build_editor_options($eventcontext),
+                $validateddata->description['text']
+            );
+
+            // If draft files were found then we need to save the new
+            // description value.
+            if ($description != $validateddata->description['text']) {
+                $properties->id = $legacyevent->id;
+                $properties->description = $description;
+                $legacyevent->update($properties);
+            }
 
             $eventmapper = event_container::get_event_mapper();
             $event = $eventmapper->from_legacy_event_to_event($legacyevent);
index f574472..0c168ba 100644 (file)
@@ -1049,7 +1049,7 @@ class calendar_information {
     public function add_sidecalendar_blocks(core_calendar_renderer $renderer, $showfilters=false, $view=null) {
         if ($showfilters) {
             $filters = new block_contents();
-            $filters->content = $renderer->fake_block_filters($this->courseid, 0, 0, 0, $view, $this->courses);
+            $filters->content = $renderer->event_filter();
             $filters->footer = '';
             $filters->title = get_string('eventskey', 'calendar');
             $renderer->add_pretend_calendar_block($filters, BLOCK_POS_RIGHT);
@@ -1478,7 +1478,7 @@ function calendar_get_mini($courses, $groups, $users, $calmonth = false, $calyea
 
             // Class and cell content.
             if (isset($typesbyday[$day]['startglobal'])) {
-                $class .= ' calendar_event_global';
+                $class .= ' calendar_event_site';
             } else if (isset($typesbyday[$day]['startcourse'])) {
                 $class .= ' calendar_event_course';
             } else if (isset($typesbyday[$day]['startgroup'])) {
@@ -1981,88 +1981,6 @@ function calendar_top_controls($type, $data) {
     return $content;
 }
 
-/**
- * Formats a filter control element.
- *
- * @param moodle_url $url of the filter
- * @param int $type constant defining the type filter
- * @return string html content of the element
- */
-function calendar_filter_controls_element(moodle_url $url, $type) {
-    global $OUTPUT;
-
-    switch ($type) {
-        case CALENDAR_EVENT_GLOBAL:
-            $typeforhumans = 'global';
-            $class = 'calendar_event_global';
-            break;
-        case CALENDAR_EVENT_COURSE:
-            $typeforhumans = 'course';
-            $class = 'calendar_event_course';
-            break;
-        case CALENDAR_EVENT_GROUP:
-            $typeforhumans = 'groups';
-            $class = 'calendar_event_group';
-            break;
-        case CALENDAR_EVENT_USER:
-            $typeforhumans = 'user';
-            $class = 'calendar_event_user';
-            break;
-    }
-
-    if (calendar_show_event_type($type)) {
-        $icon = $OUTPUT->pix_icon('t/hide', get_string('hide'));
-        $str = get_string('hide' . $typeforhumans . 'events', 'calendar');
-    } else {
-        $icon = $OUTPUT->pix_icon('t/show', get_string('show'));
-        $str = get_string('show' . $typeforhumans . 'events', 'calendar');
-    }
-    $content = \html_writer::start_tag('li', array('class' => 'calendar_event'));
-    $content .= \html_writer::start_tag('a', array('href' => $url, 'rel' => 'nofollow'));
-    $content .= \html_writer::tag('span', $icon, array('class' => $class));
-    $content .= \html_writer::tag('span', $str, array('class' => 'eventname'));
-    $content .= \html_writer::end_tag('a');
-    $content .= \html_writer::end_tag('li');
-
-    return $content;
-}
-
-/**
- * Get the controls filter for calendar.
- *
- * Filter is used to hide calendar info from the display page.
- *
-
- * @param moodle_url $returnurl return-url for filter controls
- * @return string $content return filter controls in html
- */
-function calendar_filter_controls(moodle_url $returnurl) {
-    $groupevents = true;
-
-    $seturl = new \moodle_url('/calendar/set.php', array('return' => base64_encode($returnurl->out_as_local_url(false)),
-        'sesskey' => sesskey()));
-    $content = \html_writer::start_tag('ul');
-
-    $seturl->param('var', 'showglobal');
-    $content .= calendar_filter_controls_element($seturl, CALENDAR_EVENT_GLOBAL);
-
-    $seturl->param('var', 'showcourses');
-    $content .= calendar_filter_controls_element($seturl, CALENDAR_EVENT_COURSE);
-
-    if (isloggedin() && !isguestuser()) {
-        if ($groupevents) {
-            // This course MIGHT have group events defined, so show the filter.
-            $seturl->param('var', 'showgroups');
-            $content .= calendar_filter_controls_element($seturl, CALENDAR_EVENT_GROUP);
-        }
-        $seturl->param('var', 'showuser');
-        $content .= calendar_filter_controls_element($seturl, CALENDAR_EVENT_USER);
-    }
-    $content .= \html_writer::end_tag('ul');
-
-    return $content;
-}
-
 /**
  * Return the representation day.
  *
@@ -3464,6 +3382,11 @@ function calendar_get_view(\calendar_information $calendar, $view) {
         $monthdays = $type->get_num_days_in_month($date['year'], $date['mon']);
         $tend = $tstart + ($monthdays * DAYSECS) - 1;
         $selectortitle = get_string('detailedmonthviewfor', 'calendar');
+        if ($view === 'mini') {
+            $template = 'core_calendar/calendar_mini';
+        } else {
+            $template = 'core_calendar/calendar_month';
+        }
     }
 
     list($userparam, $groupparam, $courseparam) = array_map(function($param) {
@@ -3518,7 +3441,6 @@ function calendar_get_view(\calendar_information $calendar, $view) {
 
     $month = new \core_calendar\external\month_exporter($calendar, $type, $related);
     $data = $month->export($renderer);
-    $template = 'core_calendar/month_detailed';
 
     return [$data, $template];
 }
@@ -3530,24 +3452,25 @@ function calendar_get_view(\calendar_information $calendar, $view) {
  * @return string The rendered mform fragment.
  */
 function calendar_output_fragment_event_form($args) {
-    global $CFG, $OUTPUT;
-    require_once($CFG->dirroot.'/calendar/event_form.php');
+    global $CFG, $OUTPUT, $USER;
 
     $html = '';
-    $data = null;
+    $data = [];
     $eventid = isset($args['eventid']) ? clean_param($args['eventid'], PARAM_INT) : null;
     $starttime = isset($args['starttime']) ? clean_param($args['starttime'], PARAM_INT) : null;
     $courseid = isset($args['courseid']) ? clean_param($args['courseid'], PARAM_INT) : null;
     $event = null;
     $hasformdata = isset($args['formdata']) && !empty($args['formdata']);
-    $formoptions = [];
+    $context = \context_user::instance($USER->id);
+    $editoroptions = \core_calendar\local\event\forms\create::build_editor_options($context);
+    $formoptions = ['editoroptions' => $editoroptions];
+    $draftitemid = 0;
 
     if ($hasformdata) {
         parse_str(clean_param($args['formdata'], PARAM_TEXT), $data);
-    }
-
-    if (isset($args['haserror'])) {
-        $formoptions['haserror'] = clean_param($args['haserror'], PARAM_BOOL);
+        if (isset($data['description']['itemid'])) {
+            $draftitemid = $data['description']['itemid'];
+        }
     }
 
     if ($starttime) {
@@ -3572,8 +3495,22 @@ function calendar_output_fragment_event_form($args) {
         $mform->set_data($data);
     } else {
         $event = calendar_event::load($eventid);
+        $mapper = new \core_calendar\local\event\mappers\create_update_form_mapper();
+        $eventdata = $mapper->from_legacy_event_to_data($event);
+        $data = array_merge((array) $eventdata, $data);
         $event->count_repeats();
         $formoptions['event'] = $event;
+        $data['description']['text'] = file_prepare_draft_area(
+            $draftitemid,
+            $event->context->id,
+            'calendar',
+            'event_description',
+            $event->id,
+            null,
+            $data['description']['text']
+        );
+        $data['description']['itemid'] = $draftitemid;
+
         $mform = new \core_calendar\local\event\forms\update(
             null,
             $formoptions,
@@ -3583,13 +3520,6 @@ function calendar_output_fragment_event_form($args) {
             true,
             $data
         );
-    }
-
-    if ($hasformdata) {
-        $mform->is_validated();
-    } else if (!is_null($event)) {
-        $mapper = new \core_calendar\local\event\mappers\create_update_form_mapper();
-        $data = $mapper->from_legacy_event_to_data($event);
         $mform->set_data($data);
 
         // Check to see if this event is part of a subscription or import.
@@ -3604,6 +3534,10 @@ function calendar_output_fragment_event_form($args) {
         }
     }
 
+    if ($hasformdata) {
+        $mform->is_validated();
+    }
+
     $html .= $mform->render();
     return $html;
 }
@@ -3654,3 +3588,25 @@ function calendar_get_footer_options($calendar) {
 
     return [$data, $template];
 }
+
+/**
+ * Get the list of potential calendar filter types as a type => name
+ * combination.
+ *
+ * @return array
+ */
+function calendar_get_filter_types() {
+    $types = [
+        'site',
+        'course',
+        'group',
+        'user',
+    ];
+
+    return array_map(function($type) {
+        return [
+            'type' => $type,
+            'name' => get_string("eventtype{$type}", "calendar"),
+        ];
+    }, $types);
+}
index 1818234..dc597ab 100644 (file)
@@ -50,24 +50,6 @@ class core_calendar_renderer extends plugin_renderer_base {
         return html_writer::end_tag('div');
     }
 
-    /**
-     * Produces the content for the filters block (pretend block)
-     *
-     * @param int $courseid
-     * @param int $day
-     * @param int $month
-     * @param int $year
-     * @param int $view
-     * @param int $courses
-     * @return string
-     */
-    public function fake_block_filters($courseid, $day, $month, $year, $view, $courses) {
-        $returnurl = $this->page->url;
-        $returnurl->param('course', $courseid);
-        return html_writer::tag('div', calendar_filter_controls($returnurl),
-            array('class' => 'calendar_filters filters'));
-    }
-
     /**
      * Produces the content for the three months block (pretend block)
      *
@@ -502,4 +484,16 @@ class core_calendar_renderer extends plugin_renderer_base {
         $html .= html_writer::end_tag('form');
         return $html;
     }
+
+    /**
+     * Render the event filter region.
+     *
+     * @return  string
+     */
+    public function event_filter() {
+        $data = [
+            'eventtypes' => calendar_get_filter_types(),
+        ];
+        return $this->render_from_template('core_calendar/event_filter', $data);
+    }
 }
diff --git a/calendar/set.php b/calendar/set.php
deleted file mode 100644 (file)
index c69de93..0000000
+++ /dev/null
@@ -1,60 +0,0 @@
-<?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/>.
-
-/**
- * Sets the events filter for the calendar view.
- *
- * @package   core_calendar
- * @copyright 2003 Jon Papaioannou (pj@moodle.org)
- * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-require_once('../config.php');
-require_once($CFG->dirroot.'/calendar/lib.php');
-
-$var = required_param('var', PARAM_ALPHA);
-$return = clean_param(base64_decode(required_param('return', PARAM_RAW)), PARAM_LOCALURL);
-$courseid = optional_param('id', -1, PARAM_INT);
-if ($courseid != -1) {
-    $return = new moodle_url($return, array('course' => $courseid));
-} else {
-    $return = new moodle_url($return);
-}
-
-if (!confirm_sesskey()) {
-    // Do not call require_sesskey() since this page may be accessed without session (for example by bots).
-    redirect($return);
-}
-
-$url = new moodle_url('/calendar/set.php', array('return'=>base64_encode($return->out_as_local_url(false)), 'course' => $courseid, 'var'=>$var, 'sesskey'=>sesskey()));
-$PAGE->set_url($url);
-$PAGE->set_context(context_system::instance());
-
-switch($var) {
-    case 'showgroups':
-        calendar_set_event_type_display(CALENDAR_EVENT_GROUP);
-        break;
-    case 'showcourses':
-        calendar_set_event_type_display(CALENDAR_EVENT_COURSE);
-        break;
-    case 'showglobal':
-        calendar_set_event_type_display(CALENDAR_EVENT_GLOBAL);
-        break;
-    case 'showuser':
-        calendar_set_event_type_display(CALENDAR_EVENT_USER);
-        break;
-}
-
-redirect($return);
diff --git a/calendar/templates/calendar_mini.mustache b/calendar/templates/calendar_mini.mustache
new file mode 100644 (file)
index 0000000..6e28cbf
--- /dev/null
@@ -0,0 +1,45 @@
+{{!
+    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 calendar/calendar_mini
+
+    Calendar month view for blocks.
+
+    The purpose of this template is to render the month view for blocks.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Example context (json):
+    {
+    }
+}}
+<div{{!
+  }} id="calendar-month-{{uniqid}}-{{time}}" {{!
+    }} data-template="core_calendar/month_mini" {{!
+    }} data-includenavigation="{{#includenavigation}}true{{/includenavigation}}{{^includenavigation}}false{{/includenavigation}}"{{!
+    }}>
+    {{> core_calendar/month_mini}}
+</div>
+{{#js}}
+require(['jquery', 'core_calendar/calendar_mini'], function($, CalendarMini) {
+    CalendarMini.init($("#calendar-month-{{uniqid}}-{{time}}"));
+});
+{{/js}}
diff --git a/calendar/templates/calendar_month.mustache b/calendar/templates/calendar_month.mustache
new file mode 100644 (file)
index 0000000..9e5866b
--- /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 calendar/calendar_month
+
+    Calendar month view for blcoks.
+
+    The purpose of this template is to render the main, detailed monthly view.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Example context (json):
+    {
+    }
+}}
+<div id="calendar-month-{{uniqid}}" data-template="core_calendar/month_detailed">
+    {{> core_calendar/month_detailed}}
+</div>
+{{#js}}
+require(['jquery', 'core_calendar/calendar'], function($, Calendar) {
+    Calendar.init($("#calendar-month-{{uniqid}}"));
+});
+{{/js}}
diff --git a/calendar/templates/event_filter.mustache b/calendar/templates/event_filter.mustache
new file mode 100644 (file)
index 0000000..4e6e789
--- /dev/null
@@ -0,0 +1,45 @@
+{{!
+    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 calendar/event_filter
+
+    Filter to select which event types displayed on this page.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Example context (json):
+    {
+    }
+}}
+<div id="event-filter-{{uniqid}}" class="calendar_filters m-t-2">
+    <ul>
+        {{#eventtypes}}
+            <li>
+                {{> core_calendar/event_filter_key }}
+            </li>
+        {{/eventtypes}}
+    </ul>
+</div>
+{{#js}}
+require(['jquery', 'core_calendar/calendar_filter'], function($, CalendarFilter) {
+    CalendarFilter.init($("#event-filter-{{uniqid}}"));
+});
+{{/js}}
diff --git a/calendar/templates/event_filter_key.mustache b/calendar/templates/event_filter_key.mustache
new file mode 100644 (file)
index 0000000..be1154b
--- /dev/null
@@ -0,0 +1,45 @@
+{{!
+    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 calendar/event_filter_key
+
+    Key item for the event filter.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Example context (json):
+    {
+    }
+}}
+<a href="#" data-action="filter-event-type" data-eventtype="{{type}}" data-eventtype-hidden="{{hidden}}">
+    {{^hidden}}
+        <span class="calendar_event_{{type}}">
+            {{#pix}}i/hide, core, {{#str}}hideeventtype, calendar, {{name}}{{/str}}{{/pix}}
+        </span>
+        <span class="eventname">{{#str}}hideeventtype, calendar, {{name}}{{/str}}</span>
+    {{/hidden}}
+    {{#hidden}}
+        <span class="calendar_event_{{type}}">
+            {{#pix}}i/show, core, {{#str}}showeventtype, calendar, {{name}}{{/str}}{{/pix}}
+        </span>
+        <span class="eventname">{{#str}}showeventtype, calendar, {{name}}{{/str}}</span>
+    {{/hidden}}
+</a>
index bdb5e67..d6c91af 100644 (file)
 }}
 {{< core/hover_tooltip }}
     {{$anchor}}
-        <a href="{{url}}">{{day}}</a>
+        <a href="{{url}}">{{$day}}{{day}}{{/day}}</a>
     {{/anchor}}
     {{$tooltip}}
-        <b>{{title}}</b>
-        {{{content}}}
+        <b>{{$title}}{{title}}{{/title}}</b>
+        {{$content}}{{{content}}}{{/content}}
     {{/tooltip}}
 {{/ core/hover_tooltip }}
index e99c491..99a2f59 100644 (file)
 
 {{< core/modal }}
     {{$footer}}
-        <button type="button"
-                class="btn btn-secondary"
-                data-collapsed="true"
-                data-action="more-less-toggle">
-
-            {{#str}} more, calendar {{/str}}
-        </button>
         <button type="button"
                 class="btn btn-primary"
                 data-context-id="{{contextid}}"
index 80005dc..463b687 100644 (file)
@@ -77,8 +77,9 @@
                                             <li class="events-underway">[{{name}}]</li>
                                         {{/underway}}
                                         {{^underway}}
-                                            <li class="calendar_event_{{eventtype}}"
+                                            <li class="calendar_event_{{calendareventtype}}"
                                                 data-region="event-item"
+                                                data-eventtype-{{calendareventtype}}="1"
                                                 {{#canedit}}
                                                     draggable="true"
                                                     data-drag-type="move"
     </table>
 </div>
 {{#js}}
-require(['jquery', 'core_calendar/month_view_drag_drop'], function($, DragDrop) {
+require([
+    'jquery',
+    'core_calendar/month_view_drag_drop',
+    'core_calendar/selectors',
+    'core_calendar/events',
+], function(
+    $,
+    DragDrop,
+    CalendarSelectors,
+    CalendarEvents
+) {
     var root = $('#month-detailed-{{uniqid}}');
     DragDrop.init(root);
+
+    $('body').on(CalendarEvents.filterChanged, function(e, data) {
+        M.util.js_pending("month-detailed-{{uniqid}}-filterChanged");
+        // A filter value has been changed.
+        // Find all matching cells in the popover data, and hide them.
+        $("#month-detailed-{{uniqid}}")
+            .find(CalendarSelectors.eventType[data.type])
+            .toggleClass('hidden', !!data.hidden);
+        M.util.js_complete("month-detailed-{{uniqid}}-filterChanged");
+    });
 });
 {{/js}}
diff --git a/calendar/templates/month_mini.mustache b/calendar/templates/month_mini.mustache
new file mode 100644 (file)
index 0000000..b14afc9
--- /dev/null
@@ -0,0 +1,154 @@
+{{!
+    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 calendar/month_mini
+
+    Calendar month view for blocks.
+
+    The purpose of this template is to render the month view for blocks.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Example context (json):
+    {
+    }
+}}
+<div id="month-mini-{{uniqid}}-{{time}}" class="calendarwrapper" data-courseid="{{courseid}}" data-current-time="{{time}}">
+    {{> core/overlay_loading}}
+    <table class="minicalendar calendartable">
+        <caption class="calendar-controls">
+            <a href="#" class="arrow_link previous" title="{{#str}}monthprev, calendar{{/str}}" data-time="{{previousperiod}}"><span class="arrow">{{{larrow}}}</span></a>
+            <span class="hide"> | </span>
+            <span class="current">
+                <a href="{{{url}}}" title="{{#str}}monththis, calendar{{/str}}" data-time="">{{periodname}}</a>
+            </span>
+            <span class="hide"> | </span>
+            <a href="#" class="arrow_link next" title="{{#str}}monthnext, calendar{{/str}}" data-time="{{nextperiod}}"><span class="arrow">{{{rarrow}}}</span></a>
+        </caption>
+        <thead>
+          <tr>
+                {{# daynames }}
+                <th class="header text-xs-center" scope="col">
+                    <abbr title="{{fullname}}">{{shortname}}</abbr>
+                </th>
+                {{/ daynames }}
+            </tr>
+        </thead>
+        <tbody>
+{{#weeks}}
+            <tr data-region="month-view-week">
+                {{#prepadding}}
+                    <td class="dayblank">&nbsp;</td>
+                {{/prepadding}}
+                {{#days}}
+                    <td class="day text-center{{!
+                            This is the list of additional classes to display.
+
+                            This cell is for today.
+                            }}{{#istoday}} today{{/istoday}}{{!
+
+                            This day falls on a weekend.
+                            }}{{#isweekend}} weekend{{/isweekend}}{{!
+
+                            There is at least one event.
+                            }}{{#events.0}} hasevent{{/events.0}}{{!
+
+                            There are events on this day which span multiple days.
+                            }}{{#durationevents.0}} duration{{/durationevents.0}}{{!
+                            }}{{#durationevents}} duration_{{.}}{{/durationevents}}{{!
+                            }}{{#islastday}} duraction_finish{{/islastday}}{{!
+
+                            These event types fall on this day.
+                            }}{{#calendareventtypes}}{{!
+                            }} calendar_event_{{.}}{{!
+                            }}{{/calendareventtypes}}{{!
+
+                            This day contains the last day of an event.
+                            }}{{#haslastdayofevent}}{{!
+                            }} duration_finish{{!
+                            }}{{/haslastdayofevent}}{{!
+                        }}"{{!
+
+                        Add data-eventtype-[eventtype] data attributes.
+                        These are used to show and hide events using the filter.
+                        }}{{#calendareventtypes}}{{!
+                        }} data-eventtype-{{.}}="1"{{!
+                        }}{{/calendareventtypes}}{{!
+
+                        This is the timestamp for this month.
+                        }} data-day-timestamp="{{timestamp}}"{{!
+                    }}>{{!
+                        }}{{#popovertitle}}
+                            {{< core_calendar/minicalendar_day_link }}
+                                {{$day}}{{mday}}{{/day}}
+                                {{$url}}{{viewdaylink}}{{/url}}
+                                {{$title}}{{popovertitle}}{{/title}}
+                                {{$nocontent}}{{#str}}eventnone, calendar{{/str}}{{/nocontent}}
+                                {{$content}}
+                                    {{#events}}
+                                        <div data-popover-eventtype-{{calendareventtype}}="1">
+                                            {{#modulename}}
+                                                {{#pix}} icon, {{modulename}} {{/pix}}
+                                            {{/modulename}}
+                                            {{^modulename}}
+                                                {{#pix}} i/{{eventtype}}event, core {{/pix}}
+                                            {{/modulename}}
+                                            {{{popupname}}}
+                                        </div>
+                                    {{/events}}
+                                {{/content}}
+                            {{/ core_calendar/minicalendar_day_link }}
+                        {{/popovertitle}}{{!
+                        }}{{^popovertitle}}
+                            {{mday}}
+                        {{/popovertitle}}{{!
+                    }}</td>
+                {{/days}}
+                {{#postpadding}}
+                    <td class="dayblank">&nbsp;</td>
+                {{/postpadding}}
+            </tr>
+{{/weeks}}
+        </tbody>
+    </table>
+</div>
+{{#js}}
+require([
+    'jquery',
+    'core_calendar/selectors',
+    'core_calendar/events',
+], function(
+    $,
+    CalendarSelectors,
+    CalendarEvents
+) {
+
+    $('body').on(CalendarEvents.filterChanged, function(e, data) {
+        M.util.js_pending("month-mini-{{uniqid}}-filterChanged");
+        // A filter value has been changed.
+        // Find all matching cells in the popover data, and hide them.
+        $("#month-mini-{{uniqid}}-{{time}}")
+            .find(CalendarSelectors.popoverType[data.type])
+            .toggleClass('hidden', !!data.hidden);
+        M.util.js_complete("month-mini-{{uniqid}}-filterChanged");
+    });
+});
+{{/js}}
index ef8fa66..0bd835c 100644 (file)
@@ -84,9 +84,9 @@ class behat_calendar extends behat_base {
      * @param int $day The day of the current month
      */
     public function i_hover_over_day_of_this_month_in_calendar($day) {
-        $summarytitle = get_string('calendarheading', 'calendar', userdate(time(), get_string('strftimemonthyear')));
+        $summarytitle = userdate(time(), get_string('strftimemonthyear'));
         // The current month table.
-        $currentmonth = "table[contains(concat(' ', normalize-space(@summary), ' '), ' {$summarytitle} ')]";
+        $currentmonth = "table[descendant::*[self::caption[contains(concat(' ', normalize-space(.), ' '), ' {$summarytitle} ')]]]";
 
         // Strings for the class cell match.
         $cellclasses  = "contains(concat(' ', normalize-space(@class), ' '), ' day ')";
@@ -95,6 +95,7 @@ class behat_calendar extends behat_base {
         $dayofmonth   = "a[{$daycontains}]";
 
         $xpath = '//' . $currentmonth . '/descendant::' . $daycell . '/' . $dayofmonth;
+        $this->execute("behat_general::wait_until_the_page_is_ready");
         $this->execute("behat_general::i_hover", array($xpath, "xpath_element"));
 
     }
index 4537acf..ea3edfd 100644 (file)
@@ -1500,6 +1500,7 @@ class core_calendar_externallib_testcase extends externallib_advanced_testcase {
             'description' => [
                 'text' => '',
                 'format' => 1,
+                'itemid' => 0
             ],
             'duration' => 1,
             'timedurationuntil' => [
@@ -1623,6 +1624,7 @@ class core_calendar_externallib_testcase extends externallib_advanced_testcase {
             'description' => [
                 'text' => '',
                 'format' => 1,
+                'itemid' => 0
             ],
             'duration' => 1,
             'timedurationuntil' => [
@@ -1750,6 +1752,7 @@ class core_calendar_externallib_testcase extends externallib_advanced_testcase {
             'description' => [
                 'text' => '',
                 'format' => 1,
+                'itemid' => 0,
             ],
             'duration' => 1,
             'timedurationuntil' => [
@@ -1949,6 +1952,7 @@ class core_calendar_externallib_testcase extends externallib_advanced_testcase {
             'description' => [
                 'text' => '',
                 'format' => 1,
+                'itemid' => 0
             ],
             'duration' => 1,
             'timedurationuntil' => [
@@ -2020,6 +2024,7 @@ class core_calendar_externallib_testcase extends externallib_advanced_testcase {
             'description' => [
                 'text' => '',
                 'format' => 1,
+                'itemid' => 0
             ],
             'duration' => 1,
             'timedurationuntil' => [
@@ -2092,6 +2097,7 @@ class core_calendar_externallib_testcase extends externallib_advanced_testcase {
             'description' => [
                 'text' => '',
                 'format' => 1,
+                'itemid' => 0
             ],
             'duration' => 1,
             'timedurationuntil' => [
index ba0bc94..6447c8d 100644 (file)
@@ -161,5 +161,4 @@ echo html_writer::end_tag('div');
 list($data, $template) = calendar_get_footer_options($calendar);
 echo $renderer->render_from_template($template, $data);
 
-$PAGE->requires->js_call_amd('core_calendar/calendar', 'init');
 echo $OUTPUT->footer();
index d7e7b84..9a2a3c7 100644 (file)
@@ -206,7 +206,8 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str'
          */
         var findNextFocusable = function(mainElement) {
             var tabables = $("a:visible");
-            var isInside = false, foundElement = null;
+            var isInside = false;
+            var foundElement = null;
             tabables.each(function() {
                 if ($.contains(mainElement[0], this)) {
                     isInside = true;
index 833f7fb..8972162 100644 (file)
@@ -124,4 +124,41 @@ class mycourse extends \core_search\base {
     public function get_context_url(\core_search\document $doc) {
         return new \moodle_url('/course/view.php', array('id' => $doc->get('courseid')));
     }
+
+    /**
+     * Returns true if this area uses file indexing.
+     *
+     * @return bool
+     */
+    public function uses_file_indexing() {
+        return true;
+    }
+
+    /**
+     * Return the context info required to index files for
+     * this search area.
+     *
+     * Should be overridden by each search area.
+     *
+     * @return array
+     */
+    public function get_search_fileareas() {
+        $fileareas = array(
+                'overviewfiles',
+                'summary'// Fileareas.
+        );
+
+        return $fileareas;
+    }
+
+    /**
+     * Returns the moodle component name.
+     *
+     * It might be the plugin name (whole frankenstyle name) or the core subsystem name.
+     *
+     * @return string
+     */
+    public function get_component_name() {
+        return 'course';
+    }
 }
index 2ccc6f1..b38c641 100644 (file)
@@ -3894,16 +3894,14 @@ function course_get_user_navigation_options($context, $course = null) {
     // Frontpage settings?
     if ($isfrontpage) {
         // We are on the front page, so make sure we use the proper capability (site:viewparticipants).
-        $options->participants = has_capability('moodle/site:viewparticipants', $sitecontext) ||
-            has_capability('moodle/course:enrolreview', $sitecontext);
+        $options->participants = course_can_view_participants($sitecontext);
         $options->badges = !empty($CFG->enablebadges) && has_capability('moodle/badges:viewbadges', $sitecontext);
         $options->tags = !empty($CFG->usetags) && $isloggedin;
         $options->search = !empty($CFG->enableglobalsearch) && has_capability('moodle/search:query', $sitecontext);
         $options->calendar = $isloggedin;
     } else {
         // We are in a course, so make sure we use the proper capability (course:viewparticipants).
-        $options->participants = has_capability('moodle/course:viewparticipants', $context) ||
-            has_capability('moodle/course:enrolreview', $context);
+        $options->participants = course_can_view_participants($context);
         $options->badges = !empty($CFG->enablebadges) && !empty($CFG->badges_allowcoursebadges) &&
                             has_capability('moodle/badges:viewbadges', $context);
         // Add view grade report is permitted.
@@ -4237,3 +4235,34 @@ function course_check_module_updates_since($cm, $from, $fileareas = array(), $fi
 
     return $updates;
 }
+
+/**
+ * Returns true if the user can view the participant page, false otherwise,
+ *
+ * @param context $context The context we are checking.
+ * @return bool
+ */
+function course_can_view_participants($context) {
+    $viewparticipantscap = 'moodle/course:viewparticipants';
+    if ($context->contextlevel == CONTEXT_SYSTEM) {
+        $viewparticipantscap = 'moodle/site:viewparticipants';
+    }
+
+    return has_any_capability([$viewparticipantscap, 'moodle/course:enrolreview'], $context);
+}
+
+/**
+ * Checks if a user can view the participant page, if not throws an exception.
+ *
+ * @param context $context The context we are checking.
+ * @throws required_capability_exception
+ */
+function course_require_view_participants($context) {
+    if (!course_can_view_participants($context)) {
+        $viewparticipantscap = 'moodle/course:viewparticipants';
+        if ($context->contextlevel == CONTEXT_SYSTEM) {
+            $viewparticipantscap = 'moodle/site:viewparticipants';
+        }
+        throw new required_capability_exception($context, $viewparticipantscap, 'nopermissions', '');
+    }
+}
index cc2750a..d3056ea 100644 (file)
@@ -27,6 +27,7 @@ if (!defined('MOODLE_INTERNAL')) {
     die('Direct access to this script is forbidden.');    ///  It must be included from a Moodle page
 }
 
+require_once($CFG->dirroot . '/course/lib.php');
 require_once($CFG->libdir.'/formslib.php');
 
 class recent_form extends moodleform {
@@ -64,9 +65,9 @@ class recent_form extends moodleform {
         }
 
         if ($COURSE->id == SITEID) {
-            $viewparticipants = has_capability('moodle/site:viewparticipants', context_system::instance());
+            $viewparticipants = course_can_view_participants(context_system::instance());
         } else {
-            $viewparticipants = has_capability('moodle/course:viewparticipants', $context);
+            $viewparticipants = course_can_view_participants($context);
         }
 
         if ($viewparticipants) {
index 702771f..6d53ba5 100644 (file)
@@ -3904,4 +3904,174 @@ class core_course_courselib_testcase extends advanced_testcase {
         // Update the assign instances for this course.
         $this->assertTrue(course_module_bulk_update_calendar_events('assign', $course->id));
     }
+
+    /**
+     * Test that a student can view participants in a course they are enrolled in.
+     */
+    public function test_course_can_view_participants_as_student() {
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+        $coursecontext = context_course::instance($course->id);
+
+        $user = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($user->id, $course->id);
+
+        $this->setUser($user);
+
+        $this->assertTrue(course_can_view_participants($coursecontext));
+    }
+
+    /**
+     * Test that a student in a course can not view participants on the site.
+     */
+    public function test_course_can_view_participants_as_student_on_site() {
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+
+        $user = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($user->id, $course->id);
+
+        $this->setUser($user);
+
+        $this->assertFalse(course_can_view_participants(context_system::instance()));
+    }
+
+    /**
+     * Test that an admin can view participants on the site.
+     */
+    public function test_course_can_view_participants_as_admin_on_site() {
+        $this->resetAfterTest();
+
+        $this->setAdminUser();
+
+        $this->assertTrue(course_can_view_participants(context_system::instance()));
+    }
+
+    /**
+     * Test teachers can view participants in a course they are enrolled in.
+     */
+    public function test_course_can_view_participants_as_teacher() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+        $coursecontext = context_course::instance($course->id);
+
+        $user = $this->getDataGenerator()->create_user();
+        $roleid = $DB->get_field('role', 'id', array('shortname' => 'editingteacher'));
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $roleid);
+
+        $this->setUser($user);
+
+        $this->assertTrue(course_can_view_participants($coursecontext));
+    }
+
+    /**
+     * Check the teacher can still view the participants page without the 'viewparticipants' cap.
+     */
+    public function test_course_can_view_participants_as_teacher_without_view_participants_cap() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+        $coursecontext = context_course::instance($course->id);
+
+        $user = $this->getDataGenerator()->create_user();
+        $roleid = $DB->get_field('role', 'id', array('shortname' => 'editingteacher'));
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $roleid);
+
+        $this->setUser($user);
+
+        // Disable one of the capabilties.
+        assign_capability('moodle/course:viewparticipants', CAP_PROHIBIT, $roleid, $coursecontext);
+
+        // Should still be able to view the page as they have the 'moodle/course:enrolreview' cap.
+        $this->assertTrue(course_can_view_participants($coursecontext));
+    }
+
+    /**
+     * Check the teacher can still view the participants page without the 'moodle/course:enrolreview' cap.
+     */
+    public function test_course_can_view_participants_as_teacher_without_enrol_review_cap() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+        $coursecontext = context_course::instance($course->id);
+
+        $user = $this->getDataGenerator()->create_user();
+        $roleid = $DB->get_field('role', 'id', array('shortname' => 'editingteacher'));
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $roleid);
+
+        $this->setUser($user);
+
+        // Disable one of the capabilties.
+        assign_capability('moodle/course:enrolreview', CAP_PROHIBIT, $roleid, $coursecontext);
+
+        // Should still be able to view the page as they have the 'moodle/course:viewparticipants' cap.
+        $this->assertTrue(course_can_view_participants($coursecontext));
+    }
+
+    /**
+     * Check the teacher can not view the participants page without the required caps.
+     */
+    public function test_course_can_view_participants_as_teacher_without_required_caps() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+        $coursecontext = context_course::instance($course->id);
+
+        $user = $this->getDataGenerator()->create_user();
+        $roleid = $DB->get_field('role', 'id', array('shortname' => 'editingteacher'));
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $roleid);
+
+        $this->setUser($user);
+
+        // Disable the capabilities.
+        assign_capability('moodle/course:viewparticipants', CAP_PROHIBIT, $roleid, $coursecontext);
+        assign_capability('moodle/course:enrolreview', CAP_PROHIBIT, $roleid, $coursecontext);
+
+        $this->assertFalse(course_can_view_participants($coursecontext));
+    }
+
+    /**
+     * Check that an exception is not thrown if we can view the participants page.
+     */
+    public function test_course_require_view_participants() {
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+        $coursecontext = context_course::instance($course->id);
+
+        $user = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($user->id, $course->id);
+
+        $this->setUser($user);
+
+        course_require_view_participants($coursecontext);
+    }
+
+    /**
+     * Check that an exception is thrown if we can't view the participants page.
+     */
+    public function test_course_require_view_participants_as_student_on_site() {
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+
+        $user = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($user->id, $course->id);
+
+        $this->setUser($user);
+
+        $this->expectException('required_capability_exception');
+        course_require_view_participants(context_system::instance());
+    }
 }
index 370141a..d11bfab 100644 (file)
@@ -86,6 +86,8 @@ class core_enrol_external extends external_api {
      */
     public static function get_enrolled_users_with_capability($coursecapabilities, $options) {
         global $CFG, $DB;
+
+        require_once($CFG->dirroot . '/course/lib.php');
         require_once($CFG->dirroot . "/user/lib.php");
 
         if (empty($coursecapabilities)) {
@@ -145,11 +147,8 @@ class core_enrol_external extends external_api {
                 throw new moodle_exception(get_string('errorcoursecontextnotvalid' , 'webservice', $exceptionparam));
             }
 
-            if ($courseid == SITEID) {
-                require_capability('moodle/site:viewparticipants', $context);
-            } else {
-                require_capability('moodle/course:viewparticipants', $context);
-            }
+            course_require_view_participants($context);
+
             // The accessallgroups capability is needed to use this option.
             if (!empty($groupid) && groups_is_member($groupid)) {
                 require_capability('moodle/site:accessallgroups', $coursecontext);
@@ -293,7 +292,9 @@ class core_enrol_external extends external_api {
      * @return array of courses
      */
     public static function get_users_courses($userid) {
-        global $USER, $DB;
+        global $CFG, $USER, $DB;
+
+        require_once($CFG->dirroot . '/course/lib.php');
 
         // Do basic automatic PARAM checks on incoming data, using params description
         // If any problems are found then exceptions are thrown with helpful error messages
@@ -312,7 +313,7 @@ class core_enrol_external extends external_api {
                 continue;
             }
 
-            if ($userid != $USER->id and !has_capability('moodle/course:viewparticipants', $context)) {
+            if ($userid != $USER->id and !course_can_view_participants($context)) {
                 // we need capability to view participants
                 continue;
             }
@@ -520,6 +521,8 @@ class core_enrol_external extends external_api {
      */
     public static function get_enrolled_users($courseid, $options = array()) {
         global $CFG, $USER, $DB;
+
+        require_once($CFG->dirroot . '/course/lib.php');
         require_once($CFG->dirroot . "/user/lib.php");
 
         $params = self::validate_parameters(
@@ -600,11 +603,8 @@ class core_enrol_external extends external_api {
             throw new moodle_exception('errorcoursecontextnotvalid' , 'webservice', '', $exceptionparam);
         }
 
-        if ($courseid == SITEID) {
-            require_capability('moodle/site:viewparticipants', $context);
-        } else {
-            require_capability('moodle/course:viewparticipants', $context);
-        }
+        course_require_view_participants($context);
+
         // to overwrite this parameter, you need role:review capability
         if ($withcapability) {
             require_capability('moodle/role:review', $coursecontext);
index 6e8789e..9eb6d21 100644 (file)
@@ -30,8 +30,6 @@ $string['activitynext'] = 'Next activity';
 $string['activityprev'] = 'Previous activity';
 $string['breadcrumb'] = 'Breadcrumb trail';
 $string['hideblocka'] = 'Hide {$a} block';
-$string['monthnext'] = 'Next month';
-$string['monthprev'] = 'Previous month';
 $string['showblocka'] = 'Show {$a} block';
 $string['sitemap'] = 'Site map';
 $string['skipa'] = 'Skip {$a}';
@@ -41,3 +39,7 @@ $string['skipto'] = 'Skip to {$a}';
 $string['tocontent'] = 'Skip to main content';
 $string['tonavigation'] = 'Go to navigation';
 $string['youarehere'] = 'You are here';
+
+// Deprecated since Moodle 3.4.
+$string['monthnext'] = 'Next month';
+$string['monthprev'] = 'Previous month';
index b127fcb..17d12c0 100644 (file)
@@ -53,6 +53,8 @@ $string['errorunexistingtimesplitting'] = 'The selected time-splitting method is
 $string['errorunexistingmodel'] = 'Non-existing model {$a}';
 $string['errorunknownaction'] = 'Unknown action';
 $string['eventpredictionactionstarted'] = 'Prediction process started';
+$string['eventinsightsviewed'] = 'Insights viewed';
+$string['fixedack'] = 'Acknowledged / fixed';
 $string['insightmessagesubject'] = 'New insight for "{$a->contextname}": {$a->insightname}';
 $string['insightinfomessage'] = 'The system generated some insights for you: {$a}';
 $string['insightinfomessagehtml'] = 'The system generated some insights for you: <a href="{$a}">{$a}</a>.';
@@ -71,6 +73,7 @@ $string['nonewtimeranges'] = 'No new time ranges; nothing to predict.';
 $string['nopredictionsyet'] = 'No predictions available yet';
 $string['noranges'] = 'No predictions yet';
 $string['notrainingbasedassumptions'] = 'Models based on assumptions do not need training';
+$string['notuseful'] = 'Not useful';
 $string['novaliddata'] = 'No valid data available';
 $string['novalidsamples'] = 'No valid samples available';
 $string['onlycli'] = 'Analytics processes execution via command line only';
index 405046d..30a936c 100644 (file)
@@ -90,6 +90,7 @@ $string['eventdate'] = 'Date';
 $string['eventdescription'] = 'Description';
 $string['eventduration'] = 'Duration';
 $string['eventendtime'] = 'End time';
+$string['eventendtimewrapped'] = '{$a} (End time)';
 $string['eventinstanttime'] = 'Time';
 $string['eventkind'] = 'Type of event';
 $string['eventname'] = 'Event title';
@@ -135,10 +136,14 @@ $string['gotocalendar'] = 'Go to calendar';
 $string['group'] = 'Group';
 $string['groupevent'] = 'Group event';
 $string['groupevents'] = 'Group events';
-$string['hidecourseevents'] = 'Hide course events';
-$string['hideglobalevents'] = 'Hide global events';
-$string['hidegroupsevents'] = 'Hide group events';
-$string['hideuserevents'] = 'Hide user events';
+$string['eventtypeglobal'] = 'global';
+$string['eventtypesite'] = 'global';
+$string['eventtypecourse'] = 'course';
+$string['eventtypemodule'] = 'module';
+$string['eventtypegroup'] = 'group';
+$string['eventtypeuser'] = 'user';
+$string['hideeventtype'] = 'Hide {$a} events';
+$string['showeventtype'] = 'Show {$a} events';
 $string['hourly'] = 'Hourly';
 $string['ical'] = 'iCal';
 $string['importcalendar'] = 'Import calendar';
@@ -159,9 +164,10 @@ $string['monday'] = 'Monday';
 $string['monthly'] = 'Monthly';
 $string['monthlyview'] = 'Monthly view';
 $string['monthnext'] = 'Next month';
+$string['monthprev'] = 'Previous month';
 $string['monththis'] = 'This month';
 $string['more'] = 'More';
-$string['namewithsource'] = '{$a->name}({$a->source})';
+$string['namewithsource'] = '{$a->name} ({$a->source})';
 $string['never'] = 'Never';
 $string['newevent'] = 'New event';
 $string['notitle'] = 'no title';
@@ -194,10 +200,6 @@ $string['repeatweeksl'] = 'Repeat weekly, creating altogether';
 $string['repeatweeksr'] = 'events';
 $string['sat'] = 'Sat';
 $string['saturday'] = 'Saturday';
-$string['showcourseevents'] = 'Show course events';
-$string['showglobalevents'] = 'Show global events';
-$string['showgroupsevents'] = 'Show group events';
-$string['showuserevents'] = 'Show user events';
 $string['shown'] = 'shown';
 $string['siteevents'] = 'Site events';
 $string['spanningevents'] = 'Events underway';
@@ -214,17 +216,10 @@ $string['timeformat_12'] = '12-hour (am/pm)';
 $string['timeformat_24'] = '24-hour';
 $string['timeperiod'] = 'Time period';
 $string['today'] = 'Today';
+$string['todayplustitle'] = 'Today {$a}';
 $string['tomorrow'] = 'Tomorrow';
 $string['tt_deleteevent'] = 'Delete event';
 $string['tt_editevent'] = 'Edit event';
-$string['tt_hidecourse'] = 'Course events are shown (click to hide)';
-$string['tt_hideglobal'] = 'Global events are shown (click to hide)';
-$string['tt_hidegroups'] = 'Group events are shown (click to hide)';
-$string['tt_hideuser'] = 'User events are shown (click to hide)';
-$string['tt_showcourse'] = 'Course events are hidden (click to show)';
-$string['tt_showglobal'] = 'Global events are hidden (click to show)';
-$string['tt_showgroups'] = 'Group events are hidden (click to show)';
-$string['tt_showuser'] = 'User events are hidden (click to show)';
 $string['tue'] = 'Tue';
 $string['tuesday'] = 'Tuesday';
 $string['typeclose'] = 'Close event';
@@ -252,3 +247,21 @@ $string['youcandeleteallrepeats'] = 'This event is part of a repeating event ser
 
 // Deprecated since Moodle 3.2.
 $string['for'] = 'for';
+
+// Deprecated since Moodle 3.4.
+$string['tt_hidecourse'] = 'Course events are shown (click to hide)';
+$string['tt_hideglobal'] = 'Global events are shown (click to hide)';
+$string['tt_hidegroups'] = 'Group events are shown (click to hide)';
+$string['tt_hideuser'] = 'User events are shown (click to hide)';
+$string['tt_showcourse'] = 'Course events are hidden (click to show)';
+$string['tt_showglobal'] = 'Global events are hidden (click to show)';
+$string['tt_showgroups'] = 'Group events are hidden (click to show)';
+$string['tt_showuser'] = 'User events are hidden (click to show)';
+$string['hidecourseevents'] = 'Hide course events';
+$string['hideglobalevents'] = 'Hide global events';
+$string['hidegroupsevents'] = 'Hide group events';
+$string['hideuserevents'] = 'Hide user events';
+$string['showcourseevents'] = 'Show course events';
+$string['showglobalevents'] = 'Show global events';
+$string['showgroupsevents'] = 'Show group events';
+$string['showuserevents'] = 'Show user events';
index 55a0e70..cd7bb58 100644 (file)
@@ -61,3 +61,21 @@ error:backpackloginfailed,core_badges
 signinwithyouremail,core_badges
 sectionusedefaultname,core
 registermoochtips,core_hub
+monthnext,core_access
+monthprev,core_access
+tt_hidecourse,core_calendar
+tt_hideglobal,core_calendar
+tt_hidegroups,core_calendar
+tt_hideuser,core_calendar
+tt_showcourse,core_calendar
+tt_showglobal,core_calendar
+tt_showgroups,core_calendar
+tt_showuser,core_calendar
+hidecourseevents,core_calendar
+hideglobalevents,core_calendar
+hidegroupsevents,core_calendar
+hideuserevents,core_calendar
+showcourseevents,core_calendar
+showglobalevents,core_calendar
+showgroupsevents,core_calendar
+showuserevents,core_calendar
index addc4f7..6844d5f 100644 (file)
@@ -620,6 +620,8 @@ $string['emaildisplayyes'] = 'Allow everyone to see my email address';
 $string['emailenable'] = 'This email address is enabled';
 $string['emailenableclick'] = 'Click here to re-enable all email being sent to this address';
 $string['emailexists'] = 'This email address is already registered.';
+$string['emailexistssignuphint'] = 'Perhaps you created an account in the past? {$a}';
+$string['emailexistshintlink'] = 'Recover forgotten username or password';
 $string['emailformat'] = 'Email format';
 $string['emailcharset'] = 'Email charset';
 $string['emailmustbereal'] = 'Note: your email address must be a real one';
index ce69373..3b75249 100644 (file)
Binary files a/lib/amd/build/form-autocomplete.min.js and b/lib/amd/build/form-autocomplete.min.js differ
index 60c678f..76294eb 100644 (file)
@@ -37,6 +37,8 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
         UP: 38
     };
 
+    var uniqueId = $.now();
+
     /**
      * Make an item in the selection list "active".
      *
@@ -786,11 +788,15 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
             // Find or generate some ids.
             var state = {
                 selectId: originalSelect.attr('id'),
-                inputId: 'form_autocomplete_input-' + $.now(),
-                suggestionsId: 'form_autocomplete_suggestions-' + $.now(),
-                selectionId: 'form_autocomplete_selection-' + $.now(),
-                downArrowId: 'form_autocomplete_downarrow-' + $.now()
+                inputId: 'form_autocomplete_input-' + uniqueId,
+                suggestionsId: 'form_autocomplete_suggestions-' + uniqueId,
+                selectionId: 'form_autocomplete_selection-' + uniqueId,
+                downArrowId: 'form_autocomplete_downarrow-' + uniqueId
             };
+
+            // Increment the unique counter so we don't get duplicates ever.
+            uniqueId++;
+
             options.multiple = originalSelect.attr('multiple');
 
             if (typeof closeSuggestionsOnSelect !== "undefined") {
index 8dae596..27adb6a 100644 (file)
@@ -438,7 +438,7 @@ define(['core/mustache',
                 treated += content.substring(0, index);
                 content = content.substr(index);
                 strIndex = '';
-                walker = 4;  // 4 is the length of '[[_s'.
+                walker = 4; // 4 is the length of '[[_s'.
 
                 // Walk the characters to manually extract the index of the string from the placeholder.
                 char = content.substr(walker, 1);
@@ -455,7 +455,7 @@ define(['core/mustache',
                     strFinal = '';
                 }
                 treated += strFinal;
-                content = content.substr(6 + strIndex.length);  // 6 is the length of the placeholder without the index: '[[_s]]'.
+                content = content.substr(6 + strIndex.length); // 6 is the length of the placeholder without the index: '[[_s]]'.
 
                 // Find the next placeholder.
                 index = content.search(pattern);
index 4af4bcb..ae72619 100644 (file)
@@ -887,7 +887,9 @@ function signup_validate_data($data, $files) {
         $errors['email'] = get_string('invalidemail');
 
     } else if ($DB->record_exists('user', array('email' => $data['email']))) {
-        $errors['email'] = get_string('emailexists').' <a href="forgot_password.php">'.get_string('newpassword').'?</a>';
+        $errors['email'] = get_string('emailexists') . ' ' .
+                get_string('emailexistssignuphint', 'moodle',
+                        html_writer::link(new moodle_url('/login/forgot_password.php'), get_string('emailexistshintlink')));
     }
     if (empty($data['email2'])) {
         $errors['email2'] = get_string('missingemail');
index b773632..baac51d 100644 (file)
@@ -61,25 +61,27 @@ class course_dropout extends \core_analytics\local\target\binary {
     public function prediction_actions(\core_analytics\prediction $prediction, $includedetailsaction = false) {
         global $USER;
 
-        $actions = parent::prediction_actions($prediction, $includedetailsaction);
+        $actions = array();
 
         $sampledata = $prediction->get_sample_data();
         $studentid = $sampledata['user']->id;
 
+        $attrs = array('target' => '_blank');
+
         // Send a message.
         $url = new \moodle_url('/message/index.php', array('user' => $USER->id, 'id' => $studentid));
         $pix = new \pix_icon('t/message', get_string('sendmessage', 'message'));
-        $actions['studentmessage'] = new \core_analytics\prediction_action('studentmessage', $prediction, $url, $pix,
-            get_string('sendmessage', 'message'));
+        $actions[] = new \core_analytics\prediction_action('studentmessage', $prediction, $url, $pix,
+            get_string('sendmessage', 'message'), $attrs);
 
         // View outline report.
         $url = new \moodle_url('/report/outline/user.php', array('id' => $studentid, 'course' => $sampledata['course']->id,
             'mode' => 'outline'));
         $pix = new \pix_icon('i/report', get_string('outlinereport'));
-        $actions['viewoutlinereport'] = new \core_analytics\prediction_action('viewoutlinereport', $prediction, $url, $pix,
-            get_string('outlinereport'));
+        $actions[] = new \core_analytics\prediction_action('viewoutlinereport', $prediction, $url, $pix,
+            get_string('outlinereport'), $attrs);
 
-        return $actions;
+        return array_merge($actions, parent::prediction_actions($prediction, $includedetailsaction));
     }
 
     /**
index 7d3714f..739670e 100644 (file)
@@ -63,26 +63,32 @@ class no_teaching extends \core_analytics\local\target\binary {
      * @return \core_analytics\prediction_action[]
      */
     public function prediction_actions(\core_analytics\prediction $prediction, $includedetailsaction = false) {
+        global $CFG;
 
-        // No need to call the parent as the parent's action is view details and this target only have 1 feature.
-        $actions = array();
+        require_once($CFG->dirroot . '/course/lib.php');
 
         $sampledata = $prediction->get_sample_data();
         $course = $sampledata['course'];
 
+        $actions = array();
+
         $url = new \moodle_url('/course/view.php', array('id' => $course->id));
         $pix = new \pix_icon('i/course', get_string('course'));
-        $actions['viewcourse'] = new \core_analytics\prediction_action('viewcourse', $prediction,
+        $actions[] = new \core_analytics\prediction_action('viewcourse', $prediction,
             $url, $pix, get_string('view'));
 
-        if (has_any_capability(['moodle/course:viewparticipants', 'moodle/course:enrolreview'], $sampledata['context'])) {
+        if (course_can_view_participants($sampledata['context'])) {
             $url = new \moodle_url('/user/index.php', array('id' => $course->id));
             $pix = new \pix_icon('i/cohort', get_string('participants'));
-            $actions['viewparticipants'] = new \core_analytics\prediction_action('viewparticipants', $prediction,
+            $actions[] = new \core_analytics\prediction_action('viewparticipants', $prediction,
                 $url, $pix, get_string('participants'));
         }
 
-        return $actions;
+        $parentactions = parent::prediction_actions($prediction, $includedetailsaction);
+        // No need to show details as there is only 1 indicator.
+        unset($parentactions[\core_analytics\prediction::ACTION_PREDICTION_DETAILS]);
+
+        return array_merge($actions, $parentactions);
     }
 
     /**
diff --git a/lib/classes/event/insights_viewed.php b/lib/classes/event/insights_viewed.php
new file mode 100644 (file)
index 0000000..357474e
--- /dev/null
@@ -0,0 +1,79 @@
+<?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/>.
+
+/**
+ * Insights page viewed event.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *
+ *      - string modelid: The model id
+ * }
+ *
+ * @package    core_analytics
+ * @copyright  2017 David Monllao {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Event triggered after a user views the insights page.
+ *
+ * @package    core_analytics
+ * @copyright  2017 David Monllao {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class insights_viewed extends \core\event\base {
+
+    /**
+     * Set basic properties for the event.
+     */
+    protected function init() {
+        $this->data['crud'] = 'r';
+        // It depends on the insight really.
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventinsightsviewed', 'analytics');
+    }
+
+    /**
+     * Returns non-localised event description with id's for admin use only.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' has viewed model '{$this->other['modelid']}' insights in " .
+            "context with id '{$this->data['contextid']}'";
+    }
+
+    /**
+     * Returns relevant URL.
+     * @return \moodle_url
+     */
+    public function get_url() {
+        return new \moodle_url('/report/insights/insights.php', array('modelid' => $this->other['modelid'],
+            'contextid' => $this->data['contextid']));
+    }
+}
index 1e36687..880905b 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20170814" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20170904" COMMENT="XMLDB file for core Moodle tables"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
 >
         <INDEX NAME="starttime-endtime-contextid" UNIQUE="false" FIELDS="starttime, endtime, contextid"/>
       </INDEXES>
     </TABLE>
+    <TABLE NAME="analytics_prediction_actions" COMMENT="Register of user actions over predictions.">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="predictionid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="actionname" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="predictionid" TYPE="foreign" FIELDS="predictionid" REFTABLE="analytics_predictions" REFFIELDS="id"/>
+        <KEY NAME="userid" TYPE="foreign" FIELDS="userid" REFTABLE="user" REFFIELDS="id"/>
+      </KEYS>
+      <INDEXES>
+        <INDEX NAME="predictionidanduseridandactionname" UNIQUE="false" FIELDS="predictionid, userid, actionname"/>
+      </INDEXES>
+    </TABLE>
   </TABLES>
 </XMLDB>
index e34e605..df03600 100644 (file)
@@ -2436,7 +2436,46 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2017082800.00);
     }
 
-    if ($oldversion < 2017090600.00) {
+    if ($oldversion < 2017090700.01) {
+
+        // Define table analytics_prediction_actions to be created.
+        $table = new xmldb_table('analytics_prediction_actions');
+
+        // Adding fields to table analytics_prediction_actions.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('predictionid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('userid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('actionname', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+
+        // Adding keys to table analytics_prediction_actions.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
+        $table->add_key('predictionid', XMLDB_KEY_FOREIGN, array('predictionid'), 'analytics_predictions', array('id'));
+        $table->add_key('userid', XMLDB_KEY_FOREIGN, array('userid'), 'user', array('id'));
+
+        // Adding indexes to table analytics_prediction_actions.
+        $table->add_index('predictionidanduseridandactionname', XMLDB_INDEX_NOTUNIQUE,
+            array('predictionid', 'userid', 'actionname'));
+
+        // Conditionally launch create table for analytics_prediction_actions.
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2017090700.01);
+    }
+
+    if ($oldversion < 2017091200.00) {
+        // Force all messages to be reindexed.
+        set_config('core_message_message_sent_lastindexrun', '0', 'core_search');
+        set_config('core_message_message_received_lastindexrun', '0', 'core_search');
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2017091200.00);
+    }
+
+    if ($oldversion < 2017091201.00) {
         // Define field userid to be added to task_adhoc.
         $table = new xmldb_table('task_adhoc');
         $field = new xmldb_field('userid', XMLDB_TYPE_INTEGER, '10', null, null, null, null, 'customdata');
@@ -2452,7 +2491,7 @@ function xmldb_main_upgrade($oldversion) {
         $dbman->add_key($table, $key);
 
         // Main savepoint reached.
-        upgrade_main_savepoint(true, 2017090600.00);
+        upgrade_main_savepoint(true, 2017091201.00);
     }
 
     return true;
index 4434e02..603896a 100644 (file)
Binary files a/lib/editor/atto/plugins/media/yui/build/moodle-atto_media-button/moodle-atto_media-button-debug.js and b/lib/editor/atto/plugins/media/yui/build/moodle-atto_media-button/moodle-atto_media-button-debug.js differ
index 6e0e8c2..0049c93 100644 (file)
Binary files a/lib/editor/atto/plugins/media/yui/build/moodle-atto_media-button/moodle-atto_media-button-min.js and b/lib/editor/atto/plugins/media/yui/build/moodle-atto_media-button/moodle-atto_media-button-min.js differ
index 4434e02..603896a 100644 (file)
Binary files a/lib/editor/atto/plugins/media/yui/build/moodle-atto_media-button/moodle-atto_media-button.js and b/lib/editor/atto/plugins/media/yui/build/moodle-atto_media-button/moodle-atto_media-button.js differ
index e5446db..da1fa3f 100644 (file)
@@ -1,7 +1,8 @@
 {
     "moodle-atto_media-button": {
         "requires": [
-            "moodle-editor_atto-plugin"
+            "moodle-editor_atto-plugin",
+            "moodle-form-shortforms"
         ]
     }
 }
index 8f99d9d..11b68c8 100644 (file)
@@ -60,6 +60,9 @@ require_once("$CFG->libdir/filebrowser/file_info_context_module.php");
 */
 class file_browser {
 
+    /** @var array cached list of enrolled courses.  */
+    protected $enrolledcourses = null;
+
     /**
      * Looks up file_info instance
      *
@@ -190,7 +193,7 @@ class file_browser {
     /**
      * Returns info about the files at Course category context
      *
-     * @param stdClass $context context object
+     * @param context $context context object
      * @param string $component component
      * @param string $filearea file area
      * @param int $itemid item ID
@@ -199,39 +202,36 @@ class file_browser {
      * @return file_info|null file_info instance or null if not found or access not allowed
      */
     private function get_file_info_context_module($context, $component, $filearea, $itemid, $filepath, $filename) {
-        global $COURSE, $DB, $CFG;
-
-        static $cachedmodules = array();
-
-        if (!array_key_exists($context->instanceid, $cachedmodules)) {
-            $cachedmodules[$context->instanceid] = get_coursemodule_from_id('', $context->instanceid);
-        }
-
-        if (!($cm = $cachedmodules[$context->instanceid])) {
+        if (!($context instanceof context_module)) {
             return null;
         }
+        $coursecontext = $context->get_course_context();
+        $modinfo = get_fast_modinfo($coursecontext->instanceid);
+        $cm = $modinfo->get_cm($context->instanceid);
 
-        if ($cm->course == $COURSE->id) {
-            $course = $COURSE;
-        } else if (!$course = $DB->get_record('course', array('id'=>$cm->course))) {
+        if (empty($cm->uservisible)) {
             return null;
         }
 
-        $modinfo = get_fast_modinfo($course);
-        if (empty($modinfo->cms[$cm->id]->uservisible)) {
-            return null;
-        }
-
-        $modname = $modinfo->cms[$cm->id]->modname;
-
-        if (!file_exists("$CFG->dirroot/mod/$modname/lib.php")) {
-            return null;
-        }
-
-        // ok, we know that module exists, and user may access it
-
-        $level = new file_info_context_module($this, $context, $course, $cm, $modname);
+        $level = new file_info_context_module($this, $context, $cm->get_course(), $cm, $cm->modname);
         return $level->get_file_info($component, $filearea, $itemid, $filepath, $filename);
     }
 
+    /**
+     * Check if user is enrolled into the course
+     *
+     * This function keeps a cache of enrolled courses because it may be called multiple times for many courses in one request
+     *
+     * @param int $courseid
+     * @return bool
+     */
+    public function is_enrolled($courseid) {
+        if ($this->enrolledcourses === null || PHPUNIT_TEST) {
+            // Since get_file_browser() returns a statically cached object we can't rely on cache
+            // inside the file_browser class in the unittests.
+            // TODO MDL-59964 remove this caching when it's implemented inside enrol_get_my_courses().
+            $this->enrolledcourses = enrol_get_my_courses(['id']);
+        }
+        return array_key_exists($courseid, $this->enrolledcourses);
+    }
 }
index 67f842c..3e9ffa3 100644 (file)
@@ -36,6 +36,9 @@ class file_info_context_course extends file_info {
     /** @var stdClass course object */
     protected $course;
 
+    /** @var file_info_context_module[] cached child modules. See {@link get_child_module()} */
+    protected $childrenmodules = [];
+
     /**
      * Constructor
      *
@@ -68,7 +71,7 @@ class file_info_context_course extends file_info {
             return null;
         }
 
-        if (!is_viewing($this->context) and !is_enrolled($this->context)) {
+        if (!is_viewing($this->context) and !$this->browser->is_enrolled($this->course->id)) {
             // no peaking here if not enrolled or inspector
             return null;
         }
@@ -86,6 +89,41 @@ class file_info_context_course extends file_info {
         return null;
     }
 
+    /**
+     * Returns list of areas inside this course
+     *
+     * @param string $extensions Only return areas that have files with these extensions
+     * @param bool $returnemptyfolders return all areas always, if true it will ignore the previous argument
+     * @return array
+     */
+    protected function get_course_areas($extensions = '*', $returnemptyfolders = false) {
+        global $DB;
+
+        $allareas = [
+            'course_summary',
+            'course_overviewfiles',
+            'course_section',
+            'backup_section',
+            'backup_course',
+            'backup_automated',
+            'course_legacy'
+        ];
+
+        if ($returnemptyfolders) {
+            return $allareas;
+        }
+
+        $params1 = ['contextid' => $this->context->id, 'emptyfilename' => '.'];
+        $sql1 = "SELECT " . $DB->sql_concat('f.component', "'_'", 'f.filearea') . "
+            FROM {files} f
+            WHERE f.filename <> :emptyfilename AND f.contextid = :contextid ";
+        $sql3 = ' GROUP BY f.component, f.filearea';
+        list($sql2, $params2) = $this->build_search_files_sql($extensions);
+        $areaswithfiles = $DB->get_fieldset_sql($sql1 . $sql2 . $sql3, array_merge($params1, $params2));
+
+        return array_intersect($allareas, $areaswithfiles);
+    }
+
     /**
      * Gets a stored file for the course summary filearea directory
      *
@@ -387,6 +425,28 @@ class file_info_context_course extends file_info {
         return $this->get_filtered_children('*', false, true);
     }
 
+    /**
+     * Returns the child module if it is accessible by the current user
+     *
+     * @param cm_info|int $cm
+     * @return file_info_context_module|null
+     */
+    protected function get_child_module($cm) {
+        $cmid = is_object($cm) ? $cm->id : $cm;
+        if (!array_key_exists($cmid, $this->childrenmodules)) {
+            $this->childrenmodules[$cmid] = null;
+            if (!($cm instanceof cm_info)) {
+                $cms = get_fast_modinfo($this->course)->cms;
+                $cm = array_key_exists($cmid, $cms) ? $cms[$cmid] : null;
+            }
+            if ($cm && $cm->uservisible) {
+                $this->childrenmodules[$cmid] = new file_info_context_module($this->browser,
+                    $cm->context, $this->course, $cm, $cm->modname);
+            }
+        }
+        return $this->childrenmodules[$cmid];
+    }
+
     /**
      * Help function to return files matching extensions or their count
      *
@@ -397,46 +457,52 @@ class file_info_context_course extends file_info {
      * @return array|int array of file_info instances or the count
      */
     private function get_filtered_children($extensions = '*', $countonly = false, $returnemptyfolders = false) {
-        $areas = array(
-            array('course', 'summary'),
-            array('course', 'overviewfiles'),
-            array('course', 'section'),
-            array('backup', 'section'),
-            array('backup', 'course'),
-            array('backup', 'automated'),
-            array('course', 'legacy')
-        );
         $children = array();
-        foreach ($areas as $area) {
+
+        $courseareas = $this->get_course_areas($extensions, $returnemptyfolders);
+        foreach ($courseareas as $areaname) {
+            $area = explode('_', $areaname, 2);
             if ($child = $this->get_file_info($area[0], $area[1], 0, '/', '.')) {
-                if ($returnemptyfolders || $child->count_non_empty_children($extensions)) {
-                    $children[] = $child;
-                    if (($countonly !== false) && count($children) >= $countonly) {
-                        return $countonly;
-                    }
+                $children[] = $child;
+                if (($countonly !== false) && count($children) >= $countonly) {
+                    return $countonly;
                 }
             }
         }
 
+        $cnt = count($children);
         if (!has_capability('moodle/course:managefiles', $this->context)) {
             // 'managefiles' capability is checked in every activity module callback.
             // Don't even waste time on retrieving the modules if we can't browse the files anyway
         } else {
-            // now list all modules
-            $modinfo = get_fast_modinfo($this->course);
-            foreach ($modinfo->cms as $cminfo) {
-                if (empty($cminfo->uservisible)) {
-                    continue;
-                }
-                $modcontext = context_module::instance($cminfo->id, IGNORE_MISSING);
-                if ($child = $this->browser->get_file_info($modcontext)) {
-                    if ($returnemptyfolders || $child->count_non_empty_children($extensions)) {
+            if ($returnemptyfolders) {
+                $modinfo = get_fast_modinfo($this->course);
+                foreach ($modinfo->cms as $cminfo) {
+                    if ($child = $this->get_child_module($cminfo)) {
                         $children[] = $child;
-                        if (($countonly !== false) && count($children) >= $countonly) {
-                            return $countonly;
+                        $cnt++;
+                    }
+                }
+            } else if ($moduleareas = $this->get_module_areas_with_files($extensions)) {
+                // We found files in some of the modules.
+                // Create array of children modules ordered with the same way as cms in modinfo.
+                $modulechildren = array_fill_keys(array_keys(get_fast_modinfo($this->course)->get_cms()), null);
+                foreach ($moduleareas as $area) {
+                    if ($modulechildren[$area->cmid]) {
+                        // We already found non-empty area within the same module, do not analyse other areas.
+                        continue;
+                    }
+                    if ($child = $this->get_child_module($area->cmid)) {
+                        if ($child->get_file_info($area->component, $area->filearea, $area->itemid, null, null)) {
+                            $modulechildren[$area->cmid] = $child;
+                            $cnt++;
+                            if (($countonly !== false) && $cnt >= $countonly) {
+                                return $cnt;
+                            }
                         }
                     }
                 }
+                $children = array_merge($children, array_values(array_filter($modulechildren)));
             }
         }
 
@@ -446,6 +512,52 @@ class file_info_context_course extends file_info {
         return $children;
     }
 
+    /**
+     * Returns list of areas inside the course modules that have files with the given extension
+     *
+     * @param string $extensions
+     * @return array
+     */
+    protected function get_module_areas_with_files($extensions = '*') {
+        global $DB;
+
+        $params1 = ['contextid' => $this->context->id,
+            'emptyfilename' => '.',
+            'contextlevel' => CONTEXT_MODULE,
+            'depth' => $this->context->depth + 1,
+            'pathmask' => $this->context->path . '/%'];
+        $sql1 = "SELECT ctx.id AS contextid, f.component, f.filearea, f.itemid, ctx.instanceid AS cmid, " .
+                context_helper::get_preload_record_columns_sql('ctx') . "
+            FROM {files} f
+            INNER JOIN {context} ctx ON ctx.id = f.contextid
+            WHERE f.filename <> :emptyfilename
+              AND ctx.contextlevel = :contextlevel
+              AND ctx.depth = :depth
+              AND " . $DB->sql_like('ctx.path', ':pathmask') . " ";
+        $sql3 = ' GROUP BY ctx.id, f.component, f.filearea, f.itemid, ctx.instanceid,
+              ctx.path, ctx.depth, ctx.contextlevel
+            ORDER BY ctx.id, f.component, f.filearea, f.itemid';
+        list($sql2, $params2) = $this->build_search_files_sql($extensions);
+        $areas = [];
+        if ($rs = $DB->get_recordset_sql($sql1. $sql2 . $sql3, array_merge($params1, $params2))) {
+            foreach ($rs as $record) {
+                context_helper::preload_from_record($record);
+                $areas[] = $record;
+            }
+            $rs->close();
+        }
+
+        // Sort areas so 'backup' and 'intro' are in the beginning of the list, they are the easiest to check access to.
+        usort($areas, function($a, $b) {
+            $aeasy = ($a->filearea === 'intro' && substr($a->component, 0, 4) === 'mod_') ||
+                ($a->filearea === 'activity' && $a->component === 'backup');
+            $beasy = ($b->filearea === 'intro' && substr($b->component, 0, 4) === 'mod_') ||
+                ($b->filearea === 'activity' && $b->component === 'backup');
+            return $aeasy == $beasy ? 0 : ($aeasy ? -1 : 1);
+        });
+        return $areas;
+    }
+
     /**
      * Returns list of children which are either files matching the specified extensions
      * or folders that contain at least one such file.
index 7c354bd..d0b4746 100644 (file)
@@ -97,6 +97,10 @@ class file_info_context_coursecat extends file_info {
     protected function get_area_coursecat_description($itemid, $filepath, $filename) {
         global $CFG;
 
+        if (!$this->category->id) {
+            // No coursecat description area for "system".
+            return null;
+        }
         if (!$this->category->visible and !has_capability('moodle/category:viewhiddencategories', $this->context)) {
             return null;
         }
@@ -158,37 +162,92 @@ class file_info_context_coursecat extends file_info {
      * @return array of file_info instances
      */
     public function get_children() {
-        global $DB;
-
         $children = array();
 
         if ($child = $this->get_area_coursecat_description(0, '/', '.')) {
             $children[] = $child;
         }
 
-        $course_cats = $DB->get_records('course_categories', array('parent'=>$this->category->id), 'sortorder', 'id,visible');
-        foreach ($course_cats as $category) {
+        list($coursecats, $hiddencats) = $this->get_categories();
+        foreach ($coursecats as $category) {
             $context = context_coursecat::instance($category->id);
-            if (!$category->visible and !has_capability('moodle/category:viewhiddencategories', $context)) {
-                continue;
-            }
-            if ($child = $this->browser->get_file_info($context)) {
-                $children[] = $child;
-            }
+            $children[] = new self($this->browser, $context, $category);
         }
 
-        $courses = $DB->get_records('course', array('category'=>$this->category->id), 'sortorder', 'id,visible');
+        $courses = $this->get_courses($hiddencats);
         foreach ($courses as $course) {
-            $context = context_course::instance($course->id);
-            if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $context)) {
-                continue;
-            }
-            if ($child = $this->browser->get_file_info($context)) {
-                $children[] = $child;
+            $children[] = $this->get_child_course($course);
+        }
+
+        return array_filter($children);
+    }
+
+    /**
+     * List of courses in this category and in hidden subcategories
+     *
+     * @param array $hiddencats list of categories that are hidden from current user and returned by {@link get_categories()}
+     * @return array list of courses
+     */
+    protected function get_courses($hiddencats) {
+        global $DB, $CFG;
+        require_once($CFG->libdir.'/modinfolib.php');
+
+        $params = array('category' => $this->category->id, 'contextlevel' => CONTEXT_COURSE);
+        $sql = 'c.category = :category';
+
+        foreach ($hiddencats as $category) {
+            $catcontext = context_coursecat::instance($category->id);
+            $sql .= ' OR ' . $DB->sql_like('ctx.path', ':path' . $category->id);
+            $params['path' . $category->id] = $catcontext->path . '/%';
+        }
+
+        // Let's retrieve only minimum number of fields from course table -
+        // what is needed to check access or call get_fast_modinfo().
+        $coursefields = array_merge(['id', 'visible'], course_modinfo::$cachedfields);
+        $fields = 'c.' . join(',c.', $coursefields) . ', ' .
+            context_helper::get_preload_record_columns_sql('ctx');
+        return $DB->get_records_sql('SELECT ' . $fields . ' FROM {course} c
+                JOIN {context} ctx ON (ctx.instanceid = c.id AND ctx.contextlevel = :contextlevel)
+                WHERE ('.$sql.') ORDER BY c.sortorder', $params);
+    }
+
+    /**
+     * Finds accessible and non-accessible direct subcategories
+     *
+     * @return array [$coursecats, $hiddencats] - child categories that are visible to the current user and not visible
+     */
+    protected function get_categories() {
+        global $DB;
+        $fields = 'c.*, ' . context_helper::get_preload_record_columns_sql('ctx');
+        $coursecats = $DB->get_records_sql('SELECT ' . $fields . ' FROM {course_categories} c
+                LEFT JOIN {context} ctx ON (ctx.instanceid = c.id AND ctx.contextlevel = :contextlevel)
+                WHERE c.parent = :parent ORDER BY c.sortorder',
+            array('parent' => $this->category->id, 'contextlevel' => CONTEXT_COURSECAT));
+
+        $hiddencats = [];
+
+        foreach ($coursecats as $id => &$category) {
+            context_helper::preload_from_record($category);
+            $context = context_coursecat::instance($category->id);
+            if (!$category->visible && !has_capability('moodle/category:viewhiddencategories', $context)) {
+                $hiddencats[$id] = $coursecats[$id];
+                unset($coursecats[$id]);
             }
         }
+        return [$coursecats, $hiddencats];
+    }
 
-        return $children;
+    /**
+     * Returns the file info element for a given course or null if course is not accessible
+     *
+     * @param stdClass $course may contain context fields for preloading
+     * @return file_info_context_course|null
+     */
+    protected function get_child_course($course) {
+        context_helper::preload_from_record($course);
+        $context = context_course::instance($course->id);
+        $child = new file_info_context_course($this->browser, $context, $course);
+        return $child->get_file_info(null, null, null, null, null);
     }
 
     /**
@@ -200,53 +259,33 @@ class file_info_context_coursecat extends file_info {
      * @return int
      */
     public function count_non_empty_children($extensions = '*', $limit = 1) {
-        global $DB;
         $cnt = 0;
-        if (($child = $this->get_area_coursecat_description(0, '/', '.'))
-                && $child->count_non_empty_children($extensions) && (++$cnt) >= $limit) {
-            return $cnt;
+        if ($child = $this->get_area_coursecat_description(0, '/', '.')) {
+            $cnt += $child->count_non_empty_children($extensions) ? 1 : 0;
+            if ($cnt >= $limit) {
+                return $cnt;
+            }
         }
 
-        $rs = $DB->get_recordset_sql('SELECT ctx.id AS contextid, c.visible
-                FROM {context} ctx, {course} c
-                WHERE ctx.instanceid = c.id
-                AND ctx.contextlevel = :courselevel
-                AND c.category = :categoryid
-                ORDER BY c.visible DESC', // retrieve visible courses first
-                array('categoryid' => $this->category->id, 'courselevel' => CONTEXT_COURSE));
-        foreach ($rs as $record) {
-            $context = context::instance_by_id($record->contextid);
-            if (!$record->visible and !has_capability('moodle/course:viewhiddencourses', $context)) {
-                continue;
-            }
-            if (($child = $this->browser->get_file_info($context))
-                    && $child->count_non_empty_children($extensions) && (++$cnt) >= $limit) {
-                break;
+        list($coursecats, $hiddencats) = $this->get_categories();
+        foreach ($coursecats as $category) {
+            $context = context_coursecat::instance($category->id);
+            $child = new file_info_context_coursecat($this->browser, $context, $category);
+            $cnt += $child->count_non_empty_children($extensions) ? 1 : 0;
+            if ($cnt >= $limit) {
+                return $cnt;
             }
         }
-        $rs->close();
-        if ($cnt >= $limit) {
-            return $cnt;
-        }
 
-        $rs = $DB->get_recordset_sql('SELECT ctx.id AS contextid, cat.visible
-                FROM {context} ctx, {course_categories} cat
-                WHERE ctx.instanceid = cat.id
-                AND ctx.contextlevel = :catlevel
-                AND cat.parent = :categoryid
-                ORDER BY cat.visible DESC', // retrieve visible categories first
-                array('categoryid' => $this->category->id, 'catlevel' => CONTEXT_COURSECAT));
-        foreach ($rs as $record) {
-            $context = context::instance_by_id($record->contextid);
-            if (!$record->visible and !has_capability('moodle/category:viewhiddencategories', $context)) {
-                continue;
-            }
-            if (($child = $this->browser->get_file_info($context))
-                    && $child->count_non_empty_children($extensions) && (++$cnt) >= $limit) {
-                break;
+        $courses = $this->get_courses($hiddencats);
+        foreach ($courses as $course) {
+            if ($child = $this->get_child_course($course)) {
+                $cnt += $child->count_non_empty_children($extensions) ? 1 : 0;
+                if ($cnt >= $limit) {
+                    return $cnt;
+                }
             }
         }
-        $rs->close();
 
         return $cnt;
     }
index c62f83a..c2c068f 100644 (file)
@@ -35,7 +35,7 @@ defined('MOODLE_INTERNAL') || die();
 class file_info_context_module extends file_info {
     /** @var stdClass Course object */
     protected $course;
-    /** @var stdClass Course module object */
+    /** @var cm_info Course module object */
     protected $cm;
     /** @var string Module name */
     protected $modname;
@@ -58,23 +58,17 @@ class file_info_context_module extends file_info {
 
         parent::__construct($browser, $context);
         $this->course  = $course;
-        $this->cm      = $cm;
-        $this->modname = $modname;
+        $this->cm      = cm_info::create($cm);
+        $this->modname = $this->cm->modname;
         $this->nonemptychildren = null;
 
-        include_once("$CFG->dirroot/mod/$modname/lib.php");
-
-        //find out all supported areas
-        $functionname     = 'mod_'.$modname.'_get_file_areas';
-        $functionname_old = $modname.'_get_file_areas';
-
-        if (function_exists($functionname)) {
+        if ($functionname = component_callback_exists('mod_'.$modname, 'get_file_areas')) {
+            $cm = $this->cm->get_course_module_record();
             $this->areas = $functionname($course, $cm, $context);
-        } else if (function_exists($functionname_old)) {
-            $this->areas = $functionname_old($course, $cm, $context);
         } else {
             $this->areas = array();
         }
+
         unset($this->areas['intro']); // hardcoded, ignore attempts to override it
     }
 
@@ -99,14 +93,12 @@ class file_info_context_module extends file_info {
             return null;
         }
 
-        if (!is_viewing($this->context) and !is_enrolled($this->context)) {
+        if (!is_viewing($this->context) and !$this->browser->is_enrolled($this->course->id)) {
             // no peaking here if not enrolled or inspector
             return null;
         }
 
-        $modinfo = get_fast_modinfo($this->course);
-        $cminfo = $modinfo->get_cm($this->cm->id);
-        if (!$cminfo->uservisible) {
+        if (!$this->cm->uservisible) {
             // activity hidden sorry
             return null;
         }
@@ -121,13 +113,10 @@ class file_info_context_module extends file_info {
             return $this->get_area_backup($itemid, $filepath, $filename);
         }
 
-        $functionname     = 'mod_'.$this->modname.'_get_file_info';
-        $functionname_old = $this->modname.'_get_file_info';
-
-        if (function_exists($functionname)) {
-            return $functionname($this->browser, $this->areas, $this->course, $this->cm, $this->context, $filearea, $itemid, $filepath, $filename);
-        } else if (function_exists($functionname_old)) {
-            return $functionname_old($this->browser, $this->areas, $this->course, $this->cm, $this->context, $filearea, $itemid, $filepath, $filename);
+        if ($functionname = component_callback_exists('mod_'.$this->modname, 'get_file_info')) {
+            $cm = $this->cm->get_course_module_record();
+            return $functionname($this->browser, $this->areas, $this->course, $cm,
+                $this->context, $filearea, $itemid, $filepath, $filename);
         }
 
         return null;
@@ -206,7 +195,7 @@ class file_info_context_module extends file_info {
      * @return string
      */
     public function get_visible_name() {
-        return $this->cm->name.' ('.get_string('modulename', $this->cm->modname).')';
+        return $this->cm->get_formatted_name().' ('.$this->cm->get_module_type_name().')';
     }
 
     /**
index 27144c0..a7c4f28 100644 (file)
@@ -25,6 +25,8 @@
 
 defined('MOODLE_INTERNAL') || die();
 
+require_once($CFG->libdir.'/filebrowser/file_info_context_coursecat.php');
+
 /**
  * Represents the system context in the tree navigated by {@link file_browser}.
  *
@@ -32,7 +34,7 @@ defined('MOODLE_INTERNAL') || die();
  * @copyright  2008 Petr Skoda (http://skodak.org)
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class file_info_context_system extends file_info {
+class file_info_context_system extends file_info_context_coursecat {
 
     /**
      * Constructor
@@ -41,7 +43,7 @@ class file_info_context_system extends file_info {
      * @param stdClass $context context object
      */
     public function __construct($browser, $context) {
-        parent::__construct($browser, $context);
+        parent::__construct($browser, $context, (object)['id' => 0, 'parent' => 0, 'visible' => 1]);
     }
 
     /**
@@ -139,71 +141,6 @@ class file_info_context_system extends file_info {
         return true;
     }
 
-    /**
-     * Returns list of children.
-     *
-     * @return array of file_info instances
-     */
-    public function get_children() {
-        global $DB;
-
-        $children = array();
-
-        // Add course categories on the top level that are either visible or user is able to view hidden categories.
-        $course_cats = $DB->get_records('course_categories', array('parent'=>0), 'sortorder', 'id,visible');
-        foreach ($course_cats as $category) {
-            $context = context_coursecat::instance($category->id);
-            if (!$category->visible and !has_capability('moodle/category:viewhiddencategories', $context)) {
-                continue;
-            }
-            if ($child = $this->browser->get_file_info($context)) {
-                $children[] = $child;
-            }
-        }
-
-        // Add courses where user is enrolled that are located in hidden course categories because they would not
-        // be present in the above tree but user may still be able to access files in them.
-        if ($hiddencontexts = $this->get_inaccessible_coursecat_contexts()) {
-            $courses = enrol_get_my_courses();
-            foreach ($courses as $course) {
-                $context = context_course::instance($course->id);
-                $parents = $context->get_parent_context_ids();
-                if (array_intersect($hiddencontexts, $parents)) {
-                    // This course has hidden parent category.
-                    if ($child = $this->browser->get_file_info($context)) {
-                        $children[] = $child;
-                    }
-                }
-            }
-        }
-
-        return $children;
-    }
-
-    /**
-     * Returns list of course categories contexts that current user can not see
-     *
-     * @return array array of course categories contexts ids
-     */
-    protected function get_inaccessible_coursecat_contexts() {
-        global $DB;
-
-        $sql = context_helper::get_preload_record_columns_sql('ctx');
-        $records = $DB->get_records_sql("SELECT ctx.id, $sql
-            FROM {course_categories} c
-            JOIN {context} ctx ON c.id = ctx.instanceid AND ctx.contextlevel = ?
-            WHERE c.visible = ?", [CONTEXT_COURSECAT, 0]);
-        $hiddencontexts = [];
-        foreach ($records as $record) {
-            context_helper::preload_from_record($record);
-            $context = context::instance_by_id($record->id);
-            if (!has_capability('moodle/category:viewhiddencategories', $context)) {
-                $hiddencontexts[] = $record->id;
-            }
-        }
-        return $hiddencontexts;
-    }
-
     /**
      * Returns parent file_info instance
      *
diff --git a/lib/filebrowser/tests/file_browser_test.php b/lib/filebrowser/tests/file_browser_test.php
new file mode 100644 (file)
index 0000000..3ce0570
--- /dev/null
@@ -0,0 +1,244 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for file browser
+ *
+ * @package    core_files
+ * @copyright  2017 Marina Glancy
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Unit tests for file browser
+ *
+ * @package    core_files
+ * @copyright  2017 Marina Glancy
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class file_browser_testcase extends advanced_testcase {
+
+    /** @var stdClass */
+    protected $course1;
+    /** @var stdClass */
+    protected $course2;
+    /** @var stdClass */
+    protected $module1;
+    /** @var stdClass */
+    protected $module2;
+    /** @var stdClass */
+    protected $course1filerecord;
+    /** @var stdClass */
+    protected $teacher;
+    /** @var stdClass */
+    protected $teacherrole;
+
+    /**
+     * Set up
+     */
+    public function setUp() {
+        global $DB;
+        $this->resetAfterTest();
+
+        $this->setAdminUser();
+
+        $this->getDataGenerator()->create_category(); // Empty category.
+        $this->course1 = $this->getDataGenerator()->create_course(); // Empty course.
+
+        $this->course2 = $this->getDataGenerator()->create_course();
+
+        // Add a file to course1 summary.
+        $coursecontext1 = context_course::instance($this->course1->id);
+        $this->course1filerecord = array('contextid' => $coursecontext1->id,
+            'component' => 'course',
+            'filearea' => 'summary',
+            'itemid' => '0',
+            'filepath' => '/',
+            'filename' => 'summaryfile.jpg');
+        $fs = get_file_storage();
+        $fs->create_file_from_string($this->course1filerecord, 'IMG');
+
+        $this->module1 = $this->getDataGenerator()->create_module('resource', ['course' => $this->course2->id]); // Contains 1 file.
+        $this->module2 = $this->getDataGenerator()->create_module('assign', ['course' => $this->course2->id]); // Contains no files.
+
+        $this->teacher = $this->getDataGenerator()->create_user();
+        $this->teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'));
+        $this->getDataGenerator()->enrol_user($this->teacher->id, $this->course1->id, $this->teacherrole->id);
+        $this->getDataGenerator()->enrol_user($this->teacher->id, $this->course2->id, $this->teacherrole->id);
+
+        $this->setUser($this->teacher);
+    }
+
+    /**
+     * Test "Server files" from the system context
+     */
+    public function test_file_info_context_system() {
+
+        // There is one non-empty category child and two category children.
+
+        $browser = get_file_browser();
+        $fileinfo = $browser->get_file_info(context_system::instance());
+        $this->assertNotEmpty($fileinfo->count_non_empty_children());
+        $this->assertEquals(1, count($fileinfo->get_non_empty_children()));
+        $categorychildren = array_filter($fileinfo->get_children(), function($a) {
+            return $a instanceof file_info_context_coursecat;
+        });
+        $this->assertEquals(2, count($categorychildren));
+    }
+
+    /**
+     * Test "Server files" from the system context, hide Misc category
+     */
+    public function test_file_info_context_system_hidden() {
+
+        // Hide the course category that contains our two courses. Teacher does not have cap to view hidden categories.
+        coursecat::get($this->course1->category)->update(['visible' => 0]);
+
+        // We should have two non-empty children in system context (courses).
+        $browser = get_file_browser();
+        $fileinfo = $browser->get_file_info(context_system::instance());
+        $this->assertNotEmpty($fileinfo->count_non_empty_children());
+        $this->assertEquals(2, count($fileinfo->get_non_empty_children()));
+
+        // Should be 1 category children (empty category).
+        $categorychildren = array_filter($fileinfo->get_children(), function($a) {
+            return $a instanceof file_info_context_coursecat;
+        });
+        $this->assertEquals(1, count($categorychildren));
+
+        // Should be 2 course children - courses that belonged to hidden subcategory are now direct children of "System".
+        $coursechildren = array_filter($fileinfo->get_children(), function($a) {
+            return $a instanceof file_info_context_course;
+        });
+        $this->assertEquals(2, count($coursechildren));
+    }
+
+    /**
+     * Test "Server files" from the course category context
+     */
+    public function test_file_info_context_coursecat() {
+
+        // There are two non-empty courses.
+
+        $browser = get_file_browser();
+        $fileinfo = $browser->get_file_info(context_coursecat::instance($this->course2->category));
+        $this->assertNotEmpty($fileinfo->count_non_empty_children());
+        $this->assertEquals(2, count($fileinfo->get_non_empty_children()));
+        $coursechildren = array_filter($fileinfo->get_children(), function($a) {
+            return $a instanceof file_info_context_course;
+        });
+        $this->assertEquals(2, count($coursechildren));
+    }
+
+    /**
+     * Test "Server files" from the course category context, only look for .jpg
+     */
+    public function test_file_info_context_coursecat_jpg() {
+
+        // There is one non-empty category child and two category children.
+
+        $browser = get_file_browser();
+        $fileinfo = $browser->get_file_info(context_system::instance());
+        $this->assertNotEmpty($fileinfo->count_non_empty_children(['.jpg']));
+        $this->assertEquals(1, count($fileinfo->get_non_empty_children(['.jpg'])));
+    }
+
+    /**
+     * Test "Server files" from the course context (course1)
+     */
+    public function test_file_info_context_course_1() {
+
+        $browser = get_file_browser();
+        $fileinfo = $browser->get_file_info(context_course::instance($this->course1->id));
+        // Fileinfo element has only one non-empty child - "Course summary" file area.
+        $this->assertNotEmpty($fileinfo->count_non_empty_children());
+        $nonemptychildren = $fileinfo->get_non_empty_children();
+        $this->assertEquals(1, count($nonemptychildren));
+        $child = reset($nonemptychildren);
+        $this->assertTrue($child instanceof file_info_stored);
+        $this->assertEquals(['filename' => '.'] + $this->course1filerecord, $child->get_params());
+        // Filearea "Course summary" has a child that is the actual image file.
+        $this->assertEquals($this->course1filerecord, $child->get_children()[0]->get_params());
+
+        // There are six course-level file areas and no modules in this course.
+        $allchildren = $fileinfo->get_children();
+        $this->assertEquals(6, count($allchildren));
+        $modulechildren = array_filter($allchildren, function($a) {
+            return $a instanceof file_info_context_module;
+        });
+        $this->assertEquals(0, count($modulechildren));
+
+        // Admin can see seven course-level file areas.
+        $this->setAdminUser();
+        $fileinfo = $browser->get_file_info(context_course::instance($this->course1->id));
+        $this->assertEquals(7, count($fileinfo->get_children()));
+    }
+
+    /**
+     * Test "Server files" from the course context (course1)
+     */
+    public function test_file_info_context_course_2() {
+
+        // 2. Start from the course level.
+        $browser = get_file_browser();
+        $fileinfo = $browser->get_file_info(context_course::instance($this->course2->id));
+        $this->assertNotEmpty($fileinfo->count_non_empty_children());
+        $nonemptychildren = $fileinfo->get_non_empty_children();
+        $this->assertEquals(1, count($nonemptychildren));
+        $child = reset($nonemptychildren);
+        $this->assertTrue($child instanceof file_info_context_module);
+        $this->assertEquals($this->module1->name.' (File)', $child->get_visible_name());
+        $this->assertEquals(1, count($child->get_non_empty_children()));
+        $this->assertEquals(1, $child->count_non_empty_children());
+        $modulechildren = array_filter($fileinfo->get_children(), function($a) {
+            return $a instanceof file_info_context_module;
+        });
+        $this->assertEquals(2, count($modulechildren));
+    }
+
+    /**
+     * Test "Server files" from the course context (module1)
+     */
+    public function test_file_info_context_module_1() {
+
+        $module1context = context_module::instance($this->module1->cmid);
+        $browser = get_file_browser();
+        $fileinfo = $browser->get_file_info($module1context);
+        $this->assertEquals($this->module1->name . ' (File)', $fileinfo->get_visible_name());
+        $this->assertNotEmpty($fileinfo->count_non_empty_children());
+        $nonemptychildren = $fileinfo->get_non_empty_children();
+        $this->assertEquals(1, count($nonemptychildren));
+        $child = reset($nonemptychildren);
+        $this->assertTrue($child instanceof file_info_stored);
+    }
+
+    /**
+     * Test "Server files" from the course context (module1)
+     */
+    public function test_file_info_context_module_2() {
+
+        $module2context = context_module::instance($this->module2->cmid);
+        $browser = get_file_browser();
+        $fileinfo = $browser->get_file_info($module2context);
+        $this->assertEquals($this->module2->name.' (Assignment)', $fileinfo->get_visible_name());
+        $this->assertEmpty($fileinfo->count_non_empty_children());
+        $nonemptychildren = $fileinfo->get_non_empty_children();
+        $this->assertEquals(0, count($nonemptychildren));
+
+    }
+}
index 7d8c2ab..3ca1c63 100644 (file)
@@ -81,6 +81,7 @@ trait templatable_form_element {
         $context['extraclasses'] = $extraclasses;
         $context['type'] = $this->getType();
         $context['attributes'] = implode(' ', $otherattributes);
+        $context['emptylabel'] = ($this->getLabel() === '');
 
         // Elements with multiple values need array syntax.
         if ($this->getAttribute('multiple')) {
index 0a2293a..6a320f1 100644 (file)
@@ -1411,6 +1411,16 @@ abstract class moodleform {
 
         return $data;
     }
+
+    /**
+     * Set display mode for the form when labels take full width of the form and above the elements even on big screens
+     *
+     * Useful for forms displayed inside modals or in narrow containers
+     */
+    public function set_display_vertical() {
+        $oldclass = $this->_form->getAttribute('class');
+        $this->_form->updateAttributes(array('class' => $oldclass . ' full-width-labels'));
+    }
 }
 
 /**
index afcd47e..da9cb84 100644 (file)
@@ -2212,6 +2212,8 @@ class global_navigation extends navigation_node {
     protected function load_for_user($user=null, $forceforcontext=false) {
         global $DB, $CFG, $USER, $SITE;
 
+        require_once($CFG->dirroot . '/course/lib.php');
+
         if ($user === null) {
             // We can't require login here but if the user isn't logged in we don't
             // want to show anything
@@ -2258,7 +2260,7 @@ class global_navigation extends navigation_node {
         } else if ($USER->id != $user->id) {
             // This is the site so add a users node to the root branch.
             $usersnode = $this->rootnodes['users'];
-            if (has_capability('moodle/course:viewparticipants', $coursecontext)) {
+            if (course_can_view_participants($coursecontext)) {
                 $usersnode->action = new moodle_url('/user/index.php', array('id' => $course->id));
             }
             $userviewurl = new moodle_url('/user/profile.php', $baseargs);
@@ -2715,7 +2717,8 @@ class global_navigation extends navigation_node {
             // Just a link to course competency.
             $title = get_string('competencies', 'core_competency');
             $path = new moodle_url("/admin/tool/lp/coursecompetencies.php", array('courseid' => $course->id));
-            $coursenode->add($title, $path, navigation_node::TYPE_SETTING, null, null, new pix_icon('i/competencies', ''));
+            $coursenode->add($title, $path, navigation_node::TYPE_SETTING, null, 'competencies',
+                    new pix_icon('i/competencies', ''));
         }
         if ($navoptions->grades) {
             $url = new moodle_url('/grade/report/index.php', array('id'=>$course->id));
index a6c2cf3..616e860 100644 (file)
@@ -56,7 +56,7 @@
 
         <div class="subcontent loginsub">
             {{#error}}
-                <div class="loginerrors">
+                <div class="loginerrors" role="alert">
                     <a href="#" id="loginerrormessage" class="accesshide">{{error}}</a>
                     {{{errorformatted}}}
                 </div>
index cd4d742..38772ea 100644 (file)
@@ -44,6 +44,8 @@ information provided here is intended especially for developers.
 * user_can_view_profile() now also checks the moodle/user:viewalldetails capability.
 * The core/modal_confirm dialogue has been deprecated. Please use the core/modal_save_cancel dialogue instead. Please ensure you
   update to use the ModalEvents.save and ModalEvents.cancel events instead of their yes/no counterparts.
+* Instead of checking the 'moodle/course:viewparticipants' and 'moodle/site:viewparticipants' capabilities use the
+  new functions course_can_view_participants() and course_require_view_participants().
 
 === 3.3.1 ===
 
index 642353f..2065529 100644 (file)
@@ -199,6 +199,10 @@ class api {
         // Make sure to limit searches to enrolled courses.
         $enrolledcourses = enrol_get_my_courses(array('id', 'cacherev'));
         $courses = array();
+        // Really we want the user to be able to view the participants if they have the capability
+        // 'moodle/course:viewparticipants' or 'moodle/course:enrolreview', but since the search_courses function
+        // only takes required parameters we can't. However, the chance of a user having 'moodle/course:enrolreview' but
+        // *not* 'moodle/course:viewparticipants' are pretty much zero, so it is not worth addressing.
         if ($arrcourses = \coursecat::search_courses(array('search' => $search), array('limit' => $limitnum),
                 array('moodle/course:viewparticipants'))) {
             foreach ($arrcourses as $course) {
index f57dc9f..009451f 100644 (file)
@@ -47,8 +47,8 @@ class message_received extends base_message {
         // We don't want to index messages received from noreply and support users.
         $params = array('modifiedfrom' => $modifiedfrom, 'noreplyuser' => \core_user::NOREPLY_USER,
             'supportuser' => \core_user::SUPPORT_USER);
-        return $DB->get_recordset_select('message_read', 'timecreated >= :modifiedfrom AND
-            useridto != :noreplyuser AND useridto != :supportuser', $params, 'timecreated ASC');
+        return $DB->get_recordset_select('message_read', 'timeread >= :modifiedfrom AND
+            useridto != :noreplyuser AND useridto != :supportuser', $params, 'timeread ASC');
     }
 
     /**
index f0ab8d3..07373d8 100644 (file)
@@ -46,8 +46,8 @@ class message_sent extends base_message {
         // We don't want to index messages sent by noreply and support users.
         $params = array('modifiedfrom' => $modifiedfrom, 'noreplyuser' => \core_user::NOREPLY_USER,
             'supportuser' => \core_user::SUPPORT_USER);
-        return $DB->get_recordset_select('message_read', 'timecreated >= :modifiedfrom AND
-            useridfrom != :noreplyuser AND useridfrom != :supportuser', $params, 'timecreated ASC');
+        return $DB->get_recordset_select('message_read', 'timeread >= :modifiedfrom AND
+            useridfrom != :noreplyuser AND useridfrom != :supportuser', $params, 'timeread ASC');
     }
 
     /**
index 0d80644..27f7568 100644 (file)
@@ -249,7 +249,7 @@ define(['jquery', 'core/templates', 'core/notification', 'core/custom_interactio
         // If the element isn't in the view window.
         if (relativeTop > container.innerHeight()) {
             var height = notificationElement.outerHeight();
-            // offset enough to make sure the notification will be in view.
+            // Offset enough to make sure the notification will be in view.
             height = height * 4;
             var scrollTo = position.top - height;
             container.scrollTop(scrollTo);
index ea5a69c..7517d9c 100644 (file)
@@ -36,6 +36,7 @@ require_once($CFG->dirroot . '/mod/assign/locallib.php');
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class activity extends \core_search\base_activity {
+
     /**
      * Returns true if this area uses file indexing.
      *
@@ -46,22 +47,14 @@ class activity extends \core_search\base_activity {
     }
 
     /**
-     * Add the attached description files.
+     * Return the context info required to index files for
+     * this search area.
      *
-     * @param document $document The current document
-     * @return null
+     * @return array
      */
-    public function attach_files($document) {
-        $fs = get_file_storage();
-
-        $cm = $this->get_cm($this->get_module_name(), $document->get('itemid'), $document->get('courseid'));
-        $context = \context_module::instance($cm->id);
-
-        $files = $fs->get_area_files($context->id, 'mod_assign', ASSIGN_INTROATTACHMENT_FILEAREA, 0,
-                'sortorder DESC, id ASC', false);
+    public function get_search_fileareas() {
+        $fileareas = array('intro', ASSIGN_INTROATTACHMENT_FILEAREA); // Fileareas.
 
-        foreach ($files as $file) {
-            $document->add_stored_file($file);
-        }
+        return $fileareas;
     }
 }
index 14103ad..0ae808e 100644 (file)
@@ -170,6 +170,12 @@ class assign {
     /** @var array cached list of IDs of users who share group membership with the user. The cache key will be the user. */
     private $sharedgroupmembers = array();
 
+    /**
+     * @var stdClass The most recent team submission. Used to determine additional attempt numbers and whether
+     * to update the gradebook.
+     */
+    private $mostrecentteamsubmission = null;
+
     /**
      * Constructor for the base assign class.
      *
@@ -2478,13 +2484,16 @@ class assign {
 
         $submission = null;
         if ($this->get_instance()->teamsubmission) {
-            $submission = $this->get_group_submission($grade->userid, 0, false);
+            if (isset($this->mostrecentteamsubmission)) {
+                $submission = $this->mostrecentteamsubmission;
+            } else {
+                $submission = $this->get_group_submission($grade->userid, 0, false);
+            }
         } else {
             $submission = $this->get_user_submission($grade->userid, false);
         }
 
-        // Only push to gradebook if the update is for the latest attempt.
-        // Not the latest attempt.
+        // Only push to gradebook if the update is for the most recent attempt.
         if ($submission && $submission->attemptnumber != $grade->attemptnumber) {
             return true;
         }
@@ -7935,6 +7944,12 @@ class assign {
         $instance = $this->get_instance();
         $submission = null;
         if ($instance->teamsubmission) {
+            // We need to know what the most recent group submission is.
+            // Specifically when determining if we are adding another attempt (we only want to add one attempt per team),
+            // and when deciding if we need to update the gradebook with an edited grade.
+            $mostrecentsubmission = $this->get_group_submission($userid, 0, false, -1);
+            $this->set_most_recent_team_submission($mostrecentsubmission);
+            // Get the submission that we are saving grades for. The data attempt number determines which submission attempt.
             $submission = $this->get_group_submission($userid, 0, false, $data->attemptnumber);
         } else {
             $submission = $this->get_user_submission($userid, false, $data->attemptnumber);
@@ -7949,8 +7964,10 @@ class assign {
             }
             $members = $this->get_submission_group_members($groupid, true, $this->show_only_active_users());
             foreach ($members as $member) {
-                // User may exist in multple groups (which should put them in the default group).
-                $this->apply_grade_to_user($data, $member->id, $data->attemptnumber);
+                // We only want to update the grade for this group submission attempt. The data attempt number could be
+                // -1 which may end up in additional attempts being created for each group member instead of just one
+                // additional attempt for the group.
+                $this->apply_grade_to_user($data, $member->id, $submission->attemptnumber);
                 $this->process_outcomes($member->id, $data, $userid);
             }
         } else {
@@ -8121,6 +8138,11 @@ class assign {
             }
 
             if (empty($groupsprocessed[$groupid])) {
+                // We need to know what the most recent group submission is.
+                // Specifically when determining if we are adding another attempt (we only want to add one attempt per team),
+                // and when deciding if we need to update the gradebook with an edited grade.
+                $currentsubmission = $this->get_group_submission($userid, 0, false, -1);
+                $this->set_most_recent_team_submission($currentsubmission);
                 $result = $this->process_add_attempt($userid) && $result;
                 $groupsprocessed[$groupid] = true;
             }
@@ -8171,7 +8193,18 @@ class assign {
 
         // Create the new submission record for the group/user.
         if ($this->get_instance()->teamsubmission) {
-            $newsubmission = $this->get_group_submission($userid, 0, true, $oldsubmission->attemptnumber + 1);
+            if (isset($this->mostrecentteamsubmission)) {
+                // Team submissions can end up in this function for each user (via save_grade). We don't want to create
+                // more than one attempt for the whole team.
+                if ($this->mostrecentteamsubmission->attemptnumber == $oldsubmission->attemptnumber) {
+                    $newsubmission = $this->get_group_submission($userid, 0, true, $oldsubmission->attemptnumber + 1);
+                } else {
+                    $newsubmission = $this->get_group_submission($userid, 0, false, $oldsubmission->attemptnumber);
+                }
+            } else {
+                debugging('Please use set_most_recent_team_submission() before calling add_attempt', DEBUG_DEVELOPER);
+                $newsubmission = $this->get_group_submission($userid, 0, true, $oldsubmission->attemptnumber + 1);
+            }
         } else {
             $newsubmission = $this->get_user_submission($userid, true, $oldsubmission->attemptnumber + 1);
         }
@@ -8635,6 +8668,18 @@ class assign {
 
         return $o;
     }
+
+    /**
+     * Set the most recent submission for the team.
+     * The most recent team submission is used to determine if another attempt should be created when allowing another
+     * attempt on a group assignment, and whether the gradebook should be updated.
+     *
+     * @since Moodle 3.4
+     * @param stdClass $submission The most recent submission of the group.
+     */
+    public function set_most_recent_team_submission($submission) {
+        $this->mostrecentteamsubmission = $submission;
+    }
 }
 
 /**
index ba940f5..29fdb6b 100644 (file)
@@ -126,42 +126,43 @@ Feature: In an assignment, students start a new attempt based on their previous
       | operation | Allow another attempt |
     And I click on "Go" "button" confirming the dialogue
     And I should not see "The grades were not saved because someone has modified one or more records more recently than when you loaded the page."
-# Behat tests for the group submission, should be uncommented once the MDL-48216 is fixed.
-#    And I log out
-#    And I log in as "student3"
-#    And I am on "Course 1" course homepage
-#    And I follow "Test assignment name"
-#    #And I should see "This is attempt 1 ( 3 attempts allowed )."
-#    And I press "Add submission"
-#    And I set the following fields to these values:
-#      | Online text | I'm the student's 3 group 2 first attempt |
-#    And I press "Save changes"
-#    And I log out
-#    And I log in as "teacher1"
-#    And I am on "Course 1" course homepage
-#    And I follow "Test assignment name"
-#    And I navigate to "View all submissions" in current page administration
-#    And "Student 1" row "Status" column of "generaltable" table should contain "Reopened"
-#    And "Student 2" row "Status" column of "generaltable" table should contain "Reopened"
-#    And "Student 3" row "Status" column of "generaltable" table should contain "Submitted for grading"
-#    And "Student 4" row "Status" column of "generaltable" table should contain "Submitted for grading"
-#    And I click on "Grade " "link" in the "Student 3" "table_row"
-#    And I set the following fields to these values:
-#      | Allow another attempt | 1 |
-#    And I press "Save changes"
-#    And I log out
-#    And I log in as "student4"
-#    And I am on "Course 1" course homepage
-#    And I follow "Test assignment name"
-#    #And I should see "This is attempt 2 ( 3 attempts allowed )."
-#    And I press "Add submission"
-#    And I set the following fields to these values:
-#      | Online text | I'm the student's 4 group 2 second attempt |
-#    And I press "Save changes"
-#    And I log out
-#    And I log in as "teacher1"
-#    And I am on "Course 1" course homepage
-#    And I follow "Test assignment name"
-#    I navigate to "View all submissions" in current page administration
-#    And I click on "Grade" "link" in the "Student 1" "table_row"
-    #And I should see "This is attempt 2 (3 attempts allowed)"
+    And I log out
+    And I log in as "student3"
+    And I am on "Course 1" course homepage
+    And I follow "Test assignment name"
+    And I should see "This is attempt 1 ( 3 attempts allowed )."
+    And I press "Add submission"
+    And I set the following fields to these values:
+      | Online text | I'm the student's 3 group 2 first attempt |
+    And I press "Save changes"
+    And I log out
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Test assignment name"
+    And I navigate to "View all submissions" in current page administration
+    And "Student 1" row "Status" column of "generaltable" table should contain "Reopened"
+    And "Student 2" row "Status" column of "generaltable" table should contain "Reopened"
+    And "Student 3" row "Status" column of "generaltable" table should contain "Submitted for grading"
+    And "Student 4" row "Status" column of "generaltable" table should contain "Submitted for grading"
+    And I click on "Grade" "link" in the "Student 3" "table_row"
+    And I set the following fields to these values:
+      | Allow another attempt | 1 |
+    And I press "Save changes"
+    And I press "Ok"
+    And I follow "Assignment: Test assignment name"
+    And I log out
+    And I log in as "student4"
+    And I am on "Course 1" course homepage
+    And I follow "Test assignment name"
+    And I should see "This is attempt 2 ( 3 attempts allowed )."
+    And I press "Add a new attempt"
+    And I set the following fields to these values:
+      | Online text | I'm the student's 4 group 2 second attempt |
+    And I press "Save changes"
+    And I log out
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Test assignment name"
+    And I select "Group 2" from the "group" singleselect
+    And I click on "Grade" "link" in the ".submissionlinks" "css_element"
+    And I should see "2" in the "#id_attemptsettings" "css_element"
index 0d99f07..e6cef21 100644 (file)
@@ -1,5 +1,9 @@
 This files describes API changes in the assign code.
 
+=== 3.4 ===
+* assign::add_attempt requires that set_most_recent_team_submission() be called if attempting to use this function with a team
+  submission.
+
 === 3.3.2 ===
 * assign_refresh_events() Now takes two additional parameters to refine the update to a specific instance. This function
   now optionally takes the module instance object or ID, and the course module object or ID. Please try to send the full
index 862e42e..dac6b7b 100644 (file)
@@ -34,4 +34,13 @@ defined('MOODLE_INTERNAL') || die();
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class activity extends \core_search\base_activity {
+
+    /**
+     * Returns true if this area uses file indexing.
+     *
+     * @return bool
+     */
+    public function uses_file_indexing() {
+        return true;
+    }
 }
index 021b58c..3acd186 100644 (file)
@@ -158,4 +158,25 @@ class chapter extends \core_search\base_mod {
         $contextmodule = \context::instance_by_id($doc->get('contextid'));
         return new \moodle_url('/mod/book/view.php', array('id' => $contextmodule->instanceid));
     }
+
+    /**
+     * Returns true if this area uses file indexing.
+     *
+     * @return bool
+     */
+    public function uses_file_indexing() {
+        return true;
+    }
+
+    /**
+     * Return the context info required to index files for
+     * this search area.
+     *
+     * @return array
+     */
+    public function get_search_fileareas() {
+        $fileareas = array('chapter'); // Filearea.
+
+        return $fileareas;
+    }
 }
index ffc7eb7..4072c78 100644 (file)
@@ -579,8 +579,8 @@ function book_export_contents($cm, $baseurl) {
         $chapterindexfile['filesize']     = 0;
         $chapterindexfile['fileurl']      = moodle_url::make_webservice_pluginfile_url(
                     $context->id, 'mod_book', 'chapter', $chapter->id, '/', 'index.html')->out(false);
-        $chapterindexfile['timecreated']  = $book->timecreated;
-        $chapterindexfile['timemodified'] = $book->timemodified;
+        $chapterindexfile['timecreated']  = $chapter->timecreated;
+        $chapterindexfile['timemodified'] = $chapter->timemodified;
         $chapterindexfile['content']      = format_string($chapter->title, true, array('context' => $context));
         $chapterindexfile['sortorder']    = 0;
         $chapterindexfile['userid']       = null;
index 067dd6e..98bed37 100644 (file)
@@ -34,4 +34,13 @@ defined('MOODLE_INTERNAL') || die();
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class activity extends \core_search\base_activity {
+
+    /**
+     * Returns true if this area uses file indexing.
+     *
+     * @return bool
+     */
+    public function uses_file_indexing() {
+        return true;
+    }
 }
index af2bd91..0c7eb2f 100644 (file)
@@ -34,4 +34,13 @@ defined('MOODLE_INTERNAL') || die();
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class activity extends \core_search\base_activity {
+
+    /**
+     * Returns true if this area uses file indexing.
+     *
+     * @return bool
+     */
+    public function uses_file_indexing() {
+        return true;
+    }
 }
index 0cd9457..b188cbe 100644 (file)
@@ -542,6 +542,7 @@ class mod_data_external extends external_api {
 
         $result = array(
             'entry' => $entry,
+            'ratinginfo' => \core_rating\external\util::get_rating_info($database, $context, 'mod_data', 'entry', array($record)),
             'warnings' => $warnings
         );
         // Check if we should return the entry rendered.
@@ -564,6 +565,7 @@ class mod_data_external extends external_api {
             array(
                 'entry' => record_exporter::get_read_structure(),
                 'entryviewcontents' => new external_value(PARAM_RAW, 'The entry as is rendered in the site.', VALUE_OPTIONAL),
+                'ratinginfo' => \core_rating\external\util::external_ratings_structure(),
                 'warnings' => new external_warnings()
             )
         );
index 7f1f609..75fc280 100644 (file)
@@ -34,4 +34,14 @@ defined('MOODLE_INTERNAL') || die();
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class activity extends \core_search\base_activity {
+
+    /**
+     * Returns true if this area uses file indexing.
+     *
+     * @return bool
+     */
+    public function uses_file_indexing() {
+        return true;
+    }
+
 }
index 8220817..dece2fe 100644 (file)
@@ -1251,4 +1251,68 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
         $this->expectException('moodle_exception');
         mod_data_external::update_entry($entry11, []);
     }
+
+    /**
+     * Test get_entry_rating_information.
+     */
+    public function test_get_entry_rating_information() {
+        global $DB, $CFG;
+        require_once($CFG->dirroot . '/rating/lib.php');
+
+        $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();
+
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($user1->id, $this->course->id, $this->studentrole->id, 'manual');
+        $this->getDataGenerator()->enrol_user($user2->id, $this->course->id, $this->studentrole->id, 'manual');
+
+        // Rate the entry as user1.
+        $rating1 = new stdClass();
+        $rating1->contextid = $this->context->id;
+        $rating1->component = 'mod_data';
+        $rating1->ratingarea = 'entry';
+        $rating1->itemid = $entry11;
+        $rating1->rating = 50;
+        $rating1->scaleid = 100;
+        $rating1->userid = $user1->id;
+        $rating1->timecreated = time();
+        $rating1->timemodified = time();
+        $rating1->id = $DB->insert_record('rating', $rating1);
+
+        // Rate the entry as user2.
+        $rating2 = new stdClass();
+        $rating2->contextid = $this->context->id;
+        $rating2->component = 'mod_data';
+        $rating2->ratingarea = 'entry';
+        $rating2->itemid = $entry11;
+        $rating2->rating = 100;
+        $rating2->scaleid = 100;
+        $rating2->userid = $user2->id;
+        $rating2->timecreated = time() + 1;
+        $rating2->timemodified = time() + 1;
+        $rating2->id = $DB->insert_record('rating', $rating2);
+
+        // As student, retrieve ratings information.
+        $this->setUser($this->student2);
+        $result = mod_data_external::get_entry($entry11);
+        $result = external_api::clean_returnvalue(mod_data_external::get_entry_returns(), $result);
+        $this->assertCount(1, $result['ratinginfo']['ratings']);
+        $this->assertFalse($result['ratinginfo']['ratings'][0]['canviewaggregate']);
+        $this->assertFalse($result['ratinginfo']['canviewall']);
+        $this->assertFalse($result['ratinginfo']['ratings'][0]['canrate']);
+        $this->assertTrue(!isset($result['ratinginfo']['ratings'][0]['count']));
+
+        // Now, as teacher, I should see the info correctly.
+        $this->setUser($this->teacher);
+        $result = mod_data_external::get_entry($entry11);
+        $result = external_api::clean_returnvalue(mod_data_external::get_entry_returns(), $result);
+        $this->assertCount(1, $result['ratinginfo']['ratings']);
+        $this->assertTrue($result['ratinginfo']['ratings'][0]['canviewaggregate']);
+        $this->assertTrue($result['ratinginfo']['canviewall']);
+        $this->assertTrue($result['ratinginfo']['ratings'][0]['canrate']);
+        $this->assertEquals(2, $result['ratinginfo']['ratings'][0]['count']);
+        $this->assertEquals(100, $result['ratinginfo']['ratings'][0]['aggregate']); // Expect maximium scale value.
+    }
 }
index 92ede9d..28f7a44 100644 (file)
@@ -4,6 +4,7 @@ information provided here is intended especially for developers.
 === 3.4 ===
 * External function mod_data_external::search_entries() now returns the maxcount field: Total count of records that the user could
     see in the database (if all the search criterias were removed).
+* External function get_entry now returns an additional field "ratinginfo" containing the entry rating information.
 
 === 3.3.2 ===
 * data_refresh_events() Now takes two additional parameters to refine the update to a specific instance. This function
index cdcceee..44480a2 100644 (file)
@@ -70,6 +70,7 @@ class mod_feedback_complete_form extends moodleform {
         $isanonymous = $this->structure->is_anonymous() ? ' ianonymous' : '';
         parent::__construct(null, $customdata, 'POST', '',
                 array('id' => $formid, 'class' => 'feedback_form' . $isanonymous), true);
+        $this->set_display_vertical();
     }
 
     /**
index 84755b4..eee1f83 100644 (file)
@@ -34,4 +34,13 @@ defined('MOODLE_INTERNAL') || die();
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class activity extends \core_search\base_activity {
+
+    /**
+     * Returns true if this area uses file indexing.
+     *
+     * @return bool
+     */
+    public function uses_file_indexing() {
+        return true;
+    }
 }
index 0809337..51175e2 100644 (file)
@@ -34,6 +34,7 @@ defined('MOODLE_INTERNAL') || die();
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class activity extends \core_search\base_activity {
+
     /**
      * Returns true if this area uses file indexing.
      *
@@ -44,21 +45,14 @@ class activity extends \core_search\base_activity {
     }
 
     /**
-     * Add all the folder files to the index.
+     * Return the context info required to index files for
+     * this search area.
      *
-     * @param document $document The current document
-     * @return null
+     * @return array
      */
-    public function attach_files($document) {
-        $fs = get_file_storage();
-
-        $cm = $this->get_cm($this->get_module_name(), $document->get('itemid'), $document->get('courseid'));
-        $context = \context_module::instance($cm->id);
-
-        $files = $fs->get_area_files($context->id, 'mod_folder', 'content', 0, 'sortorder DESC, id ASC', false);
+    public function get_search_fileareas() {
+        $fileareas = array('intro', 'content'); // Fileareas.
 
-        foreach ($files as $file) {
-            $document->add_stored_file($file);
-        }
+        return $fileareas;
     }
 }
index 6435104..8239758 100644 (file)
@@ -34,4 +34,13 @@ defined('MOODLE_INTERNAL') || die();
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class activity extends \core_search\base_activity {
+
+    /**
+     * Returns true if this area uses file indexing.
+     *
+     * @return bool
+     */
+    public function uses_file_indexing() {
+        return true;
+    }
 }
index 8c43e4e..bb48a03 100644 (file)
@@ -120,6 +120,21 @@ class post extends \core_search\base_mod {
         return true;
     }
 
+    /**
+     * Return the context info required to index files for
+     * this search area.
+     *
+     * @return array
+     */
+    public function get_search_fileareas() {
+        $fileareas = array(
+            'attachment',
+            'post'
+        );
+
+        return $fileareas;
+    }
+
     /**
      * Add the forum post attachments.
      *
@@ -142,14 +157,21 @@ class post extends \core_search\base_mod {
         // Because this is used during indexing, we don't want to cache posts. Would result in memory leak.
         unset($this->postsdata[$postid]);
 
-        $cm = $this->get_cm('forum', $post->forum, $document->get('courseid'));
+        $cm = $this->get_cm($this->get_module_name(), $post->forum, $document->get('courseid'));
         $context = \context_module::instance($cm->id);
+        $contextid = $context->id;
+
+        $fileareas = $this->get_search_fileareas();
+        $component = $this->get_component_name();
 
         // Get the files and attach them.
-        $fs = get_file_storage();
-        $files = $fs->get_area_files($context->id, 'mod_forum', 'attachment', $postid, "filename", false);
-        foreach ($files as $file) {
-            $document->add_stored_file($file);
+        foreach ($fileareas as $filearea) {
+            $fs = get_file_storage();
+            $files = $fs->get_area_files($contextid, $component, $filearea, $postid, '', false);
+
+            foreach ($files as $file) {
+                $document->add_stored_file($file);
+            }
         }
     }
 
index 12c0e12..9bdb2d4 100644 (file)
@@ -309,6 +309,7 @@ class mod_forum_external extends external_api {
 
         $result = array();
         $result['posts'] = $posts;
+        $result['ratinginfo'] = \core_rating\external\util::get_rating_info($forum, $modcontext, 'mod_forum', 'post', $posts);
         $result['warnings'] = $warnings;
         return $result;
     }
@@ -349,6 +350,7 @@ class mod_forum_external extends external_api {
                             ), 'post'
                         )
                     ),
+                'ratinginfo' => \core_rating\external\util::external_ratings_structure(),
                 'warnings' => new external_warnings()
             )
         );
index 0f080a9..03ecffd 100644 (file)
@@ -5362,6 +5362,8 @@ function forum_print_latest_discussions($course, $forum, $maxdiscussions = -1, $
                                         $currentgroup = -1, $groupmode = -1, $page = -1, $perpage = 100, $cm = null) {
     global $CFG, $USER, $OUTPUT;
 
+    require_once($CFG->dirroot . '/course/lib.php');
+
     if (!$cm) {
         if (!$cm = get_coursemodule_from_instance('forum', $forum->id, $forum->course)) {
             print_error('invalidcoursemodule');
@@ -5498,7 +5500,7 @@ function forum_print_latest_discussions($course, $forum, $maxdiscussions = -1, $
         }
     }
 
-    $canviewparticipants = has_capability('moodle/course:viewparticipants',$context);
+    $canviewparticipants = course_can_view_participants($context);
     $canviewhiddentimedposts = has_capability('mod/forum:viewhiddentimedposts', $context);
 
     $strdatestring = get_string('strftimerecentfull');
index e3ee2b6..cfe301f 100644 (file)
@@ -275,6 +275,15 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
         // Create what we expect to be returned when querying the discussion.
         $expectedposts = array(
             'posts' => array(),
+            'ratinginfo' => array(
+                'contextid' => $forum1context->id,
+                'component' => 'mod_forum',
+                'ratingarea' => 'post',
+                'canviewall' => null,
+                'canviewany' => null,
+                'scales' => array(),
+                'ratings' => array(),
+            ),
             'warnings' => array(),
         );
 
@@ -1066,4 +1075,95 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
 
     }
 
+    /**
+     * Test get forum posts discussions including rating information.
+     */
+    public function test_mod_forum_get_forum_discussion_rating_information() {
+        global $DB, $CFG;
+        require_once($CFG->dirroot . '/rating/lib.php');
+
+        $this->resetAfterTest(true);
+
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+        $user3 = self::getDataGenerator()->create_user();
+        $teacher = self::getDataGenerator()->create_user();
+