Merge branch 'MDL-59961_file_validation' of https://github.com/mrmark/moodle
authorDavid Monllao <davidm@moodle.com>
Mon, 4 Sep 2017 09:08:23 +0000 (11:08 +0200)
committerDavid Monllao <davidm@moodle.com>
Thu, 7 Sep 2017 08:46:57 +0000 (10:46 +0200)
200 files changed:
admin/tool/analytics/classes/output/models_list.php
admin/tool/lp/templates/form-user-selector-suggestion.mustache
admin/tool/lp/tests/behat/plan_crud.feature
analytics/classes/classifier.php [new file with mode: 0644]
analytics/classes/course.php
analytics/classes/local/analyser/base.php
analytics/classes/local/indicator/base.php
analytics/classes/local/indicator/binary.php
analytics/classes/local/indicator/community_of_inquiry_activity.php
analytics/classes/local/indicator/discrete.php
analytics/classes/local/indicator/linear.php
analytics/classes/local/target/base.php
analytics/classes/local/target/binary.php
analytics/classes/local/target/discrete.php
analytics/classes/local/target/linear.php
analytics/classes/local/time_splitting/base.php
analytics/classes/model.php
analytics/classes/predictor.php
analytics/classes/regressor.php [new file with mode: 0644]
analytics/classes/site.php
analytics/tests/model_test.php
backup/moodle2/backup_course_task.class.php
backup/util/helper/tests/backup_encode_content_test.php
blocks/myoverview/templates/course-event-list.mustache
calendar/amd/build/calendar.min.js
calendar/amd/build/drag_drop.min.js [deleted file]
calendar/amd/build/drag_drop_data_store.min.js [new file with mode: 0644]
calendar/amd/build/event_form.min.js
calendar/amd/build/modal_event_form.min.js
calendar/amd/build/month_navigation_drag_drop.min.js [new file with mode: 0644]
calendar/amd/build/month_view_drag_drop.min.js [new file with mode: 0644]
calendar/amd/build/view_manager.min.js
calendar/amd/src/calendar.js
calendar/amd/src/drag_drop_data_store.js [new file with mode: 0644]
calendar/amd/src/event_form.js
calendar/amd/src/modal_event_form.js
calendar/amd/src/month_navigation_drag_drop.js [new file with mode: 0644]
calendar/amd/src/month_view_drag_drop.js [moved from calendar/amd/src/drag_drop.js with 64% similarity]
calendar/amd/src/view_manager.js
calendar/classes/external/day_exporter.php
calendar/classes/local/event/forms/create.php
calendar/classes/local/event/mappers/create_update_form_mapper.php
calendar/lib.php
calendar/renderer.php
calendar/templates/month_detailed.mustache
calendar/templates/month_navigation.mustache
calendar/tests/externallib_test.php
course/classes/analytics/indicator/completion_enabled.php [new file with mode: 0644]
course/classes/analytics/indicator/no_teacher.php
course/classes/analytics/indicator/potential_cognitive_depth.php [new file with mode: 0644]
course/classes/analytics/indicator/potential_social_breadth.php [new file with mode: 0644]
course/lib.php
course/tests/courselib_test.php
course/tests/indicators_test.php [new file with mode: 0644]
enrol/locallib.php
enrol/manual/templates/form-user-selector-suggestion.mustache
group/classes/output/user_groups_editable.php
install/lang/da/install.php
install/lang/dsb/langconfig.php [new file with mode: 0644]
install/lang/kn/install.php [new file with mode: 0644]
install/lang/sk/moodle.php
install/lang/sl/error.php
lang/en/analytics.php
lang/en/calendar.php
lang/en/enrol.php
lang/en/error.php
lang/en/moodle.php
lib/amd/build/form-autocomplete.min.js
lib/amd/build/inplace_editable.min.js
lib/amd/src/form-autocomplete.js
lib/amd/src/inplace_editable.js
lib/classes/analytics/target/no_teaching.php
lib/classes/oauth2/api.php
lib/classes/oauth2/client.php
lib/classes/oauth2/refresh_system_tokens_task.php
lib/classes/oauth2/rest.php
lib/db/install.xml
lib/db/upgrade.php
lib/filelib.php
lib/form/datetimeselector.php
lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-debug.js
lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-min.js
lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector.js
lib/form/yui/src/dateselector/js/dateselector.js
lib/formslib.php
lib/mlbackend/php/classes/processor.php
lib/mlbackend/python/classes/processor.php
lib/templates/overlay_loading.mustache [new file with mode: 0644]
lib/tests/behat/behat_forms.php
mod/assign/backup/moodle2/restore_assign_stepslib.php
mod/assign/classes/analytics/indicator/cognitive_depth.php
mod/assign/classes/analytics/indicator/social_breadth.php
mod/assign/db/upgrade.php
mod/assign/feedback/editpdf/classes/combined_document.php
mod/assign/lang/en/assign.php
mod/assign/lib.php
mod/assign/locallib.php
mod/assign/submission/comments/lib.php
mod/assign/submission/comments/locallib.php
mod/assign/tests/behat/assign_comments_no_error.feature [new file with mode: 0644]
mod/assign/tests/behat/rescale_grades.feature
mod/assign/tests/lib_test.php
mod/assign/tests/locallib_test.php
mod/assign/upgradelib.php
mod/assign/version.php
mod/book/classes/analytics/indicator/activity_base.php
mod/book/classes/analytics/indicator/cognitive_depth.php
mod/book/classes/analytics/indicator/social_breadth.php
mod/chat/classes/analytics/indicator/cognitive_depth.php
mod/chat/classes/analytics/indicator/social_breadth.php
mod/choice/classes/analytics/indicator/cognitive_depth.php
mod/choice/classes/analytics/indicator/social_breadth.php
mod/data/classes/analytics/indicator/cognitive_depth.php
mod/data/classes/analytics/indicator/social_breadth.php
mod/feedback/classes/analytics/indicator/cognitive_depth.php
mod/feedback/classes/analytics/indicator/social_breadth.php
mod/folder/classes/analytics/indicator/activity_base.php
mod/folder/classes/analytics/indicator/cognitive_depth.php
mod/folder/classes/analytics/indicator/social_breadth.php
mod/forum/classes/analytics/indicator/cognitive_depth.php
mod/forum/classes/analytics/indicator/social_breadth.php
mod/glossary/classes/analytics/indicator/cognitive_depth.php
mod/glossary/classes/analytics/indicator/social_breadth.php
mod/imscp/classes/analytics/indicator/activity_base.php
mod/imscp/classes/analytics/indicator/cognitive_depth.php
mod/imscp/classes/analytics/indicator/social_breadth.php
mod/label/classes/analytics/indicator/activity_base.php
mod/label/classes/analytics/indicator/cognitive_depth.php
mod/label/classes/analytics/indicator/social_breadth.php
mod/lesson/classes/analytics/indicator/cognitive_depth.php
mod/lesson/classes/analytics/indicator/social_breadth.php
mod/lti/classes/analytics/indicator/cognitive_depth.php
mod/lti/classes/analytics/indicator/social_breadth.php
mod/page/classes/analytics/indicator/activity_base.php
mod/page/classes/analytics/indicator/cognitive_depth.php
mod/page/classes/analytics/indicator/social_breadth.php
mod/quiz/classes/analytics/indicator/cognitive_depth.php
mod/quiz/classes/analytics/indicator/social_breadth.php
mod/resource/classes/analytics/indicator/activity_base.php
mod/resource/classes/analytics/indicator/cognitive_depth.php
mod/resource/classes/analytics/indicator/social_breadth.php
mod/scorm/classes/analytics/indicator/cognitive_depth.php
mod/scorm/classes/analytics/indicator/social_breadth.php
mod/survey/classes/analytics/indicator/activity_base.php
mod/survey/classes/analytics/indicator/cognitive_depth.php
mod/survey/classes/analytics/indicator/social_breadth.php
mod/url/classes/analytics/indicator/activity_base.php
mod/url/classes/analytics/indicator/cognitive_depth.php
mod/url/classes/analytics/indicator/social_breadth.php
mod/wiki/classes/analytics/indicator/cognitive_depth.php
mod/wiki/classes/analytics/indicator/social_breadth.php
mod/workshop/classes/analytics/indicator/cognitive_depth.php
mod/workshop/classes/analytics/indicator/social_breadth.php
question/engine/questionusage.php
report/completion/index.php
report/insights/action.php
report/insights/classes/output/insight.php
report/insights/classes/output/insights_list.php
report/insights/classes/output/renderer.php
report/insights/insights.php
report/insights/lang/en/report_insights.php
report/insights/lib.php
report/insights/prediction.php
report/insights/settings.php [new file with mode: 0644]
report/insights/templates/insight.mustache
report/insights/templates/insight_details.mustache
report/insights/templates/insights_list.mustache
report/insights/version.php
repository/filepicker.js
theme/boost/scss/moodle/calendar.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/forms.scss
theme/boost/scss/moodle/user.scss
theme/boost/templates/core/form_autocomplete_selection.mustache
theme/bootstrapbase/less/moodle.less
theme/bootstrapbase/less/moodle/bs4-compat.less
theme/bootstrapbase/less/moodle/calendar.less
theme/bootstrapbase/less/moodle/core.less
theme/bootstrapbase/less/moodle/forms.less
theme/bootstrapbase/less/moodle/modal.less
theme/bootstrapbase/less/moodle/user.less
theme/bootstrapbase/less/moodle/variables.less [deleted file]
theme/bootstrapbase/style/moodle.css
theme/bootstrapbase/templates/block_myoverview/course-event-list-item.mustache
theme/bootstrapbase/templates/block_myoverview/course-event-list.mustache
theme/bootstrapbase/templates/block_myoverview/course-summary.mustache
theme/bootstrapbase/templates/block_myoverview/courses-view-course-item.mustache
theme/bootstrapbase/templates/block_myoverview/event-list-item.mustache
theme/bootstrapbase/templates/report_insights/insight.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/report_insights/insight_details.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/report_insights/insights_list.mustache [new file with mode: 0644]
user/action_redir.php
user/classes/output/user_roles_editable.php
user/classes/participants_table.php
user/index.php
user/renderer.php
user/templates/status_field.mustache
user/templates/unified_filter.mustache
user/tests/behat/view_participants.feature
version.php

index c068b18..b074ae3 100644 (file)
@@ -115,7 +115,9 @@ class models_list implements \renderable, \templatable {
             }
 
             // Model predictions list.
-            if ($model->uses_insights()) {
+            if (!$model->is_enabled()) {
+                $modeldata->noinsights = get_string('disabledmodel', 'analytics');
+            } else if ($model->uses_insights()) {
                 $predictioncontexts = $model->get_predictions_contexts();
                 if ($predictioncontexts) {
 
index 0946f78..61557f9 100644 (file)
@@ -49,7 +49,7 @@
     }
 }}
 <span>
-    <img height="18" src="{{profileimageurlsmall}}" alt="" role="presentation">
+    <img height="12" src="{{profileimageurlsmall}}" alt="" role="presentation">
     <span>{{fullname}}</span>
     {{#hasidentity}}
     <span><small>{{identity}}</small></span>
index 7e9fecc..f1ff7dd 100644 (file)
@@ -29,7 +29,7 @@ Feature: Manage plearning plan
     And I navigate to "Competencies > Learning plan templates" in site administration
     And I click on ".template-userplans" "css_element" in the "Science template" "table_row"
     And I open the autocomplete suggestions list
-    And I click on "Admin" item in the autocomplete list
+    And I click on "Admin User" item in the autocomplete list
     And I press key "27" in the field "Select users to create learning plans for"
     When I click on "Create learning plans" "button"
     Then I should see "A learning plan was created"
diff --git a/analytics/classes/classifier.php b/analytics/classes/classifier.php
new file mode 100644 (file)
index 0000000..be0d3a0
--- /dev/null
@@ -0,0 +1,69 @@
+<?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/>.
+
+/**
+ * Classifier interface.
+ *
+ * @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_analytics;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Classifier interface.
+ *
+ * @package   core_analytics
+ * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+interface classifier extends predictor {
+
+    /**
+     * Train this processor classification model using the provided supervised learning dataset.
+     *
+     * @param string $uniqueid
+     * @param \stored_file $dataset
+     * @param string $outputdir
+     * @return \stdClass
+     */
+    public function train_classification($uniqueid, \stored_file $dataset, $outputdir);
+
+    /**
+     * Classifies the provided dataset samples.
+     *
+     * @param string $uniqueid
+     * @param \stored_file $dataset
+     * @param string $outputdir
+     * @return \stdClass
+     */
+    public function classify($uniqueid, \stored_file $dataset, $outputdir);
+
+    /**
+     * Evaluates this processor classification model using the provided supervised learning dataset.
+     *
+     * @param string $uniqueid
+     * @param float $maxdeviation
+     * @param int $niterations
+     * @param \stored_file $dataset
+     * @param string $outputdir
+     * @return \stdClass
+     */
+    public function evaluate_classification($uniqueid, $maxdeviation, $niterations, \stored_file $dataset, $outputdir);
+}
index aae5748..791d7c2 100644 (file)
@@ -140,10 +140,18 @@ class course implements \core_analytics\analysable {
         $this->now = time();
 
         // Get the course users, including users assigned to student and teacher roles at an higher context.
-        $studentroles = array_keys(get_archetype_roles('student'));
+        $cache = \cache::make_from_params(\cache_store::MODE_REQUEST, 'core_analytics', 'rolearchetypes');
+
+        if (!$studentroles = $cache->get('student')) {
+            $studentroles = array_keys(get_archetype_roles('student'));
+            $cache->set('student', $studentroles);
+        }
         $this->studentids = $this->get_user_ids($studentroles);
 
-        $teacherroles = array_keys(get_archetype_roles('editingteacher') + get_archetype_roles('teacher'));
+        if (!$teacherroles = $cache->get('teacher')) {
+            $teacherroles = array_keys(get_archetype_roles('editingteacher') + get_archetype_roles('teacher'));
+            $cache->set('teacher', $teacherroles);
+        }
         $this->teacherids = $this->get_user_ids($teacherroles);
     }
 
index 9bf10c8..b179dcb 100644 (file)
@@ -469,6 +469,9 @@ abstract class base {
             return $result;
         }
 
+        // Add target metadata.
+        $this->add_target_metadata($data);
+
         // Write all calculated data to a file.
         $file = $dataset->store($data);
 
@@ -636,4 +639,28 @@ abstract class base {
             $DB->insert_record('analytics_predict_samples', $predictionrange);
         }
     }
+
+    /**
+     * Adds target metadata to the dataset.
+     *
+     * @param array $data
+     * @return void
+     */
+    protected function add_target_metadata(&$data) {
+        $data[0][] = 'targetcolumn';
+        $data[1][] = $this->analysabletarget->get_id();
+        if ($this->analysabletarget->is_linear()) {
+            $data[0][] = 'targettype';
+            $data[1][] = 'linear';
+            $data[0][] = 'targetmin';
+            $data[1][] = $this->analysabletarget::get_min_value();
+            $data[0][] = 'targetmax';
+            $data[1][] = $this->analysabletarget::get_max_value();
+        } else {
+            $data[0][] = 'targettype';
+            $data[1][] = 'discrete';
+            $data[0][] = 'targetclasses';
+            $data[1][] = json_encode($this->analysabletarget::get_classes());
+        }
+    }
 }
index 30fdf38..7c6aa3a 100644 (file)
@@ -121,6 +121,22 @@ abstract class base extends \core_analytics\calculable {
         return self::MIN_VALUE;
     }
 
+    /**
+     * Hook to allow indicators to pre-fill data that is shared accross time range calculations.
+     *
+     * Useful to fill analysable-dependant data that does not depend on the time ranges. Use
+     * instance vars to cache data that can be re-used across samples calculations but changes
+     * between time ranges (indicator instances are reset between time ranges to avoid unexpected
+     * problems).
+     *
+     * You are also responsible of emptying previous analysable caches.
+     *
+     * @param \core_analytics\analysable $analysable
+     * @return void
+     */
+    public function fill_per_analysable_caches(\core_analytics\analysable $analysable) {
+    }
+
     /**
      * Calculates the indicator.
      *
index 7f4c346..a730996 100644 (file)
@@ -46,6 +46,23 @@ abstract class binary extends discrete {
         return array(0);
     }
 
+    /**
+     * It should always be displayed.
+     *
+     * Binary values have no subtypes by default, please overwrite if
+     * your indicator is adding extra features.
+     *
+     * @param float $value
+     * @param string $subtype
+     * @return bool
+     */
+    public function should_be_displayed($value, $subtype) {
+        if ($subtype != false) {
+            return false;
+        }
+        return true;
+    }
+
     /**
      * get_display_value
      *
index 60d5e0f..8a886eb 100644 (file)
@@ -60,12 +60,36 @@ abstract class community_of_inquiry_activity extends linear {
      */
     const INDICATOR_SOCIAL = "social";
 
+    /**
+     * Max cognitive depth level accepted.
+     */
+    const MAX_COGNITIVE_LEVEL = 5;
+
+    /**
+     * Max social breadth level accepted.
+     */
+    const MAX_SOCIAL_LEVEL = 5;
+
+    /**
+     * Fetch the course grades of this activity type instances.
+     *
+     * @param \core_analytics\analysable $analysable
+     * @return void
+     */
+    public function fill_per_analysable_caches(\core_analytics\analysable $analysable) {
+
+        // Better to check it, we can not be 100% it will be a \core_analytics\course object.
+        if ($analysable instanceof \core_analytics\course) {
+            $this->fetch_student_grades($analysable);
+        }
+    }
+
     /**
      * Returns the activity type. No point in changing this class in children classes.
      *
      * @var string The activity name (e.g. assign or quiz)
      */
-    protected final function get_activity_type() {
+    public final function get_activity_type() {
         $class = get_class($this);
         $package = stristr($class, "\\", true);
         $type = str_replace("mod_", "", $package);
@@ -81,7 +105,7 @@ abstract class community_of_inquiry_activity extends linear {
      * @param \cm_info $cm
      * @return int
      */
-    protected function get_cognitive_depth_level(\cm_info $cm) {
+    public function get_cognitive_depth_level(\cm_info $cm) {
         throw new \coding_exception('Overwrite get_cognitive_depth_level method to set your activity potential cognitive ' .
             'depth level');
     }
@@ -92,7 +116,7 @@ abstract class community_of_inquiry_activity extends linear {
      * @param \cm_info $cm
      * @return int
      */
-    protected function get_social_breadth_level(\cm_info $cm) {
+    public function get_social_breadth_level(\cm_info $cm) {
         throw new \coding_exception('Overwrite get_social_breadth_level method to set your activity potential social ' .
             'breadth level');
     }
@@ -396,8 +420,8 @@ abstract class community_of_inquiry_activity extends linear {
         }
 
         if ($this->grades === null) {
-            $courseactivities = $this->course->get_all_activities($this->get_activity_type());
-            $this->grades = $this->course->get_student_grades($courseactivities);
+            // Even if this is probably already filled during fill_per_analysable_caches.
+            $this->fetch_student_grades($this->course);
         }
 
         if ($cm = $this->retrieve('cm', $sampleid)) {
@@ -506,7 +530,7 @@ abstract class community_of_inquiry_activity extends linear {
         foreach ($useractivities as $contextid => $cm) {
 
             $potentiallevel = $this->get_cognitive_depth_level($cm);
-            if (!is_int($potentiallevel) || $potentiallevel > 5 || $potentiallevel < 1) {
+            if (!is_int($potentiallevel) || $potentiallevel > self::MAX_COGNITIVE_LEVEL || $potentiallevel < 1) {
                 throw new \coding_exception('Activities\' potential cognitive depth go from 1 to 5.');
             }
             $scoreperlevel = $scoreperactivity / $potentiallevel;
@@ -593,12 +617,19 @@ abstract class community_of_inquiry_activity extends linear {
         foreach ($useractivities as $contextid => $cm) {
 
             $potentiallevel = $this->get_social_breadth_level($cm);
-            if (!is_int($potentiallevel) || $potentiallevel > 2 || $potentiallevel < 1) {
-                throw new \coding_exception('Activities\' potential social breadth go from 1 to 2.');
+            if (!is_int($potentiallevel) || $potentiallevel > self::MAX_SOCIAL_LEVEL || $potentiallevel < 1) {
+                throw new \coding_exception('Activities\' potential social breadth go from 1 to ' .
+                    community_of_inquiry_activity::MAX_SOCIAL_LEVEL . '.');
             }
             $scoreperlevel = $scoreperactivity / $potentiallevel;
             switch ($potentiallevel) {
                 case 2:
+                case 3:
+                case 4:
+                case 5:
+                    // Core activities social breadth only reaches level 2, until core activities social
+                    // breadth do not reach level 5 we limit it to what we currently support, which is level 2.
+
                     // Social breadth level 2 is to view feedback. (Same as cognitive level 3).
 
                     if ($this->any_feedback('viewed', $cm, $contextid, $user)) {
@@ -645,10 +676,21 @@ abstract class community_of_inquiry_activity extends linear {
         throw new \coding_exception("Indicator type is invalid.");
     }
 
+    /**
+     * Gets the course student grades.
+     *
+     * @param \core_analytics\course $course
+     * @return void
+     */
+    protected function fetch_student_grades(\core_analytics\course $course) {
+        $courseactivities = $course->get_all_activities($this->get_activity_type());
+        $this->grades = $course->get_student_grades($courseactivities);
+    }
+
     /**
      * Defines indicator type.
      *
      * @return string
      */
-    abstract protected function get_indicator_type();
+    abstract public function get_indicator_type();
 }
index 0b51e0d..8a8aa66 100644 (file)
@@ -85,7 +85,7 @@ abstract class discrete extends base {
      */
     public function get_display_value($value, $subtype = false) {
 
-        $displayvalue = array_search($subtype, static::get_classes());
+        $displayvalue = array_search($subtype, static::get_classes(), false);
 
         debugging('Please overwrite \core_analytics\local\indicator\discrete::get_display_value to show something ' .
             'different than the default "' . $displayvalue . '"', DEBUG_DEVELOPER);
index 691aa8a..56902c0 100644 (file)
@@ -63,7 +63,7 @@ abstract class linear extends base {
     }
 
     /**
-     * should_be_displayed
+     * Show only the main feature.
      *
      * @param float $value
      * @param string $subtype
index 466255e..1a4f978 100644 (file)
@@ -130,6 +130,8 @@ abstract class base extends \core_analytics\calculable {
     /**
      * Callback to execute once a prediction has been returned from the predictions processor.
      *
+     * Note that the analytics_predictions db record is not yet inserted.
+     *
      * @param int $modelid
      * @param int $sampleid
      * @param int $rangeindex
@@ -231,7 +233,7 @@ abstract class base extends \core_analytics\calculable {
      */
     protected function min_prediction_score() {
         // The default minimum discards predictions with a low score.
-        return \core_analytics\model::MIN_SCORE;
+        return \core_analytics\model::PREDICTION_MIN_SCORE;
     }
 
     /**
index ea04410..f8c3b04 100644 (file)
@@ -78,7 +78,7 @@ abstract class binary extends discrete {
             throw new \moodle_exception('errorpredictionformat', 'analytics');
         }
 
-        if (in_array($value, $this->ignored_predicted_classes())) {
+        if (in_array($value, $this->ignored_predicted_classes(), false)) {
             // Just in case, if it is ignored the prediction should not even be recorded but if it would, it is ignored now,
             // which should mean that is it nothing serious.
             return self::OUTCOME_VERY_POSITIVE;
index 0799b4d..cbd8fe0 100644 (file)
@@ -42,17 +42,18 @@ abstract class discrete extends base {
      */
     public function is_linear() {
         // Not supported yet.
-        throw new \coding_exception('Sorry, this version\'s prediction processors only support targets with binary values.');
+        throw new \coding_exception('Sorry, this version\'s prediction processors only support targets with binary values.' .
+            ' You can write your own and overwrite this method though.');
     }
 
     /**
      * Is the provided class one of this target valid classes?
      *
-     * @param string $class
+     * @param mixed $class
      * @return bool
      */
     protected static function is_a_class($class) {
-        return (in_array($class, static::get_classes()));
+        return (in_array($class, static::get_classes(), false));
     }
 
     /**
@@ -99,7 +100,7 @@ abstract class discrete extends base {
             throw new \moodle_exception('errorpredictionformat', 'analytics');
         }
 
-        if (in_array($value, $this->ignored_predicted_classes())) {
+        if (in_array($value, $this->ignored_predicted_classes(), false)) {
             // Just in case, if it is ignored the prediction should not even be recorded.
             return self::OUTCOME_OK;
         }
@@ -138,15 +139,16 @@ abstract class discrete extends base {
      * Returns the predicted classes that will be ignored.
      *
      * Better be keen to add more than less classes here, the callback is always able to discard some classes. As an example
-     * a target with classes 'grade 0-3', 'grade 3-6', 'grade 6-8' and 'grade 8-10' is interested in flagging both 'grade 0-3'
-     * and 'grade 3-6'. On the other hand, a target like dropout risk with classes 'yes', 'no' may just be interested in 'yes'.
+     * a target with classes 'grade 0-3', 'grade 3-6', 'grade 6-8' and 'grade 8-10' is interested in flagging both 'grade 6-8'
+     * and 'grade 8-10' as ignored. On the other hand, a target like dropout risk with classes 'yes', 'no' may just be
+     * interested in 'yes'.
      *
      * @return array List of values that will be ignored (array keys are ignored).
      */
     protected function ignored_predicted_classes() {
         // Coding exception as this will only be called if this target have non-linear values.
-        throw new \coding_exception('Overwrite ignored_predicted_classes() and return an array with the classes that triggers ' .
-            'the callback');
+        throw new \coding_exception('Overwrite ignored_predicted_classes() and return an array with the classes that should not ' .
+            'trigger the callback');
     }
 
     /**
@@ -162,10 +164,8 @@ abstract class discrete extends base {
             return false;
         }
 
-        if (!$this->is_linear()) {
-            if (in_array($predictedvalue, $this->ignored_predicted_classes())) {
-                return false;
-            }
+        if (in_array($predictedvalue, $this->ignored_predicted_classes())) {
+            return false;
         }
 
         return true;
index 8d8e258..d16ad96 100644 (file)
@@ -42,7 +42,8 @@ abstract class linear extends base {
      */
     public function is_linear() {
         // Not supported yet.
-        throw new \coding_exception('Sorry, this version\'s prediction processors only support targets with binary values.');
+        throw new \coding_exception('Sorry, this version\'s prediction processors only support targets with binary values.' .
+            ' You can write your own and overwrite this method though.');
     }
 
     /**
@@ -52,7 +53,7 @@ abstract class linear extends base {
      * @param string $ignoredsubtype
      * @return int
      */
-    public function get_calculated_outcome($value, $ignoredsubtype = false) {
+    public function get_calculation_outcome($value, $ignoredsubtype = false) {
 
         // This is very generic, targets will probably be interested in overwriting this.
         $diff = static::get_max_value() - static::get_min_value();
@@ -67,7 +68,7 @@ abstract class linear extends base {
      *
      * @return float
      */
-    protected static function get_max_value() {
+    public static function get_max_value() {
         // Coding exception as this will only be called if this target have linear values.
         throw new \coding_exception('Overwrite get_max_value() and return the target max value');
     }
@@ -77,11 +78,33 @@ abstract class linear extends base {
      *
      * @return float
      */
-    protected static function get_min_value() {
+    public static function get_min_value() {
         // Coding exception as this will only be called if this target have linear values.
         throw new \coding_exception('Overwrite get_min_value() and return the target min value');
     }
 
+    /**
+     * Should the model callback be triggered?
+     *
+     * @param mixed $predictedvalue
+     * @param float $predictionscore
+     * @return bool
+     */
+    public function triggers_callback($predictedvalue, $predictionscore) {
+
+        if (!parent::triggers_callback($predictedvalue, $predictionscore)) {
+            return false;
+        }
+
+        // People may not want to set a boundary.
+        $boundary = $this->get_callback_boundary();
+        if (!empty($boundary) && floatval($predictedvalue) < $boundary) {
+            return false;
+        }
+
+        return true;
+    }
+
     /**
      * Returns the minimum value that triggers the callback.
      *
index 109084e..f45934a 100644 (file)
@@ -235,6 +235,9 @@ abstract class base {
         $newcalculations = array();
         foreach ($indicators as $indicator) {
 
+            // Hook to allow indicators to store analysable-dependant data.
+            $indicator->fill_per_analysable_caches($this->analysable);
+
             // Per-range calculations.
             foreach ($ranges as $rangeindex => $range) {
 
@@ -371,12 +374,9 @@ abstract class base {
         $metadata = array(
             'timesplitting' => $this->get_id(),
             // If no target the first column is the sampleid, if target the last column is the target.
+            // This will need to be updated when we support unsupervised learning models.
             'nfeatures' => count(current($dataset)) - 1
         );
-        if ($target) {
-            $metadata['targetclasses'] = json_encode($target::get_classes());
-            $metadata['targettype'] = ($target->is_linear()) ? 'linear' : 'discrete';
-        }
 
         // The first 2 samples will be used to store metadata about the dataset.
         $metadatacolumns = [];
index 04a95f1..8120dd6 100644 (file)
@@ -80,6 +80,11 @@ class model {
      */
     const MIN_SCORE = 0.7;
 
+    /**
+     * Minimum prediction confidence (from 0 to 1) to accept a prediction as reliable enough.
+     */
+    const PREDICTION_MIN_SCORE = 0.6;
+
     /**
      * Maximum standard deviation between different evaluation repetitions to consider that evaluation results are stable.
      */
@@ -438,7 +443,12 @@ class model {
 
             // Reset trained flag.
             $this->model->trained = 0;
+
+        } else if ($this->model->enabled != $enabled) {
+            // We purge the cached contexts with insights as some will not be visible anymore.
+            $this->purge_insights_cache();
         }
+
         $this->model->enabled = intval($enabled);
         $this->model->indicators = $indicatorsstr;
         $this->model->timesplitting = $timesplittingid;
@@ -524,8 +534,13 @@ class model {
             $outputdir = $this->get_output_dir(array('evaluation', $dashestimesplittingid));
 
             // Evaluate the dataset, the deviation we accept in the results depends on the amount of iterations.
-            $predictorresult = $predictor->evaluate($this->model->id, self::ACCEPTED_DEVIATION,
+            if ($this->get_target()->is_linear()) {
+                $predictorresult = $predictor->evaluate_regression($this->get_unique_id(), self::ACCEPTED_DEVIATION,
+                self::EVALUATION_ITERATIONS, $dataset, $outputdir);
+            } else {
+                $predictorresult = $predictor->evaluate_classification($this->get_unique_id(), self::ACCEPTED_DEVIATION,
                 self::EVALUATION_ITERATIONS, $dataset, $outputdir);
+            }
 
             $result->status = $predictorresult->status;
             $result->info = $predictorresult->info;
@@ -599,7 +614,11 @@ class model {
         $samplesfile = $datasets[$this->model->timesplitting];
 
         // Train using the dataset.
-        $predictorresult = $predictor->train($this->get_unique_id(), $samplesfile, $outputdir);
+        if ($this->get_target()->is_linear()) {
+            $predictorresult = $predictor->train_regression($this->get_unique_id(), $samplesfile, $outputdir);
+        } else {
+            $predictorresult = $predictor->train_classification($this->get_unique_id(), $samplesfile, $outputdir);
+        }
 
         $result = new \stdClass();
         $result->status = $predictorresult->status;
@@ -678,8 +697,12 @@ class model {
             $result->predictions = $this->get_static_predictions($indicatorcalculations);
 
         } else {
-            // Prediction process runs on the machine learning backend.
-            $predictorresult = $predictor->predict($this->get_unique_id(), $samplesfile, $outputdir);
+            // Estimation and classification processes run on the machine learning backend side.
+            if ($this->get_target()->is_linear()) {
+                $predictorresult = $predictor->estimate($this->get_unique_id(), $samplesfile, $outputdir);
+            } else {
+                $predictorresult = $predictor->classify($this->get_unique_id(), $samplesfile, $outputdir);
+            }
             $result->status = $predictorresult->status;
             $result->info = $predictorresult->info;
             $result->predictions = $this->format_predictor_predictions($predictorresult);
@@ -757,9 +780,12 @@ class model {
                 list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
 
                 // Store the predicted values.
-                $samplecontext = $this->save_prediction($sampleid, $rangeindex, $prediction->prediction,
+                list($record, $samplecontext) = $this->prepare_prediction_record($sampleid, $rangeindex, $prediction->prediction,
                     $prediction->predictionscore, json_encode($indicatorcalculations[$uniquesampleid]));
 
+                // We will later bulk-insert them all.
+                $records[$uniquesampleid] = $record;
+
                 // Also store all samples context to later generate insights or whatever action the target wants to perform.
                 $samplecontexts[$samplecontext->id] = $samplecontext;
 
@@ -768,6 +794,8 @@ class model {
             }
         }
 
+        $this->save_predictions($records);
+
         return $samplecontexts;
     }
 
@@ -894,7 +922,7 @@ class model {
      * @param string $calculations
      * @return \context
      */
-    protected function save_prediction($sampleid, $rangeindex, $prediction, $predictionscore, $calculations) {
+    protected function prepare_prediction_record($sampleid, $rangeindex, $prediction, $predictionscore, $calculations) {
         global $DB;
 
         $context = $this->get_analyser()->sample_access_context($sampleid);
@@ -908,9 +936,18 @@ class model {
         $record->predictionscore = $predictionscore;
         $record->calculations = $calculations;
         $record->timecreated = time();
-        $DB->insert_record('analytics_predictions', $record);
 
-        return $context;
+        return array($record, $context);
+    }
+
+    /**
+     * Save the prediction objects.
+     *
+     * @param \stdClass[] $records
+     */
+    protected function save_predictions($records) {
+        global $DB;
+        $DB->insert_records('analytics_predictions', $records);
     }
 
     /**
@@ -939,6 +976,13 @@ class model {
             $this->model->timesplitting = $timesplittingid;
             $this->model->version = $now;
         }
+
+        // Purge pages with insights as this may change things.
+        if ($timesplittingid && $timesplittingid !== $this->model->timesplitting ||
+                $this->model->enabled != 1) {
+            $this->purge_insights_cache();
+        }
+
         $this->model->enabled = 1;
         $this->model->timemodified = $now;
 
@@ -1194,7 +1238,7 @@ class model {
 
         // Generate a unique id for this site, this model and this time splitting method, considering the last time
         // that the model target and indicators were updated.
-        $ids = array($CFG->wwwroot, $CFG->dirroot, $CFG->prefix, $this->model->id, $this->model->version);
+        $ids = array($CFG->wwwroot, $CFG->prefix, $this->model->id, $this->model->version);
         $this->uniqueid = sha1(implode('$$', $ids));
 
         return $this->uniqueid;
@@ -1343,6 +1387,13 @@ class model {
 
         // We don't expect people to clear models regularly and the cost of filling the cache is
         // 1 db read per context.
+        $this->purge_insights_cache();
+    }
+
+    /**
+     * Purges the insights cache.
+     */
+    private function purge_insights_cache() {
         $cache = \cache::make('core', 'contextwithinsights');
         $cache->purge();
     }
index 944c76e..4b4548b 100644 (file)
@@ -41,36 +41,4 @@ interface predictor {
      * @return bool
      */
     public function is_ready();
-
-    /**
-     * Train the provided dataset.
-     *
-     * @param int $modelid
-     * @param \stored_file $dataset
-     * @param string $outputdir
-     * @return \stdClass
-     */
-    public function train($modelid, \stored_file $dataset, $outputdir);
-
-    /**
-     * Predict the provided dataset samples.
-     *
-     * @param int $modelid
-     * @param \stored_file $dataset
-     * @param string $outputdir
-     * @return \stdClass
-     */
-    public function predict($modelid, \stored_file $dataset, $outputdir);
-
-    /**
-     * evaluate
-     *
-     * @param int $modelid
-     * @param float $maxdeviation
-     * @param int $niterations
-     * @param \stored_file $dataset
-     * @param string $outputdir
-     * @return \stdClass
-     */
-    public function evaluate($modelid, $maxdeviation, $niterations, \stored_file $dataset, $outputdir);
 }
diff --git a/analytics/classes/regressor.php b/analytics/classes/regressor.php
new file mode 100644 (file)
index 0000000..c2d2a89
--- /dev/null
@@ -0,0 +1,69 @@
+<?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/>.
+
+/**
+ * Regressors interface.
+ *
+ * @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_analytics;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Regressors interface.
+ *
+ * @package   core_analytics
+ * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+interface regressor extends predictor {
+
+    /**
+     * Train this processor regression model using the provided supervised learning dataset.
+     *
+     * @param string $uniqueid
+     * @param \stored_file $dataset
+     * @param string $outputdir
+     * @return \stdClass
+     */
+    public function train_regression($uniqueid, \stored_file $dataset, $outputdir);
+
+    /**
+     * Estimates linear values for the provided dataset samples.
+     *
+     * @param string $uniqueid
+     * @param \stored_file $dataset
+     * @param mixed $outputdir
+     * @return void
+     */
+    public function estimate($uniqueid, \stored_file $dataset, $outputdir);
+
+    /**
+     * Evaluates this processor regression model using the provided supervised learning dataset.
+     *
+     * @param string $uniqueid
+     * @param float $maxdeviation
+     * @param int $niterations
+     * @param \stored_file $dataset
+     * @param string $outputdir
+     * @return \stdClass
+     */
+    public function evaluate_regression($uniqueid, $maxdeviation, $niterations, \stored_file $dataset, $outputdir);
+}
index d616d4b..69fe722 100644 (file)
@@ -73,21 +73,14 @@ class site implements \core_analytics\analysable {
             return $this->start;
         }
 
-        if (!$logstore = \core_analytics\manager::get_analytics_logstore()) {
-            $this->start = 0;
-            return $this->start;
-        }
-
-        // Basically a SELECT MIN(timecreated) FROM ...
-        $events = $logstore->get_events_select("", array(), "timecreated ASC", 0, 1);
-        if ($events) {
-            // There should be just 1 event.
-            $event = reset($events);
-            $this->start = intval($event->timecreated);
-        } else {
-            $this->start = 0;
+        // Much faster than reading the first log in the site.
+        $admins = get_admins();
+        $this->start = 9999999999;
+        foreach ($admins as $admin) {
+            if ($admin->firstaccess < $this->start) {
+                $this->start = $admin->firstaccess;
+            }
         }
-
         return $this->start;
     }
 
@@ -101,21 +94,7 @@ class site implements \core_analytics\analysable {
             return $this->end;
         }
 
-        if (!$logstore = \core_analytics\manager::get_analytics_logstore()) {
-            $this->end = time();
-            return $this->end;
-        }
-
-        // Basically a SELECT MAX(timecreated) FROM ...
-        $events = $logstore->get_events_select("", array(), "timecreated DESC", 0, 1);
-        if ($events) {
-            // There should be just 1 event.
-            $event = reset($events);
-            $this->end = intval($event->timecreated);
-        } else {
-            $this->end = time();
-        }
-
+        $this->end = time();
         return $this->end;
     }
 }
index d4cf05a..b8e1768 100644 (file)
@@ -150,12 +150,11 @@ class analytics_model_testcase extends advanced_testcase {
 
         global $DB;
 
-        // 2 built-in models + the testing one.
-        $this->assertCount(3, $DB->get_records('analytics_models'));
+        $count = $DB->count_records('analytics_models');
 
         // No new models added if the builtin ones already exist.
         \core_analytics\manager::add_builtin_models();
-        $this->assertCount(3, $DB->get_records('analytics_models'));
+        $this->assertCount($count, $DB->get_records('analytics_models'));
 
         $target = \core_analytics\manager::get_target('\core\analytics\target\no_teaching');
         $this->assertTrue(\core_analytics\model::exists($target));
index f9ae5dc..d9060e3 100644 (file)
@@ -167,15 +167,25 @@ class backup_course_task extends backup_task {
     /**
      * Helper method, used by encode_content_links.
      * @param string $content content in which to encode links.
-     * @param unknown_type $name the name of this type of encoded link.
-     * @param unknown_type $path the path that identifies this type of link, up
+     * @param string $name the name of this type of encoded link.
+     * @param string $path the path that identifies this type of link, up
      *      to the ?paramname= bit.
      * @return string content with one type of link encoded.
      */
     static private function encode_links_helper($content, $name, $path) {
         global $CFG;
-        $base = preg_quote($CFG->wwwroot . $path, '/');
-        return preg_replace('/(' . $base . ')([0-9]+)/', '$@' . $name . '*$2@$', $content);
+        // We want to convert both http and https links.
+        $root = $CFG->wwwroot;
+        $httpsroot = str_replace('http://', 'https://', $root);
+        $httproot = str_replace('https://', 'http://', $root);
+
+        $httpsbase = preg_quote($httpsroot . $path, '/');
+        $httpbase = preg_quote($httproot . $path, '/');
+
+        $return = preg_replace('/(' . $httpsbase . ')([0-9]+)/', '$@' . $name . '*$2@$', $content);
+        $return = preg_replace('/(' . $httpbase . ')([0-9]+)/', '$@' . $name . '*$2@$', $return);
+
+        return $return;
     }
 
 // Protected API starts here
index 26299d9..63554d3 100644 (file)
@@ -44,13 +44,33 @@ class backup_course_task_testcase extends basic_testcase {
      */
     public function test_course_encode_content_links() {
         global $CFG;
+        $httpsroot = "https://moodle.org";
+        $httproot = "http://moodle.org";
+        $oldroot = $CFG->wwwroot;
+
+        // HTTPS root and links of both types in content.
+        $CFG->wwwroot = $httpsroot;
         $encoded = backup_course_task::encode_content_links(
-                $CFG->wwwroot . '/course/view.php?id=123, ' .
-                $CFG->wwwroot . '/grade/index.php?id=123, ' .
-                $CFG->wwwroot . '/grade/report/index.php?id=123, ' .
-                $CFG->wwwroot . '/badges/view.php?type=2&id=123 and ' .
-                $CFG->wwwroot . '/user/index.php?id=123.');
-        $this->assertEquals('$@COURSEVIEWBYID*123@$, $@GRADEINDEXBYID*123@$, ' .
+                $httproot . '/course/view.php?id=123, ' .
+                $httpsroot . '/course/view.php?id=123, ' .
+                $httpsroot . '/grade/index.php?id=123, ' .
+                $httpsroot . '/grade/report/index.php?id=123, ' .
+                $httpsroot . '/badges/view.php?type=2&id=123 and ' .
+                $httpsroot . '/user/index.php?id=123.');
+        $this->assertEquals('$@COURSEVIEWBYID*123@$, $@COURSEVIEWBYID*123@$, $@GRADEINDEXBYID*123@$, ' .
                 '$@GRADEREPORTINDEXBYID*123@$, $@BADGESVIEWBYID*123@$ and $@USERINDEXVIEWBYID*123@$.', $encoded);
+
+        // HTTP root and links of both types in content.
+        $CFG->wwwroot = $httproot;
+        $encoded = backup_course_task::encode_content_links(
+            $httproot . '/course/view.php?id=123, ' .
+            $httpsroot . '/course/view.php?id=123, ' .
+            $httproot . '/grade/index.php?id=123, ' .
+            $httproot . '/grade/report/index.php?id=123, ' .
+            $httproot . '/badges/view.php?type=2&id=123 and ' .
+            $httproot . '/user/index.php?id=123.');
+        $this->assertEquals('$@COURSEVIEWBYID*123@$, $@COURSEVIEWBYID*123@$, $@GRADEINDEXBYID*123@$, ' .
+            '$@GRADEREPORTINDEXBYID*123@$, $@BADGESVIEWBYID*123@$ and $@USERINDEXVIEWBYID*123@$.', $encoded);
+        $CFG->wwwroot = $oldroot;
     }
 }
index d9e729a..d7f9fb2 100644 (file)
@@ -97,7 +97,7 @@
              role="presentation">
         <p class="text-muted m-t-1">{{#str}} noevents, block_myoverview {{/str}}</p>
         <a href="{{viewurl}}" class="btn btn-secondary {{#visible}}text-primary{{/visible}}"
-           aria-label="{{#str}} viewcoursename, block_myoverview, {{fullnamedisplay}} {{/str}}">
+           aria-label="{{#str}} viewcoursename, block_myoverview, {{{fullnamedisplay}}} {{/str}}">
             {{#str}} viewcourse, block_myoverview {{/str}}
         </a>
     </div>
index b0336c4..9d29445 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/drag_drop.min.js b/calendar/amd/build/drag_drop.min.js
deleted file mode 100644 (file)
index 249c6d6..0000000
Binary files a/calendar/amd/build/drag_drop.min.js and /dev/null differ
diff --git a/calendar/amd/build/drag_drop_data_store.min.js b/calendar/amd/build/drag_drop_data_store.min.js
new file mode 100644 (file)
index 0000000..e2cc7de
Binary files /dev/null and b/calendar/amd/build/drag_drop_data_store.min.js differ
index 49c3a88..d90e7c8 100644 (file)
Binary files a/calendar/amd/build/event_form.min.js and b/calendar/amd/build/event_form.min.js differ
index 78b804a..f29703f 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/month_navigation_drag_drop.min.js b/calendar/amd/build/month_navigation_drag_drop.min.js
new file mode 100644 (file)
index 0000000..a8888bf
Binary files /dev/null and b/calendar/amd/build/month_navigation_drag_drop.min.js differ
diff --git a/calendar/amd/build/month_view_drag_drop.min.js b/calendar/amd/build/month_view_drag_drop.min.js
new file mode 100644 (file)
index 0000000..b2ee13d
Binary files /dev/null and b/calendar/amd/build/month_view_drag_drop.min.js differ
index dc730d3..cb96bd8 100644 (file)
Binary files a/calendar/amd/build/view_manager.min.js and b/calendar/amd/build/view_manager.min.js differ
index 86fd106..ea10975 100644 (file)
@@ -58,10 +58,13 @@ define([
 
     var SELECTORS = {
         ROOT: "[data-region='calendar']",
+        DAY: "[data-region='day']",
+        EVENT_ITEM: "[data-region='event-item']",
         EVENT_LINK: "[data-action='view-event']",
         NEW_EVENT_BUTTON: "[data-action='new-event-button']",
         DAY_CONTENT: "[data-region='day-content']",
         LOADING_ICON: '.loading-icon',
+        VIEW_DAY_LINK: "[data-action='view-day-link']"
     };
 
     /**
@@ -165,24 +168,30 @@ define([
      * updated.
      *
      * @param {event} e The calendar move event
-     * @param {object} eventElement The jQuery element with the event id
-     * @param {object} originElement The jQuery element for where the event is moving from
+     * @param {int} eventId The event id being moved
+     * @param {object|null} originElement The jQuery element for where the event is moving from
      * @param {object} destinationElement The jQuery element for where the event is moving to
      */
-    var handleMoveEvent = function(e, eventElement, originElement, destinationElement) {
-        var eventId = eventElement.attr('data-event-id');
-        var originTimestamp = originElement.attr('data-day-timestamp');
+    var handleMoveEvent = function(e, eventId, originElement, destinationElement) {
+        var originTimestamp = null;
         var destinationTimestamp = destinationElement.attr('data-day-timestamp');
 
+        if (originElement) {
+            originTimestamp = originElement.attr('data-day-timestamp');
+        }
+
         // If the event has actually changed day.
-        if (originTimestamp != destinationTimestamp) {
+        if (!originElement || originTimestamp != destinationTimestamp) {
             Templates.render('core/loading', {})
                 .then(function(html, js) {
                     // First we show some loading icons in each of the days being affected.
-                    originElement.find(SELECTORS.DAY_CONTENT).addClass('hidden');
                     destinationElement.find(SELECTORS.DAY_CONTENT).addClass('hidden');
-                    Templates.appendNodeContents(originElement, html, js);
                     Templates.appendNodeContents(destinationElement, html, js);
+
+                    if (originElement) {
+                        originElement.find(SELECTORS.DAY_CONTENT).addClass('hidden');
+                        Templates.appendNodeContents(originElement, html, js);
+                    }
                     return;
                 })
                 .then(function() {
@@ -192,19 +201,21 @@ define([
                 .then(function() {
                     // If the update was successful then broadcast an event letting the calendar
                     // know that an event has been moved.
-                    $('body').trigger(CalendarEvents.eventMoved, [eventElement, originElement, destinationElement]);
+                    $('body').trigger(CalendarEvents.eventMoved, [eventId, originElement, destinationElement]);
                     return;
                 })
                 .always(function() {
                     // Always remove the loading icons regardless of whether the update
                     // request was successful or not.
-                    var originLoadingElement = originElement.find(SELECTORS.LOADING_ICON);
                     var destinationLoadingElement = destinationElement.find(SELECTORS.LOADING_ICON);
-                    originElement.find(SELECTORS.DAY_CONTENT).removeClass('hidden');
                     destinationElement.find(SELECTORS.DAY_CONTENT).removeClass('hidden');
-
-                    Templates.replaceNode(originLoadingElement, '', '');
                     Templates.replaceNode(destinationLoadingElement, '', '');
+
+                    if (originElement) {
+                        var originLoadingElement = originElement.find(SELECTORS.LOADING_ICON);
+                        originElement.find(SELECTORS.DAY_CONTENT).removeClass('hidden');
+                        Templates.replaceNode(originLoadingElement, '', '');
+                    }
                     return;
                 })
                 .fail(Notification.exception);
@@ -246,13 +257,13 @@ define([
         var body = $('body');
 
         body.on(CalendarEvents.created, function() {
-            CalendarViewManager.reloadCurrentMonth();
+            CalendarViewManager.reloadCurrentMonth(root);
         });
         body.on(CalendarEvents.deleted, function() {
-            CalendarViewManager.reloadCurrentMonth();
+            CalendarViewManager.reloadCurrentMonth(root);
         });
         body.on(CalendarEvents.updated, function() {
-            CalendarViewManager.reloadCurrentMonth();
+            CalendarViewManager.reloadCurrentMonth(root);
         });
         body.on(CalendarEvents.editActionEvent, function(e, url) {
             // Action events needs to be edit directly on the course module.
@@ -262,7 +273,7 @@ define([
         body.on(CalendarEvents.moveEvent, handleMoveEvent);
         // When an event is successfully moved we should updated the UI.
         body.on(CalendarEvents.eventMoved, function() {
-            window.location.reload();
+            CalendarViewManager.reloadCurrentMonth(root);
         });
 
         eventFormModalPromise.then(function(modal) {
@@ -284,14 +295,42 @@ define([
         var root = $(SELECTORS.ROOT);
 
         // Bind click events to event links.
-        root.on('click', SELECTORS.EVENT_LINK, function(e) {
+        root.on('click', SELECTORS.EVENT_ITEM, function(e) {
             e.preventDefault();
-            var eventId = $(this).attr('data-event-id');
+            // We've handled the event so stop it from bubbling
+            // and causing the day click handler to fire.
+            e.stopPropagation();
+
+            var target = $(e.target);
+            var eventId = null;
+
+            if (target.is(SELECTORS.EVENT_LINK)) {
+                eventId = target.attr('data-event-id');
+            } else {
+                eventId = target.find(SELECTORS.EVENT_LINK).attr('data-event-id');
+            }
+
             renderEventSummaryModal(eventId);
         });
 
         var eventFormPromise = registerEventFormModal(root);
         registerCalendarEventListeners(root, eventFormPromise);
+
+        // Bind click events to calendar days.
+        root.on('click', SELECTORS.DAY, function(e) {
+            var target = $(e.target);
+
+            if (!target.is(SELECTORS.VIEW_DAY_LINK)) {
+                var startTime = $(this).attr('data-new-event-timestamp');
+                eventFormPromise.then(function(modal) {
+                    modal.setStartTime(startTime);
+                    modal.show();
+                    return;
+                });
+
+                e.preventDefault();
+            }
+        });
     };
 
     return {
diff --git a/calendar/amd/src/drag_drop_data_store.js b/calendar/amd/src/drag_drop_data_store.js
new file mode 100644 (file)
index 0000000..6414f8e
--- /dev/null
@@ -0,0 +1,95 @@
+// 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/>.
+
+/**
+ * A javascript module to store calendar drag and drop data.
+ *
+ * This module is unfortunately required because of the limitations
+ * of the HTML5 drag and drop API and it's ability to provide data
+ * between the different stages of the drag/drop lifecycle.
+ *
+ * @module     core_calendar/drag_drop_data_store
+ * @package    core_calendar
+ * @copyright  2017 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([], function() {
+    /* @var {int|null} eventId The id of the event being dragged */
+    var eventId = null;
+    /* @var {int|null} durationDays How many days the event spans */
+    var durationDays = null;
+
+    /**
+     * Store the id of the event being dragged.
+     *
+     * @param {int} id The event id
+     */
+    var setEventId = function(id) {
+        eventId = id;
+    };
+
+    /**
+     * Get the stored event id.
+     *
+     * @return {int|null}
+     */
+    var getEventId = function() {
+        return eventId;
+    };
+
+    /**
+     * Check if the store has an event id.
+     *
+     * @return {bool}
+     */
+    var hasEventId = function() {
+        return eventId !== null;
+    };
+
+    /**
+     * Store the duration (in days) of the event being dragged.
+     *
+     * @param {int} days Number of days the event spans
+     */
+    var setDurationDays = function(days) {
+        durationDays = days;
+    };
+
+    /**
+     * Get the stored number of days.
+     *
+     * @return {int|null}
+     */
+    var getDurationDays = function() {
+        return durationDays;
+    };
+
+    /**
+     * Reset all of the stored values.
+     */
+    var clearAll = function() {
+        setEventId(null);
+        setDurationDays(null);
+    };
+
+    return {
+        setEventId: setEventId,
+        getEventId: getEventId,
+        hasEventId: hasEventId,
+        setDurationDays: setDurationDays,
+        getDurationDays: getDurationDays,
+        clearAll: clearAll
+    };
+});
index 0a4e33e..e6abfd8 100644 (file)
@@ -232,7 +232,7 @@ define(['jquery', 'core/templates'], function($, Templates) {
                     element.removeClass('hidden');
                     element.prop('disabled', false);
 
-                    if (selectedIndex === null) {
+                    if (selectedIndex === null || element.attr('selected')) {
                         selectedIndex = index;
                     }
                 } else {
index bad5ca4..cee039a 100644 (file)
@@ -66,6 +66,7 @@ define([
     var ModalEventForm = function(root) {
         Modal.call(this, root);
         this.eventId = null;
+        this.startTime = null;
         this.reloadingBody = false;
         this.reloadingTitle = false;
         this.saveButton = this.getFooter().find(SELECTORS.SAVE_BUTTON);
@@ -106,6 +107,36 @@ define([
         return this.eventId !== null;
     };
 
+    /**
+     * Set the start time to the given value.
+     *
+     * @method setStartTime
+     * @param {int} time The start time
+     */
+    ModalEventForm.prototype.setStartTime = function(time) {
+        this.startTime = time;
+    };
+
+    /**
+     * Retrieve the current start time, if any.
+     *
+     * @method getStartTime
+     * @return {int|null} The start time
+     */
+    ModalEventForm.prototype.getStartTime = function() {
+        return this.startTime;
+    };
+
+    /**
+     * Check if the modal has start time.
+     *
+     * @method hasStartTime
+     * @return {bool}
+     */
+    ModalEventForm.prototype.hasStartTime = function() {
+        return this.startTime !== null;
+    };
+
     /**
      * Get the form element from the modal.
      *
@@ -244,6 +275,10 @@ define([
             args.eventid = this.getEventId();
         }
 
+        if (this.hasStartTime()) {
+            args.starttime = this.getStartTime();
+        }
+
         if (typeof formData !== 'undefined') {
             args.formdata = formData;
         }
@@ -305,6 +340,7 @@ define([
     ModalEventForm.prototype.hide = function() {
         Modal.prototype.hide.call(this);
         this.setEventId(null);
+        this.setStartTime(null);
     };
 
     /**
diff --git a/calendar/amd/src/month_navigation_drag_drop.js b/calendar/amd/src/month_navigation_drag_drop.js
new file mode 100644 (file)
index 0000000..0e192df
--- /dev/null
@@ -0,0 +1,221 @@
+// 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/>.
+
+/**
+ * A javascript module to handle calendar drag and drop in the calendar
+ * month view navigation.
+ *
+ * This code is run each time the calendar month view is re-rendered. We
+ * only register the event handlers once per page load so that the in place
+ * DOM updates that happen on month change don't continue to register handlers.
+ *
+ * @module     core_calendar/month_navigation_drag_drop
+ * @class      month_navigation_drag_drop
+ * @package    core_calendar
+ * @copyright  2017 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([
+            'jquery',
+            'core_calendar/drag_drop_data_store',
+        ],
+        function(
+            $,
+            DataStore
+        ) {
+
+    var SELECTORS = {
+        DRAGGABLE: '[draggable="true"][data-region="event-item"]',
+        DROP_ZONE: '[data-drop-zone="nav-link"]',
+    };
+    var HOVER_CLASS = 'bg-primary text-white';
+    var TARGET_CLASS = 'drop-target';
+    var HOVER_TIME = 1000; // 1 second hover to change month.
+
+    // We store some static variables at the module level because this
+    // module is called each time the calendar month view is reloaded but
+    // we want some actions to only occur ones.
+
+    /* @var {bool} registered If the event listeners have been added */
+    var registered = false;
+    /* @var {int} hoverTimer The timeout id of any timeout waiting for hover */
+    var hoverTimer = null;
+    /* @var {object} root The root nav element we're operating on */
+    var root = null;
+
+    /**
+     * Add or remove the appropriate styling to indicate whether
+     * the drop target is being hovered over.
+     *
+     * @param {object} target The target drop zone element
+     * @param {bool} hovered If the element is hovered over ot not
+     */
+    var updateHoverState = function(target, hovered) {
+        if (hovered) {
+            target.addClass(HOVER_CLASS);
+        } else {
+            target.removeClass(HOVER_CLASS);
+        }
+    };
+
+    /**
+     * Add some styling to the UI to indicate that the nav links
+     * are an acceptable drop target.
+     */
+    var addDropZoneIndicator = function() {
+        root.find(SELECTORS.DROP_ZONE).addClass(TARGET_CLASS);
+    };
+
+    /**
+     * Remove the styling from the nav links.
+     */
+    var removeDropZoneIndicator = function() {
+        root.find(SELECTORS.DROP_ZONE).removeClass(TARGET_CLASS);
+    };
+
+    /**
+     * Get the drop zone target from the event, if one is found.
+     *
+     * @param {event} e Javascript event
+     * @return {object|null}
+     */
+    var getTargetFromEvent = function(e) {
+        var target = $(e.target).closest(SELECTORS.DROP_ZONE);
+        return (target.length) ? target : null;
+    };
+
+    /**
+     * This will add a visual indicator to the calendar UI to
+     * indicate which nav link is a valid drop zone.
+     */
+    var dragstartHandler = function(e) {
+        // Make sure the drag event is for a calendar event.
+        var eventElement = $(e.target).closest(SELECTORS.DRAGGABLE);
+
+        if (eventElement.length) {
+            addDropZoneIndicator();
+        }
+    };
+
+    /**
+     * Update the hover state of the target nav element when
+     * the user is dragging an event over it.
+     *
+     * This will add a visual indicator to the calendar UI to
+     * indicate which nav link is being hovered.
+     *
+     * @param {event} e The dragover event
+     */
+    var dragoverHandler = function(e) {
+        e.preventDefault();
+        var target = getTargetFromEvent(e);
+
+        if (!target) {
+            return;
+        }
+
+        // If we're not draggin a calendar event then
+        // ignore it.
+        if (!DataStore.hasEventId()) {
+            return;
+        }
+
+        if (!hoverTimer) {
+            hoverTimer = setTimeout(function() {
+                target.click();
+                hoverTimer = null;
+            }, HOVER_TIME);
+        }
+
+        updateHoverState(target, true);
+        removeDropZoneIndicator();
+    };
+
+    /**
+     * Update the hover state of the target nav element that was
+     * previously dragged over but has is no longer a drag target.
+     *
+     * This will remove the visual indicator from the calendar UI
+     * that was added by the dragoverHandler.
+     *
+     * @param {event} e The dragstart event
+     */
+    var dragleaveHandler = function(e) {
+        var target = getTargetFromEvent(e);
+
+        if (!target) {
+            return;
+        }
+
+        if (hoverTimer) {
+            clearTimeout(hoverTimer);
+            hoverTimer = null;
+        }
+
+        updateHoverState(target, false);
+        addDropZoneIndicator();
+        e.preventDefault();
+    };
+
+    /**
+     * Remove the visual indicator from the calendar UI that was
+     * added by the dragoverHandler.
+     *
+     * @param {event} e The drop event
+     */
+    var dropHandler = function(e) {
+        removeDropZoneIndicator();
+        var target = getTargetFromEvent(e);
+
+        if (!target) {
+            return;
+        }
+
+        updateHoverState(target, false);
+        e.preventDefault();
+    };
+
+    return {
+        /**
+         * Initialise the event handlers for the drag events.
+         *
+         * @param {object} rootElement The element containing calendar nav links
+         */
+        init: function(rootElement) {
+            // Only register the handlers once on the first load.
+            if (!registered) {
+                // These handlers are only added the first time the module
+                // is loaded because we don't want to have a new listener
+                // added each time the "init" function is called otherwise we'll
+                // end up with lots of stale handlers.
+                document.addEventListener('dragstart', dragstartHandler, false);
+                document.addEventListener('dragover', dragoverHandler, false);
+                document.addEventListener('dragleave', dragleaveHandler, false);
+                document.addEventListener('drop', dropHandler, false);
+                document.addEventListener('dragend', removeDropZoneIndicator, false);
+                registered = true;
+            }
+
+            // Update the module variable to operate on the given
+            // root element.
+            root = $(rootElement);
+
+            // If we're currently dragging then add the indicators.
+            if (DataStore.hasEventId()) {
+                addDropZoneIndicator();
+            }
+        },
+    };
+});
similarity index 64%
rename from calendar/amd/src/drag_drop.js
rename to calendar/amd/src/month_view_drag_drop.js
index de085f0..05b3733 100644 (file)
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * A javascript module to handle calendar drag and drop. This module
- * unfortunately requires some state to be maintained because of the
- * limitations of the HTML5 drag and drop API which means it can't
- * be used multiple times with the current implementation.
+ * A javascript module to handle calendar drag and drop in the calendar
+ * month view.
  *
- * @module     core_calendar/drag_drop
- * @class      drag_drop
+ * @module     core_calendar/month_view_drag_drop
+ * @class      month_view_drag_drop
  * @package    core_calendar
  * @copyright  2017 Ryan Wyllie <ryan@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 define([
             'jquery',
-            'core_calendar/events'
+            'core_calendar/events',
+            'core_calendar/drag_drop_data_store'
         ],
         function(
             $,
-            CalendarEvents
+            CalendarEvents,
+            DataStore
         ) {
 
     var SELECTORS = {
         ROOT: "[data-region='calendar']",
-        DRAGGABLE: '[draggable="true"]',
-        DROP_ZONE: '[data-drop-zone="true"]',
+        DRAGGABLE: '[draggable="true"][data-region="event-item"]',
+        DROP_ZONE: '[data-drop-zone="month-view-day"]',
         WEEK: '[data-region="month-view-week"]',
     };
-    var HOVER_CLASS = 'bg-primary';
+    var HOVER_CLASS = 'bg-primary text-white';
+    /* @var {bool} registered If the event listeners have been added */
+    var registered = false;
 
-    // Unfortunately we are required to maintain some module
-    // level state due to the limitations of the HTML5 drag
-    // and drop API. Specifically the inability to pass data
-    // between the dragstate and dragover events handlers
-    // using the DataTransfer object in the event.
-
-    /** @var int eventId The event id being moved. */
-    var eventId = null;
-    /** @var int duration The number of days the event spans */
-    var duration = null;
+    /**
+     * Get the correct drop zone element from the given javascript
+     * event.
+     *
+     * @param {event} e The javascript event
+     * @return {object|null}
+     */
+    var getDropZoneFromEvent = function(e) {
+        var dropZone = $(e.target).closest(SELECTORS.DROP_ZONE);
+        return (dropZone.length) ? dropZone : null;
+    };
 
     /**
      * Update the hover state for the event in the calendar to reflect
@@ -70,11 +73,10 @@ define([
      * @param {bool} hovered If the target is hovered or not
      * @param {int} count How many days to highlight (default to duration)
      */
-    var updateHoverState = function(target, hovered, count) {
-        var dropZone = $(target).closest(SELECTORS.DROP_ZONE);
+    var updateHoverState = function(dropZone, hovered, count) {
         if (typeof count === 'undefined') {
             // This is how many days we need to highlight.
-            count = duration;
+            count = DataStore.getDurationDays();
         }
 
         if (hovered) {
@@ -115,16 +117,20 @@ define([
      * @param {event} e The dragstart event
      */
     var dragstartHandler = function(e) {
-        var eventElement = $(e.target);
+        var eventElement = $(e.target).closest(SELECTORS.DRAGGABLE);
 
-        if (!eventElement.is('[data-event-id]')) {
-            eventElement = eventElement.find('[data-event-id]');
+        if (!eventElement.length) {
+            return;
         }
 
-        eventId = eventElement.attr('data-event-id');
+        eventElement = eventElement.find('[data-event-id]');
 
+        var eventId = eventElement.attr('data-event-id');
         var eventsSelector = SELECTORS.ROOT + ' [data-event-id="' + eventId + '"]';
-        duration = $(eventsSelector).length;
+        var duration = $(eventsSelector).length;
+
+        DataStore.setEventId(eventId);
+        DataStore.setDurationDays(duration);
 
         e.dataTransfer.effectAllowed = "move";
         e.dataTransfer.dropEffect = "move";
@@ -145,7 +151,14 @@ define([
      */
     var dragoverHandler = function(e) {
         e.preventDefault();
-        updateHoverState(e.target, true);
+
+        var dropZone = getDropZoneFromEvent(e);
+
+        if (!dropZone) {
+            return;
+        }
+
+        updateHoverState(dropZone, true);
     };
 
     /**
@@ -158,8 +171,14 @@ define([
      * @param {event} e The dragstart event
      */
     var dragleaveHandler = function(e) {
+        var dropZone = getDropZoneFromEvent(e);
+
+        if (!dropZone) {
+            return;
+        }
+
+        updateHoverState(dropZone, false);
         e.preventDefault();
-        updateHoverState(e.target, false);
     };
 
     /**
@@ -174,35 +193,46 @@ define([
      * @param {event} e The dragstart event
      */
     var dropHandler = function(e) {
-        e.preventDefault();
+        var dropZone = getDropZoneFromEvent(e);
+
+        if (!dropZone) {
+            DataStore.clearAll();
+            return;
+        }
 
+        var eventId = DataStore.getEventId();
         var eventElementSelector = SELECTORS.ROOT + ' [data-event-id="' + eventId + '"]';
         var eventElement = $(eventElementSelector);
-        var origin = eventElement.closest(SELECTORS.DROP_ZONE);
+        var origin = null;
         var destination = $(e.target).closest(SELECTORS.DROP_ZONE);
 
-        updateHoverState(e.target, false);
-        $('body').trigger(CalendarEvents.moveEvent, [eventElement, origin, destination]);
+        if (eventElement.length) {
+            origin = eventElement.closest(SELECTORS.DROP_ZONE);
+        }
+
+        updateHoverState(dropZone, false);
+        $('body').trigger(CalendarEvents.moveEvent, [eventId, origin, destination]);
+        DataStore.clearAll();
+
+        e.preventDefault();
     };
 
     return {
         /**
          * Initialise the event handlers for the drag events.
-         *
-         * @param {object} root The root calendar element that containers the drag drop elements
          */
-        init: function(root) {
-            root = $(root);
-
-            root.find(SELECTORS.DRAGGABLE).each(function(index, element) {
-                element.addEventListener('dragstart', dragstartHandler, true);
-            });
-
-            root.find(SELECTORS.DROP_ZONE).each(function(index, element) {
-                element.addEventListener('dragover', dragoverHandler, true);
-                element.addEventListener('dragleave', dragleaveHandler, true);
-                element.addEventListener('drop', dropHandler, true);
-            });
+        init: function() {
+            if (!registered) {
+                // These handlers are only added the first time the module
+                // is loaded because we don't want to have a new listener
+                // added each time the "init" function is called otherwise we'll
+                // end up with lots of stale handlers.
+                document.addEventListener('dragstart', dragstartHandler, false);
+                document.addEventListener('dragover', dragoverHandler, false);
+                document.addEventListener('dragleave', dragleaveHandler, false);
+                document.addEventListener('drop', dropHandler, false);
+                registered = true;
+            }
         },
     };
 });
index eccb0a3..836b011 100644 (file)
@@ -26,8 +26,9 @@ define(['jquery', 'core/templates', 'core/notification', 'core_calendar/reposito
 
         var SELECTORS = {
             ROOT: "[data-region='calendar']",
-            CALENDAR_NAV_LINK: "span.calendarwrapper .arrow_link",
+            CALENDAR_NAV_LINK: ".calendarwrapper .arrow_link",
             CALENDAR_MONTH_WRAPPER: ".calendarwrapper",
+            LOADING_ICON_CONTAINER: '[data-region="overlay-icon-container"]'
         };
 
         /**
@@ -55,6 +56,10 @@ define(['jquery', 'core/templates', 'core/notification', 'core_calendar/reposito
          * @return {promise}
          */
         var refreshMonthContent = function(time, courseid) {
+            var root = $(SELECTORS.ROOT);
+
+            startLoading(root);
+
             return CalendarRepository.getCalendarMonthData(time, courseid)
                 .then(function(context) {
                     return Templates.render('core_calendar/month_detailed', context);
@@ -62,6 +67,9 @@ define(['jquery', 'core/templates', 'core/notification', 'core_calendar/reposito
                 .then(function(html, js) {
                     return Templates.replaceNode(SELECTORS.CALENDAR_MONTH_WRAPPER, html, js);
                 })
+                .always(function() {
+                    return stopLoading(root);
+                })
                 .fail(Notification.exception);
         };
 
@@ -90,14 +98,37 @@ define(['jquery', 'core/templates', 'core/notification', 'core_calendar/reposito
          *
          * @return {promise}
          */
-        var reloadCurrentMonth = function() {
-            var root = $(SELECTORS.ROOT),
-                courseid = root.find(SELECTORS.CALENDAR_MONTH_WRAPPER).data('courseid'),
+        var reloadCurrentMonth = function(root) {
+            var courseid = root.find(SELECTORS.CALENDAR_MONTH_WRAPPER).data('courseid'),
                 time = root.find(SELECTORS.CALENDAR_MONTH_WRAPPER).data('current-time');
 
             return refreshMonthContent(time, courseid);
         };
 
+        /**
+         * Set the element state to loading.
+         *
+         * @param {object} root The container element
+         * @method startLoading
+         */
+        var startLoading = function(root) {
+            var loadingIconContainer = root.find(SELECTORS.LOADING_ICON_CONTAINER);
+
+            loadingIconContainer.removeClass('hidden');
+        };
+
+        /**
+         * Remove the loading state from the element.
+         *
+         * @param {object} root The container element
+         * @method stopLoading
+         */
+        var stopLoading = function(root) {
+            var loadingIconContainer = root.find(SELECTORS.LOADING_ICON_CONTAINER);
+
+            loadingIconContainer.addClass('hidden');
+        };
+
         return {
             init: function() {
                 registerEventListeners(SELECTORS.ROOT);
index 5cac152..4a713bc 100644 (file)
@@ -82,6 +82,9 @@ class day_exporter extends exporter {
             'timestamp' => [
                 'type' => PARAM_INT,
             ],
+            'neweventtimestamp' => [
+                'type' => PARAM_INT,
+            ],
             'istoday' => [
                 'type' => PARAM_BOOL,
                 'default' => false,
@@ -108,13 +111,26 @@ class day_exporter extends exporter {
      * @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' => $this->data[0],
+            'timestamp' => $timestamp,
+            'neweventtimestamp' => $neweventstarttime->getTimestamp()
         ];
 
         $url = new moodle_url('/calendar/view.php', [
                 'view' => 'day',
-                'time' => $this->data[0],
+                'time' => $timestamp,
             ]);
         $return['viewdaylink'] = $url->out(false);
 
index 3e8ed78..e6c025f 100644 (file)
@@ -42,8 +42,13 @@ class create extends \moodleform {
 
         $mform = $this->_form;
         $haserror = !empty($this->_customdata['haserror']);
+        $starttime = isset($this->_customdata['starttime']) ? $this->_customdata['starttime'] : 0;
         $eventtypes = calendar_get_all_allowed_types();
 
+        if (empty($eventtypes)) {
+            print_error('nopermissiontoupdatecalendar');
+        }
+
         $mform->setDisableShortforms();
         $mform->disable_form_change_checker();
 
@@ -58,7 +63,7 @@ class create extends \moodleform {
         $mform->setType('name', PARAM_TEXT);
 
         // Event time start field.
-        $mform->addElement('date_time_selector', 'timestart', get_string('date'));
+        $mform->addElement('date_time_selector', 'timestart', get_string('date'), ['defaulttime' => $starttime]);
 
         // Add the select elements for the available event types.
         $this->add_event_type_elements($mform, $eventtypes);
@@ -93,6 +98,12 @@ class create extends \moodleform {
 
         $errors = parent::validation($data, $files);
         $coursekey = isset($data['groupcourseid']) ? 'groupcourseid' : 'courseid';
+        $eventtypes = calendar_get_all_allowed_types();
+        $eventtype = isset($data['eventtype']) ? $data['eventtype'] : null;
+
+        if (empty($eventtype) || !isset($eventtypes[$eventtype])) {
+            $errors['eventtype'] = get_string('invalideventtype', 'calendar');
+        }
 
         if (isset($data[$coursekey]) && $data[$coursekey] > 0) {
             if ($course = $DB->get_record('course', ['id' => $data[$coursekey]])) {
index bedcccc..b0bb691 100644 (file)
@@ -90,7 +90,11 @@ class create_update_form_mapper implements create_update_form_mapper_interface {
 
         // Default course id if none is set.
         if (!isset($properties->courseid)) {
-            $properties->courseid = 0;
+            if ($properties->eventtype === 'site') {
+                $properties->courseid = SITEID;
+            } else {
+                $properties->courseid = 0;
+            }
         }
 
         // Decode the form fields back into valid event property.
index 30cbaa4..3ad809b 100644 (file)
@@ -2175,6 +2175,7 @@ function calendar_get_link_previous($text, $linkbase, $d, $m, $y, $accesshide =
 
     $attrs = [
         'data-time' => calendar_get_timestamp($d, $m, $y, $time),
+        'data-drop-zone' => 'nav-link',
     ];
 
     return link_arrow_left($text, $href->out(false), $accesshide, 'previous', $attrs);
@@ -2202,6 +2203,7 @@ function calendar_get_link_next($text, $linkbase, $d, $m, $y, $accesshide = fals
 
     $attrs = [
         'data-time' => calendar_get_timestamp($d, $m, $y, $time),
+        'data-drop-zone' => 'nav-link',
     ];
 
     return link_arrow_right($text, $href->out(false), $accesshide, 'next', $attrs);
@@ -3534,6 +3536,7 @@ function calendar_output_fragment_event_form($args) {
     $html = '';
     $data = null;
     $eventid = isset($args['eventid']) ? clean_param($args['eventid'], PARAM_INT) : null;
+    $starttime = isset($args['starttime']) ? clean_param($args['starttime'], PARAM_INT) : null;
     $event = null;
     $hasformdata = isset($args['formdata']) && !empty($args['formdata']);
     $formoptions = [];
@@ -3546,6 +3549,10 @@ function calendar_output_fragment_event_form($args) {
         $formoptions['haserror'] = clean_param($args['haserror'], PARAM_BOOL);
     }
 
+    if ($starttime) {
+        $formoptions['starttime'] = $starttime;
+    }
+
     if (is_null($eventid)) {
         $mform = new \core_calendar\local\event\forms\create(
             null,
index 6e7cab8..c5b8531 100644 (file)
@@ -389,7 +389,7 @@ class core_calendar_renderer extends plugin_renderer_base {
         $courseurl = new moodle_url($returnurl);
         $courseurl->remove_params('course');
         $select = new single_select($courseurl, 'course', $courseoptions, $selected, null);
-        $select->class = 'cal_courses_flt m-r-1';
+        $select->class = 'm-r-1';
         if ($label !== null) {
             $select->set_label($label);
         } else {
index 19b12ff..80005dc 100644 (file)
     {
     }
 }}
-<span id="month-detailed-{{uniqid}}" class="calendarwrapper" data-courseid="{{courseid}}" data-current-time="{{time}}">
+<div class="calendarwrapper" data-courseid="{{courseid}}" data-current-time="{{time}}">
     {{> core_calendar/month_header }}
     {{> core_calendar/month_navigation }}
-    <table class="calendarmonth calendartable card-deck m-b-0">
+    {{> core/overlay_loading}}
+    <table id="month-detailed-{{uniqid}}" class="calendarmonth calendartable card-deck m-b-0">
         <thead>
             <tr>
                 {{# daynames }}
                     <td class="dayblank">&nbsp;</td>
                 {{/prepadding}}
                 {{#days}}
-                    <td class="day text-sm-center text-md-left{{!
+                    <td class="clickable day text-sm-center text-md-left{{!
                             }}{{#istoday}} today{{/istoday}}{{!
                             }}{{#isweekend}} weekend{{/isweekend}}{{!
                             }}{{#durationevents.0}} duration{{/durationevents.0}}{{!
                             }}{{#durationevents}} duration_{{.}}{{/durationevents}}{{!
                         }}"
                         data-day-timestamp="{{timestamp}}"
-                        data-drop-zone="true">
+                        data-drop-zone="month-view-day"
+                        data-region="day"
+                        data-new-event-timestamp="{{neweventtimestamp}}">
                         <div class="hidden-sm-down text-xs-center">
                             {{#events.0}}
-                                <a href="{{viewdaylink}}" class="day" title="{{viewdaylinktitle}}">{{mday}}</a>
+                                <a data-action="view-day-link" href="{{viewdaylink}}" class="day" title="{{viewdaylinktitle}}">{{mday}}</a>
                             {{/events.0}}
                             {{^events.0}}
                                 {{mday}}
@@ -75,6 +78,7 @@
                                         {{/underway}}
                                         {{^underway}}
                                             <li class="calendar_event_{{eventtype}}"
+                                                data-region="event-item"
                                                 {{#canedit}}
                                                     draggable="true"
                                                     data-drag-type="move"
     {{/weeks}}
         </tbody>
     </table>
-</span>
+</div>
 {{#js}}
-require(['jquery', 'core_calendar/drag_drop'], function($, DragDrop) {
+require(['jquery', 'core_calendar/month_view_drag_drop'], function($, DragDrop) {
     var root = $('#month-detailed-{{uniqid}}');
     DragDrop.init(root);
 });
index d7d3509..f0f67dd 100644 (file)
     }
 }}
 {{#navigation}}
-<div class="controls" data-view="{{view}}">
+<div id="month-navigation-{{uniqid}}" class="controls" data-view="{{view}}">
     {{{navigation}}}
 </div>
 {{/navigation}}
+{{#js}}
+require(['jquery', 'core_calendar/month_navigation_drag_drop'], function($, DragDrop) {
+    var root = $('#month-navigation-{{uniqid}}');
+    DragDrop.init(root);
+});
+{{/js}}
index 8098f72..4537acf 100644 (file)
@@ -1414,4 +1414,785 @@ class core_calendar_externallib_testcase extends externallib_advanced_testcase {
             $result
         );
     }
+
+    /**
+     * Submit a request where the time duration until is earlier than the time
+     * start in order to get a validation error from the server.
+     */
+    public function test_submit_create_update_form_validation_error() {
+        $user = $this->getDataGenerator()->create_user();
+        $timestart = new DateTime();
+        $interval = new DateInterval("P1D"); // One day.
+        $timedurationuntil = new DateTime();
+        $timedurationuntil->sub($interval);
+        $formdata = [
+            'id' => 0,
+            'userid' => $user->id,
+            'modulename' => '',
+            'instance' => 0,
+            'visible' => 1,
+            'name' => 'Test',
+            'timestart' => [
+                'day' => $timestart->format('j'),
+                'month' => $timestart->format('n'),
+                'year' => $timestart->format('Y'),
+                'hour' => $timestart->format('G'),
+                'minute' => 0,
+            ],
+            'eventtype' => 'user',
+            'description' => [
+                'text' => '',
+                'format' => 1,
+            ],
+            'duration' => 1,
+            'timedurationuntil' => [
+                'day' => $timedurationuntil->format('j'),
+                'month' => $timedurationuntil->format('n'),
+                'year' => $timedurationuntil->format('Y'),
+                'hour' => $timedurationuntil->format('G'),
+                'minute' => 0,
+            ]
+        ];
+
+        $formdata = \core_calendar\local\event\forms\create::mock_generate_submit_keys($formdata);
+
+        $querystring = http_build_query($formdata, '', '&amp;');
+
+        $this->resetAfterTest(true);
+        $this->setUser($user);
+
+        $result = external_api::clean_returnvalue(
+            core_calendar_external::submit_create_update_form_returns(),
+            core_calendar_external::submit_create_update_form($querystring)
+        );
+
+        $this->assertTrue($result['validationerror']);
+    }
+
+    /**
+     * A user with the moodle/calendar:manageownentries capability at the
+     * system context should be able to create a user event.
+     */
+    public function test_submit_create_update_form_create_user_event() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $roleid = $generator->create_role();
+        $context = \context_system::instance();
+        $timestart = new DateTime();
+        $interval = new DateInterval("P1D"); // One day.
+        $timedurationuntil = new DateTime();
+        $timedurationuntil->add($interval);
+        $formdata = [
+            'id' => 0,
+            'userid' => $user->id,
+            'modulename' => '',
+            'instance' => 0,
+            'visible' => 1,
+            'name' => 'Test',
+            'timestart' => [
+                'day' => $timestart->format('j'),
+                'month' => $timestart->format('n'),
+                'year' => $timestart->format('Y'),
+                'hour' => $timestart->format('G'),
+                'minute' => 0,
+            ],
+            'eventtype' => 'user',
+            'description' => [
+                'text' => '',
+                'format' => 1,
+            ],
+            'duration' => 1,
+            'timedurationuntil' => [
+                'day' => $timedurationuntil->format('j'),
+                'month' => $timedurationuntil->format('n'),
+                'year' => $timedurationuntil->format('Y'),
+                'hour' => $timedurationuntil->format('G'),
+                'minute' => 0,
+            ]
+        ];
+
+        $formdata = \core_calendar\local\event\forms\create::mock_generate_submit_keys($formdata);
+        $querystring = http_build_query($formdata, '', '&');
+
+        $generator->role_assign($roleid, $user->id, $context->id);
+        assign_capability('moodle/calendar:manageownentries', CAP_ALLOW, $roleid, $context, true);
+
+        $user->ignoresesskey = true;
+        $this->resetAfterTest(true);
+        $this->setUser($user);
+
+        $result = external_api::clean_returnvalue(
+            core_calendar_external::submit_create_update_form_returns(),
+            core_calendar_external::submit_create_update_form($querystring)
+        );
+
+        $event = $result['event'];
+        $this->assertEquals($user->id, $event['userid']);
+        $this->assertEquals($formdata['eventtype'], $event['eventtype']);
+        $this->assertEquals($formdata['name'], $event['name']);
+    }
+
+    /**
+     * A user without the moodle/calendar:manageownentries capability at the
+     * system context should not be able to create a user event.
+     */
+    public function test_submit_create_update_form_create_user_event_no_permission() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $roleid = $generator->create_role();
+        $context = \context_system::instance();
+        $timestart = new DateTime();
+        $interval = new DateInterval("P1D"); // One day.
+        $timedurationuntil = new DateTime();
+        $timedurationuntil->add($interval);
+        $formdata = [
+            'id' => 0,
+            'userid' => $user->id,
+            'modulename' => '',
+            'instance' => 0,
+            'visible' => 1,
+            'name' => 'Test',
+            'timestart' => [
+                'day' => $timestart->format('j'),
+                'month' => $timestart->format('n'),
+                'year' => $timestart->format('Y'),
+                'hour' => $timestart->format('G'),
+                'minute' => 0,
+            ],
+            'eventtype' => 'user',
+            'description' => [
+                'text' => '',
+                'format' => 1,
+            ],
+            'duration' => 1,
+            'timedurationuntil' => [
+                'day' => $timedurationuntil->format('j'),
+                'month' => $timedurationuntil->format('n'),
+                'year' => $timedurationuntil->format('Y'),
+                'hour' => $timedurationuntil->format('G'),
+                'minute' => 0,
+            ]
+        ];
+
+        $formdata = \core_calendar\local\event\forms\create::mock_generate_submit_keys($formdata);
+        $querystring = http_build_query($formdata, '', '&');
+
+        $generator->role_assign($roleid, $user->id, $context->id);
+        assign_capability('moodle/calendar:manageownentries', CAP_PROHIBIT, $roleid, $context, true);
+
+        $user->ignoresesskey = true;
+        $this->resetAfterTest(true);
+        $this->setUser($user);
+
+        $this->expectException('moodle_exception');
+
+        external_api::clean_returnvalue(
+            core_calendar_external::submit_create_update_form_returns(),
+            core_calendar_external::submit_create_update_form($querystring)
+        );
+    }
+
+    /**
+     * A user with the moodle/calendar:manageentries capability at the
+     * site course context should be able to create a site event.
+     */
+    public function test_submit_create_update_form_create_site_event() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $context = context_course::instance(SITEID);
+        $roleid = $generator->create_role();
+        $timestart = new DateTime();
+        $interval = new DateInterval("P1D"); // One day.
+        $timedurationuntil = new DateTime();
+        $timedurationuntil->add($interval);
+        $formdata = [
+            'id' => 0,
+            'userid' => $user->id,
+            'modulename' => '',
+            'instance' => 0,
+            'visible' => 1,
+            'name' => 'Test',
+            'timestart' => [
+                'day' => $timestart->format('j'),
+                'month' => $timestart->format('n'),
+                'year' => $timestart->format('Y'),
+                'hour' => $timestart->format('G'),
+                'minute' => 0,
+            ],
+            'eventtype' => 'site',
+            'description' => [
+                'text' => '',
+                'format' => 1,
+            ],
+            'duration' => 1,
+            'timedurationuntil' => [
+                'day' => $timedurationuntil->format('j'),
+                'month' => $timedurationuntil->format('n'),
+                'year' => $timedurationuntil->format('Y'),
+                'hour' => $timedurationuntil->format('G'),
+                'minute' => 0,
+            ]
+        ];
+
+        $formdata = \core_calendar\local\event\forms\create::mock_generate_submit_keys($formdata);
+        $querystring = http_build_query($formdata, '', '&');
+
+        $generator->role_assign($roleid, $user->id, $context->id);
+
+        assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $context, true);
+
+        $user->ignoresesskey = true;
+        $this->resetAfterTest(true);
+        $this->setUser($user);
+
+        $result = external_api::clean_returnvalue(
+            core_calendar_external::submit_create_update_form_returns(),
+            core_calendar_external::submit_create_update_form($querystring)
+        );
+
+        $event = $result['event'];
+        $this->assertEquals($user->id, $event['userid']);
+        $this->assertEquals($formdata['eventtype'], $event['eventtype']);
+        $this->assertEquals($formdata['name'], $event['name']);
+    }
+
+    /**
+     * A user without the moodle/calendar:manageentries capability at the
+     * site course context should not be able to create a site event.
+     */
+    public function test_submit_create_update_form_create_site_event_no_permission() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $context = context_course::instance(SITEID);
+        $roleid = $generator->create_role();
+        $timestart = new DateTime();
+        $interval = new DateInterval("P1D"); // One day.
+        $timedurationuntil = new DateTime();
+        $timedurationuntil->add($interval);
+        $formdata = [
+            'id' => 0,
+            'userid' => $user->id,
+            'modulename' => '',
+            'instance' => 0,
+            'visible' => 1,
+            'name' => 'Test',
+            'timestart' => [
+                'day' => $timestart->format('j'),
+                'month' => $timestart->format('n'),
+                'year' => $timestart->format('Y'),
+                'hour' => $timestart->format('G'),
+                'minute' => 0,
+            ],
+            'eventtype' => 'site',
+            'description' => [
+                'text' => '',
+                'format' => 1,
+            ],
+            'duration' => 1,
+            'timedurationuntil' => [
+                'day' => $timedurationuntil->format('j'),
+                'month' => $timedurationuntil->format('n'),
+                'year' => $timedurationuntil->format('Y'),
+                'hour' => $timedurationuntil->format('G'),
+                'minute' => 0,
+            ]
+        ];
+
+        $formdata = \core_calendar\local\event\forms\create::mock_generate_submit_keys($formdata);
+        $querystring = http_build_query($formdata, '', '&');
+
+        $generator->role_assign($roleid, $user->id, $context->id);
+
+        assign_capability('moodle/calendar:manageentries', CAP_PROHIBIT, $roleid, $context, true);
+
+        $user->ignoresesskey = true;
+        $this->resetAfterTest(true);
+        $this->setUser($user);
+
+        $result = external_api::clean_returnvalue(
+            core_calendar_external::submit_create_update_form_returns(),
+            core_calendar_external::submit_create_update_form($querystring)
+        );
+
+        $this->assertTrue($result['validationerror']);
+    }
+
+    /**
+     * A user that has the moodle/calendar:manageentries in a course that they
+     * are enrolled in should be able to create a course event in that course.
+     */
+    public function test_submit_create_update_form_create_course_event() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $course = $generator->create_course();
+        $context = context_course::instance($course->id);
+        $roleid = $generator->create_role();
+        $timestart = new DateTime();
+        $interval = new DateInterval("P1D"); // One day.
+        $timedurationuntil = new DateTime();
+        $timedurationuntil->add($interval);
+        $formdata = [
+            'id' => 0,
+            'userid' => $user->id,
+            'modulename' => '',
+            'instance' => 0,
+            'visible' => 1,
+            'name' => 'Test',
+            'timestart' => [
+                'day' => $timestart->format('j'),
+                'month' => $timestart->format('n'),
+                'year' => $timestart->format('Y'),
+                'hour' => $timestart->format('G'),
+                'minute' => 0,
+            ],
+            'eventtype' => 'course',
+            'courseid' => $course->id,
+            'description' => [
+                'text' => '',
+                'format' => 1,
+            ],
+            'duration' => 1,
+            'timedurationuntil' => [
+                'day' => $timedurationuntil->format('j'),
+                'month' => $timedurationuntil->format('n'),
+                'year' => $timedurationuntil->format('Y'),
+                'hour' => $timedurationuntil->format('G'),
+                'minute' => 0,
+            ]
+        ];
+
+        $formdata = \core_calendar\local\event\forms\create::mock_generate_submit_keys($formdata);
+        $querystring = http_build_query($formdata, '', '&');
+
+        $generator->enrol_user($user->id, $course->id, 'student');
+        $generator->role_assign($roleid, $user->id, $context->id);
+
+        assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $context, true);
+
+        $user->ignoresesskey = true;
+        $this->resetAfterTest(true);
+        $this->setUser($user);
+
+        $result = external_api::clean_returnvalue(
+            core_calendar_external::submit_create_update_form_returns(),
+            core_calendar_external::submit_create_update_form($querystring)
+        );
+
+        $event = $result['event'];
+        $this->assertEquals($user->id, $event['userid']);
+        $this->assertEquals($formdata['eventtype'], $event['eventtype']);
+        $this->assertEquals($formdata['name'], $event['name']);
+        $this->assertEquals($formdata['courseid'], $event['course']['id']);
+    }
+
+    /**
+     * A user without the moodle/calendar:manageentries capability in a course
+     * that they are enrolled in should not be able to create a course event in that course.
+     */
+    public function test_submit_create_update_form_create_course_event_no_permission() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $course = $generator->create_course();
+        $context = context_course::instance($course->id);
+        $roleid = $generator->create_role();
+        $timestart = new DateTime();
+        $interval = new DateInterval("P1D"); // One day.
+        $timedurationuntil = new DateTime();
+        $timedurationuntil->add($interval);
+        $formdata = [
+            'id' => 0,
+            'userid' => $user->id,
+            'modulename' => '',
+            'instance' => 0,
+            'visible' => 1,
+            'name' => 'Test',
+            'timestart' => [
+                'day' => $timestart->format('j'),
+                'month' => $timestart->format('n'),
+                'year' => $timestart->format('Y'),
+                'hour' => $timestart->format('G'),
+                'minute' => 0,
+            ],
+            'eventtype' => 'course',
+            'courseid' => $course->id,
+            'description' => [
+                'text' => '',
+                'format' => 1,
+            ],
+            'duration' => 1,
+            'timedurationuntil' => [
+                'day' => $timedurationuntil->format('j'),
+                'month' => $timedurationuntil->format('n'),
+                'year' => $timedurationuntil->format('Y'),
+                'hour' => $timedurationuntil->format('G'),
+                'minute' => 0,
+            ]
+        ];
+
+        $formdata = \core_calendar\local\event\forms\create::mock_generate_submit_keys($formdata);
+        $querystring = http_build_query($formdata, '', '&');
+
+        $generator->enrol_user($user->id, $course->id, 'student');
+        $generator->role_assign($roleid, $user->id, $context->id);
+
+        assign_capability('moodle/calendar:manageentries', CAP_PROHIBIT, $roleid, $context, true);
+
+        $user->ignoresesskey = true;
+        $this->resetAfterTest(true);
+        $this->setUser($user);
+
+        $result = external_api::clean_returnvalue(
+            core_calendar_external::submit_create_update_form_returns(),
+            core_calendar_external::submit_create_update_form($querystring)
+        );
+
+        $this->assertTrue($result['validationerror']);
+    }
+
+    /**
+     * A user should not be able to create an event for a course that they are
+     * not enrolled in.
+     */
+    public function test_submit_create_update_form_create_course_event_not_enrolled() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $course = $generator->create_course();
+        $course2 = $generator->create_course();
+        $context = context_course::instance($course->id);
+        $roleid = $generator->create_role();
+        $timestart = new DateTime();
+        $interval = new DateInterval("P1D"); // One day.
+        $timedurationuntil = new DateTime();
+        $timedurationuntil->add($interval);
+        $formdata = [
+            'id' => 0,
+            'userid' => $user->id,
+            'modulename' => '',
+            'instance' => 0,
+            'visible' => 1,
+            'name' => 'Test',
+            'timestart' => [
+                'day' => $timestart->format('j'),
+                'month' => $timestart->format('n'),
+                'year' => $timestart->format('Y'),
+                'hour' => $timestart->format('G'),
+                'minute' => 0,
+            ],
+            'eventtype' => 'course',
+            'courseid' => $course2->id, // Not enrolled.
+            'description' => [
+                'text' => '',
+                'format' => 1,
+            ],
+            'duration' => 1,
+            'timedurationuntil' => [
+                'day' => $timedurationuntil->format('j'),
+                'month' => $timedurationuntil->format('n'),
+                'year' => $timedurationuntil->format('Y'),
+                'hour' => $timedurationuntil->format('G'),
+                'minute' => 0,
+            ]
+        ];
+
+        $formdata = \core_calendar\local\event\forms\create::mock_generate_submit_keys($formdata);
+        $querystring = http_build_query($formdata, '', '&');
+
+        $generator->enrol_user($user->id, $course->id, 'student');
+        $generator->role_assign($roleid, $user->id, $context->id);
+
+        assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $context, true);
+
+        $user->ignoresesskey = true;
+        $this->resetAfterTest(true);
+        $this->setUser($user);
+
+        $this->expectException('moodle_exception');
+
+        external_api::clean_returnvalue(
+            core_calendar_external::submit_create_update_form_returns(),
+            core_calendar_external::submit_create_update_form($querystring)
+        );
+    }
+
+    /**
+     * A user should be able to create an event for a group that they are a member of in
+     * a course in which they are enrolled and have the moodle/calendar:manageentries capability.
+     */
+    public function test_submit_create_update_form_create_group_event_group_member_manage_course() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $course = $generator->create_course();
+        $group = $generator->create_group(array('courseid' => $course->id));
+        $context = context_course::instance($course->id);
+        $roleid = $generator->create_role();
+        $timestart = new DateTime();
+        $interval = new DateInterval("P1D"); // One day.
+        $timedurationuntil = new DateTime();
+        $timedurationuntil->add($interval);
+        $formdata = [
+            'id' => 0,
+            'userid' => $user->id,
+            'modulename' => '',
+            'instance' => 0,
+            'visible' => 1,
+            'name' => 'Test',
+            'timestart' => [
+                'day' => $timestart->format('j'),
+                'month' => $timestart->format('n'),
+                'year' => $timestart->format('Y'),
+                'hour' => $timestart->format('G'),
+                'minute' => 0,
+            ],
+            'eventtype' => 'group',
+            'groupid' => "{$course->id}-{$group->id}", // The form format.
+            'groupcourseid' => $course->id,
+            'description' => [
+                'text' => '',
+                'format' => 1,
+            ],
+            'duration' => 1,
+            'timedurationuntil' => [
+                'day' => $timedurationuntil->format('j'),
+                'month' => $timedurationuntil->format('n'),
+                'year' => $timedurationuntil->format('Y'),
+                'hour' => $timedurationuntil->format('G'),
+                'minute' => 0,
+            ]
+        ];
+
+        $formdata = \core_calendar\local\event\forms\create::mock_generate_submit_keys($formdata);
+        $querystring = http_build_query($formdata, '', '&');
+
+        $generator->enrol_user($user->id, $course->id, 'student');
+        $generator->role_assign($roleid, $user->id, $context->id);
+        $generator->create_group_member(['groupid' => $group->id, 'userid' => $user->id]);
+
+        assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $context, true);
+
+        $user->ignoresesskey = true;
+        $this->resetAfterTest(true);
+        $this->setUser($user);
+
+        $result = external_api::clean_returnvalue(
+            core_calendar_external::submit_create_update_form_returns(),
+            core_calendar_external::submit_create_update_form($querystring)
+        );
+
+        $event = $result['event'];
+        $this->assertEquals($user->id, $event['userid']);
+        $this->assertEquals($formdata['eventtype'], $event['eventtype']);
+        $this->assertEquals($formdata['name'], $event['name']);
+        $this->assertEquals($group->id, $event['groupid']);
+    }
+
+    /**
+     * A user should be able to create an event for a group that they are a member of in
+     * a course in which they are enrolled and have the moodle/calendar:managegroupentries capability.
+     */
+    public function test_submit_create_update_form_create_group_event_group_member_manage_group_entries() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $course = $generator->create_course();
+        $group = $generator->create_group(array('courseid' => $course->id));
+        $context = context_course::instance($course->id);
+        $roleid = $generator->create_role();
+        $timestart = new DateTime();
+        $interval = new DateInterval("P1D"); // One day.
+        $timedurationuntil = new DateTime();
+        $timedurationuntil->add($interval);
+        $formdata = [
+            'id' => 0,
+            'userid' => $user->id,
+            'modulename' => '',
+            'instance' => 0,
+            'visible' => 1,
+            'name' => 'Test',
+            'timestart' => [
+                'day' => $timestart->format('j'),
+                'month' => $timestart->format('n'),
+                'year' => $timestart->format('Y'),
+                'hour' => $timestart->format('G'),
+                'minute' => 0,
+            ],
+            'eventtype' => 'group',
+            'groupid' => "{$course->id}-{$group->id}", // The form format.
+            'groupcourseid' => $course->id,
+            'description' => [
+                'text' => '',
+                'format' => 1,
+            ],
+            'duration' => 1,
+            'timedurationuntil' => [
+                'day' => $timedurationuntil->format('j'),
+                'month' => $timedurationuntil->format('n'),
+                'year' => $timedurationuntil->format('Y'),
+                'hour' => $timedurationuntil->format('G'),
+                'minute' => 0,
+            ]
+        ];
+
+        $formdata = \core_calendar\local\event\forms\create::mock_generate_submit_keys($formdata);
+        $querystring = http_build_query($formdata, '', '&');
+
+        $generator->enrol_user($user->id, $course->id, 'student');
+        $generator->role_assign($roleid, $user->id, $context->id);
+        $generator->create_group_member(['groupid' => $group->id, 'userid' => $user->id]);
+
+        assign_capability('moodle/calendar:manageentries', CAP_PROHIBIT, $roleid, $context, true);
+        assign_capability('moodle/calendar:managegroupentries', CAP_ALLOW, $roleid, $context, true);
+
+        $user->ignoresesskey = true;
+        $this->resetAfterTest(true);
+        $this->setUser($user);
+
+        $result = external_api::clean_returnvalue(
+            core_calendar_external::submit_create_update_form_returns(),
+            core_calendar_external::submit_create_update_form($querystring)
+        );
+
+        $event = $result['event'];
+        $this->assertEquals($user->id, $event['userid']);
+        $this->assertEquals($formdata['eventtype'], $event['eventtype']);
+        $this->assertEquals($formdata['name'], $event['name']);
+        $this->assertEquals($group->id, $event['groupid']);
+    }
+
+    /**
+     * A user should be able to create an event for any group in a course in which
+     * they are enrolled and have the moodle/site:accessallgroups capability.
+     */
+    public function test_submit_create_update_form_create_group_event_access_all_groups() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $course = $generator->create_course();
+        $group = $generator->create_group(array('courseid' => $course->id));
+        $context = context_course::instance($course->id);
+        $roleid = $generator->create_role();
+        $timestart = new DateTime();
+        $interval = new DateInterval("P1D"); // One day.
+        $timedurationuntil = new DateTime();
+        $timedurationuntil->add($interval);
+        $formdata = [
+            'id' => 0,
+            'userid' => $user->id,
+            'modulename' => '',
+            'instance' => 0,
+            'visible' => 1,
+            'name' => 'Test',
+            'timestart' => [
+                'day' => $timestart->format('j'),
+                'month' => $timestart->format('n'),
+                'year' => $timestart->format('Y'),
+                'hour' => $timestart->format('G'),
+                'minute' => 0,
+            ],
+            'eventtype' => 'group',
+            'groupid' => "{$course->id}-{$group->id}", // The form format.
+            'groupcourseid' => $course->id,
+            'description' => [
+                'text' => '',
+                'format' => 1,
+            ],
+            'duration' => 1,
+            'timedurationuntil' => [
+                'day' => $timedurationuntil->format('j'),
+                'month' => $timedurationuntil->format('n'),
+                'year' => $timedurationuntil->format('Y'),
+                'hour' => $timedurationuntil->format('G'),
+                'minute' => 0,
+            ]
+        ];
+
+        $formdata = \core_calendar\local\event\forms\create::mock_generate_submit_keys($formdata);
+        $querystring = http_build_query($formdata, '', '&');
+
+        $generator->enrol_user($user->id, $course->id, 'student');
+        $generator->role_assign($roleid, $user->id, $context->id);
+
+        assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $context, true);
+        assign_capability('moodle/site:accessallgroups', CAP_ALLOW, $roleid, $context, true);
+
+        $user->ignoresesskey = true;
+        $this->resetAfterTest(true);
+        $this->setUser($user);
+
+        $result = external_api::clean_returnvalue(
+            core_calendar_external::submit_create_update_form_returns(),
+            core_calendar_external::submit_create_update_form($querystring)
+        );
+
+        $event = $result['event'];
+        $this->assertEquals($user->id, $event['userid']);
+        $this->assertEquals($formdata['eventtype'], $event['eventtype']);
+        $this->assertEquals($formdata['name'], $event['name']);
+        $this->assertEquals($group->id, $event['groupid']);
+    }
+
+    /**
+     * A user should not be able to create an event for any group that they are not a
+     * member of in a course in which they are enrolled but don't have the
+     * moodle/site:accessallgroups capability.
+     */
+    public function test_submit_create_update_form_create_group_event_non_member_no_permission() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $course = $generator->create_course();
+        $group = $generator->create_group(array('courseid' => $course->id));
+        $context = context_course::instance($course->id);
+        $roleid = $generator->create_role();
+        $timestart = new DateTime();
+        $interval = new DateInterval("P1D"); // One day.
+        $timedurationuntil = new DateTime();
+        $timedurationuntil->add($interval);
+        $formdata = [
+            'id' => 0,
+            'userid' => $user->id,
+            'modulename' => '',
+            'instance' => 0,
+            'visible' => 1,
+            'name' => 'Test',
+            'timestart' => [
+                'day' => $timestart->format('j'),
+                'month' => $timestart->format('n'),
+                'year' => $timestart->format('Y'),
+                'hour' => $timestart->format('G'),
+                'minute' => 0,
+            ],
+            'eventtype' => 'group',
+            'groupid' => "{$course->id}-{$group->id}", // The form format.
+            'groupcourseid' => $course->id,
+            'description' => [
+                'text' => '',
+                'format' => 1,
+            ],
+            'duration' => 1,
+            'timedurationuntil' => [
+                'day' => $timedurationuntil->format('j'),
+                'month' => $timedurationuntil->format('n'),
+                'year' => $timedurationuntil->format('Y'),
+                'hour' => $timedurationuntil->format('G'),
+                'minute' => 0,
+            ]
+        ];
+
+        $formdata = \core_calendar\local\event\forms\create::mock_generate_submit_keys($formdata);
+        $querystring = http_build_query($formdata, '', '&');
+
+        $generator->enrol_user($user->id, $course->id, 'student');
+        $generator->role_assign($roleid, $user->id, $context->id);
+
+        assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $context, true);
+        assign_capability('moodle/site:accessallgroups', CAP_PROHIBIT, $roleid, $context, true);
+
+        $user->ignoresesskey = true;
+        $this->resetAfterTest(true);
+        $this->setUser($user);
+
+        $result = external_api::clean_returnvalue(
+            core_calendar_external::submit_create_update_form_returns(),
+            core_calendar_external::submit_create_update_form($querystring)
+        );
+
+        $this->assertTrue($result['validationerror']);
+    }
 }
diff --git a/course/classes/analytics/indicator/completion_enabled.php b/course/classes/analytics/indicator/completion_enabled.php
new file mode 100644 (file)
index 0000000..f2598e0
--- /dev/null
@@ -0,0 +1,89 @@
+<?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/>.
+
+/**
+ * Completion enabled set indicator.
+ *
+ * @package   core_course
+ * @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_course\analytics\indicator;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->dirroot . '/lib/completionlib.php');
+
+/**
+ * Completion enabled set indicator.
+ *
+ * @package   core_course
+ * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class completion_enabled extends \core_analytics\local\indicator\binary {
+
+    /**
+     * get_name
+     *
+     * @return new \lang_string
+     */
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:completionenabled', 'moodle');
+    }
+
+    /**
+     * required_sample_data
+     *
+     * @return string[]
+     */
+    public static function required_sample_data() {
+        // Minimum course although it also accepts course_modules.
+        return array('course');
+    }
+
+    /**
+     * Is completion enabled? Work both with courses and activities.
+     *
+     * @param int $sampleid
+     * @param string $sampleorigin
+     * @param int|false $notusedstarttime
+     * @param int|false $notusedendtime
+     * @return float
+     */
+    public function calculate_sample($sampleid, $sampleorigin, $notusedstarttime = false, $notusedendtime = false) {
+
+        $course = $this->retrieve('course', $sampleid);
+
+        $cm = false;
+        if ($sampleorigin === 'course_modules') {
+            $cm = $this->retrieve('course_modules', $sampleid);
+        }
+
+        $completion = new \completion_info($course);
+
+        if (!$completion->is_enabled($cm)) {
+            $value = self::get_min_value();
+        } else if (!$cm && !$completion->has_criteria()) {
+            // Course completion enabled with no criteria counts as nothing.
+            $value = self::get_min_value();
+        } else {
+            $value = self::get_max_value();
+        }
+        return $value;
+    }
+}
index 99051c7..ade6901 100644 (file)
@@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die();
 /**
  * No teacher indicator.
  *
- * @package   core_analytics
+ * @package   core_course
  * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
@@ -64,6 +64,23 @@ class no_teacher extends \core_analytics\local\indicator\binary {
         return array('context', 'course');
     }
 
+    /**
+     * Reversed because the indicator is in 'negative' and the max returned value means teacher present.
+     *
+     * @param float $value
+     * @param string $subtype
+     * @return string
+     */
+    public function get_display_value($value, $subtype = false) {
+
+        // No subtypes for binary values by default.
+        if ($value == -1) {
+            return get_string('yes');
+        } else if ($value == 1) {
+            return get_string('no');
+        }
+    }
+
     /**
      * calculate_sample
      *
diff --git a/course/classes/analytics/indicator/potential_cognitive_depth.php b/course/classes/analytics/indicator/potential_cognitive_depth.php
new file mode 100644 (file)
index 0000000..ea96e42
--- /dev/null
@@ -0,0 +1,136 @@
+<?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/>.
+
+/**
+ * Potential cognitive depth indicator.
+ *
+ * @package   core_course
+ * @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_course\analytics\indicator;
+
+defined('MOODLE_INTERNAL') || die();
+
+use \core_analytics\local\indicator\community_of_inquiry_activity;
+
+/**
+ * Potential cognitive depth indicator.
+ *
+ * It extends linear instead of discrete as there is a linear relation between
+ * the different cognitive levels activities can reach.
+ *
+ * @package   core_course
+ * @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class potential_cognitive_depth extends \core_analytics\local\indicator\linear {
+
+    /**
+     * get_name
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:potentialcognitive', 'moodle');
+    }
+
+    /**
+     * Specify the required data to process this indicator.
+     *
+     * @return string[]
+     */
+    public static function required_sample_data() {
+        // We require course because, although this indicator can also work with course_modules we can't
+        // calculate anything without the course.
+        return array('course');
+    }
+
+    /**
+     * calculate_sample
+     *
+     * @throws \coding_exception
+     * @param int $sampleid
+     * @param string $sampleorigin
+     * @param int|false $notusedstarttime
+     * @param int|false $notusedendtime
+     * @return float
+     */
+    public function calculate_sample($sampleid, $sampleorigin, $notusedstarttime = false, $notusedendtime = false) {
+
+        if ($sampleorigin === 'course_modules') {
+            $cm = $this->retrieve('course_modules', $sampleid);
+            $cminfo = \cm_info::create($cm);
+
+            $cognitivedepthindicator = $this->get_cognitive_indicator($cminfo->modname);
+            $potentiallevel = $cognitivedepthindicator->get_cognitive_depth_level($cminfo);
+            if ($potentiallevel > community_of_inquiry_activity::MAX_COGNITIVE_LEVEL) {
+                throw new \coding_exception('Maximum cognitive depth level is ' .
+                    community_of_inquiry_activity::MAX_COGNITIVE_LEVEL . ', ' . $potentiallevel . ' provided by ' .
+                        get_class($this));
+            }
+
+        } else {
+            $course = $this->retrieve('course', $sampleid);
+            $modinfo = get_fast_modinfo($course);
+
+            $cms = $modinfo->get_cms();
+            if (!$cms) {
+                return self::get_min_value();
+            }
+
+            $potentiallevel = 0;
+            foreach ($cms as $cm) {
+                if (!$cognitivedepthindicator = $this->get_cognitive_indicator($cm->modname)) {
+                    continue;
+                }
+                $level = $cognitivedepthindicator->get_cognitive_depth_level($cm);
+                if ($level > community_of_inquiry_activity::MAX_COGNITIVE_LEVEL) {
+                    throw new \coding_exception('Maximum cognitive depth level is ' .
+                        community_of_inquiry_activity::MAX_COGNITIVE_LEVEL . ', ' . $level . ' provided by ' . get_class($this));
+                }
+                if ($level > $potentiallevel) {
+                    $potentiallevel = $level;
+                }
+            }
+        }
+
+        // Values from -1 to 1 range split in 5 parts (the max cognitive depth level).
+        // Note that we divide by 4 because we start from -1.
+        $levelscore = round((self::get_max_value() - self::get_min_value()) / 4, 2);
+        // We substract $levelscore because we want to start from the lower score and there is no cognitive depth level 0.
+        return self::get_min_value() + ($levelscore * $potentiallevel) - $levelscore;
+    }
+
+    /**
+     * Returns the cognitive depth class of this indicator.
+     *
+     * @param string $modname
+     * @return \core_analytics\local\indicator\base|false
+     */
+    protected function get_cognitive_indicator($modname) {
+        $indicators = \core_analytics\manager::get_all_indicators();
+        foreach ($indicators as $indicator) {
+            if ($indicator instanceof community_of_inquiry_activity &&
+                    $indicator->get_indicator_type() === community_of_inquiry_activity::INDICATOR_COGNITIVE &&
+                    $indicator->get_activity_type() === $modname) {
+                return $indicator;
+            }
+        }
+        return false;
+    }
+}
diff --git a/course/classes/analytics/indicator/potential_social_breadth.php b/course/classes/analytics/indicator/potential_social_breadth.php
new file mode 100644 (file)
index 0000000..dc149b5
--- /dev/null
@@ -0,0 +1,148 @@
+<?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/>.
+
+/**
+ * Potential social breadth indicator.
+ *
+ * @package   core_course
+ * @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_course\analytics\indicator;
+
+defined('MOODLE_INTERNAL') || die();
+
+use \core_analytics\local\indicator\community_of_inquiry_activity;
+
+/**
+ * Potential social breadth indicator.
+ *
+ * It extends linear instead of discrete as there is a linear relation between
+ * the different social levels activities can reach.
+ *
+ * @package   core_course
+ * @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class potential_social_breadth extends \core_analytics\local\indicator\linear {
+
+    /**
+     * get_name
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:potentialsocial', 'moodle');
+    }
+
+    /**
+     * Specify the required data to process this indicator.
+     *
+     * @return string[]
+     */
+    public static function required_sample_data() {
+        // We require course because, although this indicator can also work with course_modules we can't
+        // calculate anything without the course.
+        return array('course');
+    }
+
+    /**
+     * calculate_sample
+     *
+     * @param int $sampleid
+     * @param string $sampleorigin
+     * @param int|false $notusedstarttime
+     * @param int|false $notusedendtime
+     * @return float
+     */
+    public function calculate_sample($sampleid, $sampleorigin, $notusedstarttime = false, $notusedendtime = false) {
+
+        if ($sampleorigin === 'course_modules') {
+            $cm = $this->retrieve('course_modules', $sampleid);
+            $cminfo = \cm_info::create($cm);
+
+            $socialbreadthindicator = $this->get_social_indicator($cminfo->modname);
+            $potentiallevel = $socialbreadthindicator->get_social_breadth_level($cminfo);
+            if ($potentiallevel > community_of_inquiry_activity::MAX_SOCIAL_LEVEL) {
+                $this->level_not_accepted($potentiallevel);
+            }
+
+        } else {
+            $course = $this->retrieve('course', $sampleid);
+            $modinfo = get_fast_modinfo($course);
+
+            $cms = $modinfo->get_cms();
+            if (!$cms) {
+                return self::get_min_value();
+            }
+
+            $potentiallevel = 0;
+            foreach ($cms as $cm) {
+                if (!$socialbreadthindicator = $this->get_social_indicator($cm->modname)) {
+                    continue;
+                }
+                $level = $socialbreadthindicator->get_social_breadth_level($cm);
+                if ($level > community_of_inquiry_activity::MAX_SOCIAL_LEVEL) {
+                    $this->level_not_accepted($level);
+                }
+                if ($level > $potentiallevel) {
+                    $potentiallevel = $level;
+                }
+            }
+        }
+
+        // Core activities social breadth only reaches level 2, until core activities social
+        // breadth do not reach level 5 we limit it to what we currently support, which is level 2.
+        if ($potentiallevel > 2) {
+            $potentiallevel = 2;
+        }
+
+        // Supporting only social breadth level 1 and 2 the possible values are -1 or 1.
+        $levelscore = round(self::get_max_value() - self::get_min_value(), 2);
+
+        // We substract $levelscore because we want to start from the lower socre and there is no cognitive depth level 0.
+        return self::get_min_value() + ($levelscore * $potentiallevel) - $levelscore;
+    }
+
+    /**
+     * Returns the social breadth class of this indicator.
+     *
+     * @param string $modname
+     * @return \core_analytics\local\indicator\base|false
+     */
+    protected function get_social_indicator($modname) {
+        $indicators = \core_analytics\manager::get_all_indicators();
+        foreach ($indicators as $indicator) {
+            if ($indicator instanceof community_of_inquiry_activity &&
+                    $indicator->get_indicator_type() === community_of_inquiry_activity::INDICATOR_SOCIAL &&
+                    $indicator->get_activity_type() === $modname) {
+                return $indicator;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Throw a \coding_exception.
+     *
+     * @param int $level
+     */
+    protected function level_not_accepted($level) {
+        throw new \coding_exception('Activities\' potential social breadth go from 1 to ' .
+            community_of_inquiry_activity::MAX_SOCIAL_LEVEL . '.');
+    }
+}
index 8b22a9a..2ccc6f1 100644 (file)
@@ -3893,18 +3893,17 @@ function course_get_user_navigation_options($context, $course = null) {
 
     // Frontpage settings?
     if ($isfrontpage) {
-        if ($course->id == SITEID) {
-            $options->participants = has_capability('moodle/site:viewparticipants', $sitecontext);
-        } else {
-            $options->participants = has_capability('moodle/course:viewparticipants', context_course::instance($course->id));
-        }
-
+        // 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->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 {
-        $options->participants = has_capability('moodle/course:viewparticipants', $context);
+        // 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->badges = !empty($CFG->enablebadges) && !empty($CFG->badges_allowcoursebadges) &&
                             has_capability('moodle/badges:viewbadges', $context);
         // Add view grade report is permitted.
index fee1d43..702771f 100644 (file)
@@ -3024,23 +3024,6 @@ class core_course_courselib_testcase extends advanced_testcase {
         $this->assertTrue($navoptions->tags);
         $this->assertTrue($navoptions->search);
         $this->assertTrue($navoptions->calendar);
-
-        // Standar using viewing frontpage settings from a course where is enrolled.
-        $course = self::getDataGenerator()->create_course();
-        // Create a viewer user.
-        $viewer = self::getDataGenerator()->create_user();
-        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
-        $this->getDataGenerator()->enrol_user($viewer->id, $course->id, $studentrole->id);
-        $this->setUser($viewer);
-
-        $navoptions = course_get_user_navigation_options($context, $course);
-        $this->assertTrue($navoptions->blogs);
-        $this->assertFalse($navoptions->notes);
-        $this->assertTrue($navoptions->participants);
-        $this->assertTrue($navoptions->badges);
-        $this->assertTrue($navoptions->tags);
-        $this->assertTrue($navoptions->search);
-        $this->assertTrue($navoptions->calendar);
     }
 
     /**
diff --git a/course/tests/indicators_test.php b/course/tests/indicators_test.php
new file mode 100644 (file)
index 0000000..b9222d7
--- /dev/null
@@ -0,0 +1,315 @@
+<?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 core_course indicators.
+ *
+ * @package   core_course
+ * @category  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__ . '/../../completion/criteria/completion_criteria_self.php');
+
+
+/**
+ * Unit tests for core_course indicators.
+ *
+ * @package   core_course
+ * @category  analytics
+ * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_course_indicators_testcase extends advanced_testcase {
+
+    /**
+     * test_no_teacher
+     *
+     * @return void
+     */
+    public function test_no_teacher() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        $course1 = $this->getDataGenerator()->create_course();
+        $course2 = $this->getDataGenerator()->create_course();
+        $coursecontext1 = \context_course::instance($course1->id);
+        $coursecontext2 = \context_course::instance($course2->id);
+
+        $user = $this->getDataGenerator()->create_user();
+
+        $this->getDataGenerator()->enrol_user($user->id, $course1->id, 'student');
+        $this->getDataGenerator()->enrol_user($user->id, $course2->id, 'teacher');
+
+        $indicator = new \core_course\analytics\indicator\no_teacher();
+
+        $sampleids = array($course1->id => $course1->id, $course2->id => $course2->id);
+        $data = array(
+            $course1->id => array(
+                'context' => $coursecontext1,
+                'course' => $course1,
+            ),
+            $course2->id => array(
+                'context' => $coursecontext2,
+                'course' => $course2,
+            ));
+        $indicator->add_sample_data($data);
+
+        list($values, $ignored) = $indicator->calculate($sampleids, 'course');
+        $this->assertEquals($indicator::get_min_value(), $values[$course1->id][0]);
+        $this->assertEquals($indicator::get_max_value(), $values[$course2->id][0]);
+    }
+
+    /**
+     * test_completion_enabled
+     *
+     * @return void
+     */
+    public function test_completion_enabled() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        $course1 = $this->getDataGenerator()->create_course(array('enablecompletion' => 0));
+        $course2 = $this->getDataGenerator()->create_course(array('enablecompletion' => 1));
+        $course3 = $this->getDataGenerator()->create_course(array('enablecompletion' => 1));
+
+        // Criteria only for the last one.
+        $criteriadata = new stdClass();
+        $criteriadata->id = $course3->id;
+        $criteriadata->criteria_self = 1;
+        $criterion = new completion_criteria_self();
+        $criterion->update_config($criteriadata);
+
+        $indicator = new \core_course\analytics\indicator\completion_enabled();
+
+        $sampleids = array($course1->id => $course1->id, $course2->id => $course2->id, $course3->id => $course3->id);
+        $data = array(
+            $course1->id => array(
+                'course' => $course1,
+            ),
+            $course2->id => array(
+                'course' => $course2,
+            ),
+            $course3->id => array(
+                'course' => $course3,
+            ));
+        $indicator->add_sample_data($data);
+
+        // Calculate using course samples.
+        list($values, $ignored) = $indicator->calculate($sampleids, 'course');
+        $this->assertEquals($indicator::get_min_value(), $values[$course1->id][0]);
+        $this->assertEquals($indicator::get_min_value(), $values[$course2->id][0]);
+        $this->assertEquals($indicator::get_max_value(), $values[$course3->id][0]);
+
+        // Calculate using course_modules samples.
+        $indicator->clear_sample_data();
+        $data1 = $this->getDataGenerator()->create_module('data', array('course' => $course3->id),
+                                                             array('completion' => 0));
+        $data2 = $this->getDataGenerator()->create_module('data', array('course' => $course3->id),
+                                                             array('completion' => 1));
+
+        $sampleids = array($data1->cmid => $data1->cmid, $data2->cmid => $data2->cmid);
+        $cm1 = $DB->get_record('course_modules', array('id' => $data1->cmid));
+        $cm2 = $DB->get_record('course_modules', array('id' => $data2->cmid));
+        $data = array(
+            $cm1->id => array(
+                'course' => $course3,
+                'course_modules' => $cm1,
+            ),
+            $cm2->id => array(
+                'course' => $course3,
+                'course_modules' => $cm2,
+            ));
+        $indicator->add_sample_data($data);
+
+        list($values, $ignored) = $indicator->calculate($sampleids, 'course_modules');
+        $this->assertEquals($indicator::get_min_value(), $values[$cm1->id][0]);
+        $this->assertEquals($indicator::get_max_value(), $values[$cm2->id][0]);
+    }
+
+    /**
+     * test_potential_cognitive
+     *
+     * @return void
+     */
+    public function test_potential_cognitive() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        $course1 = $this->getDataGenerator()->create_course();
+
+        $course2 = $this->getDataGenerator()->create_course();
+        $page = $this->getDataGenerator()->create_module('page', array('course' => $course2->id));
+
+        $course3 = $this->getDataGenerator()->create_course();
+        $forum = $this->getDataGenerator()->create_module('forum', array('course' => $course3->id));
+        $assign = $this->getDataGenerator()->create_module('assign', array('course' => $course3->id));
+
+        $course4 = $this->getDataGenerator()->create_course();
+        $forum = $this->getDataGenerator()->create_module('forum', array('course' => $course4->id));
+        $forum = $this->getDataGenerator()->create_module('forum', array('course' => $course4->id));
+
+        $indicator = new \core_course\analytics\indicator\potential_cognitive_depth();
+
+        $sampleids = array($course1->id => $course1->id, $course2->id => $course2->id, $course3->id => $course3->id,
+            $course4->id => $course4->id);
+        $data = array(
+            $course1->id => array(
+                'course' => $course1,
+            ),
+            $course2->id => array(
+                'course' => $course2,
+            ),
+            $course3->id => array(
+                'course' => $course3,
+            ),
+            $course4->id => array(
+                'course' => $course4,
+            ));
+        $indicator->add_sample_data($data);
+
+        list($values, $ignored) = $indicator->calculate($sampleids, 'course');
+        $this->assertEquals($indicator::get_min_value(), $values[$course1->id][0]);
+
+        // General explanation about the points, the max level is 5 so level 1 is -1, level 2 is -0.5, level 3 is 0,
+        // level 4 is 0.5 and level 5 is 1.
+
+        // Page cognitive is level 1 (the lower one).
+        $this->assertEquals($indicator::get_min_value(), $values[$course2->id][0]);
+
+        // The maximum cognitive depth level is 5, assign level is 5 therefore the potential cognitive depth is the max.
+        $this->assertEquals($indicator::get_max_value(), $values[$course3->id][0]);
+
+        // Forum level is 4.
+        $this->assertEquals(0.5, $values[$course4->id][0]);
+
+        // Calculate using course_modules samples.
+        $course5 = $this->getDataGenerator()->create_course();
+        $assign = $this->getDataGenerator()->create_module('assign', array('course' => $course5->id));
+        $forum = $this->getDataGenerator()->create_module('forum', array('course' => $course5->id));
+
+        $sampleids = array($assign->cmid => $assign->cmid, $forum->cmid => $forum->cmid);
+        $cm1 = $DB->get_record('course_modules', array('id' => $assign->cmid));
+        $cm2 = $DB->get_record('course_modules', array('id' => $forum->cmid));
+        $data = array(
+            $cm1->id => array(
+                'course' => $course5,
+                'course_modules' => $cm1,
+            ),
+            $cm2->id => array(
+                'course' => $course5,
+                'course_modules' => $cm2,
+            ));
+        $indicator->clear_sample_data();
+        $indicator->add_sample_data($data);
+
+        list($values, $ignored) = $indicator->calculate($sampleids, 'course_modules');
+        // Assign level is 5, the maximum level.
+        $this->assertEquals($indicator::get_max_value(), $values[$cm1->id][0]);
+        // Forum level is 4.
+        $this->assertEquals(0.5, $values[$cm2->id][0]);
+
+    }
+
+    /**
+     * test_potential_social
+     *
+     * @return void
+     */
+    public function test_potential_social() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        $course1 = $this->getDataGenerator()->create_course();
+
+        $course2 = $this->getDataGenerator()->create_course();
+        $page = $this->getDataGenerator()->create_module('page', array('course' => $course2->id));
+
+        $course3 = $this->getDataGenerator()->create_course();
+        $forum = $this->getDataGenerator()->create_module('forum', array('course' => $course3->id));
+        $assign = $this->getDataGenerator()->create_module('assign', array('course' => $course3->id));
+
+        $course4 = $this->getDataGenerator()->create_course();
+        $page = $this->getDataGenerator()->create_module('page', array('course' => $course4->id));
+        $assign = $this->getDataGenerator()->create_module('assign', array('course' => $course4->id));
+
+        $indicator = new \core_course\analytics\indicator\potential_social_breadth();
+
+        $sampleids = array($course1->id => $course1->id, $course2->id => $course2->id, $course3->id => $course3->id,
+            $course4->id => $course4->id);
+        $data = array(
+            $course1->id => array(
+                'course' => $course1,
+            ),
+            $course2->id => array(
+                'course' => $course2,
+            ),
+            $course3->id => array(
+                'course' => $course3,
+            ),
+            $course4->id => array(
+                'course' => $course4,
+            ));
+        $indicator->add_sample_data($data);
+
+        list($values, $ignored) = $indicator->calculate($sampleids, 'course');
+        $this->assertEquals($indicator::get_min_value(), $values[$course1->id][0]);
+
+        // General explanation about the points, the max level is 2 so level 1 is -1, level 2 is 1.
+
+        // Page social is level 1 (the lower level).
+        $this->assertEquals($indicator::get_min_value(), $values[$course2->id][0]);
+
+        // Forum is level 2 and assign is level 2.
+        $this->assertEquals($indicator::get_max_value(), $values[$course3->id][0]);
+
+        // Page is level 1 and assign is level 2, so potential level is the max one.
+        $this->assertEquals($indicator::get_max_value(), $values[$course4->id][0]);
+
+        // Calculate using course_modules samples.
+        $course5 = $this->getDataGenerator()->create_course();
+        $assign = $this->getDataGenerator()->create_module('assign', array('course' => $course5->id));
+        $page = $this->getDataGenerator()->create_module('page', array('course' => $course5->id));
+
+        $sampleids = array($assign->cmid => $assign->cmid, $page->cmid => $page->cmid);
+        $cm1 = $DB->get_record('course_modules', array('id' => $assign->cmid));
+        $cm2 = $DB->get_record('course_modules', array('id' => $page->cmid));
+        $data = array(
+            $cm1->id => array(
+                'course' => $course5,
+                'course_modules' => $cm1,
+            ),
+            $cm2->id => array(
+                'course' => $course5,
+                'course_modules' => $cm2,
+            ));
+        $indicator->clear_sample_data();
+        $indicator->add_sample_data($data);
+
+        list($values, $ignored) = $indicator->calculate($sampleids, 'course_modules');
+        // Assign social is level 2 (the max level).
+        $this->assertEquals($indicator::get_max_value(), $values[$cm1->id][0]);
+        // Page social is level 1 (the lower level).
+        $this->assertEquals($indicator::get_min_value(), $values[$cm2->id][0]);
+    }
+}
index bf6ab9d..64356fa 100644 (file)
@@ -554,7 +554,7 @@ class course_enrolment_manager {
     }
 
     /**
-     * Gets all of the enrolment plugins that are active for this course.
+     * Gets all of the enrolment plugins that are available for this course.
      *
      * @param bool $onlyenabled return only enabled enrol plugins
      * @return array
index a55b867..ab9b862 100644 (file)
@@ -49,7 +49,7 @@
     }
 }}
 <span>
-    <img height="18" src="{{profileimageurlsmall}}" alt="" role="presentation">
+    <img height="12" src="{{profileimageurlsmall}}" alt="" role="presentation">
     <span>{{fullname}}</span>
     {{#hasidentity}}
     <span><small>{{identity}}</small></span>
index 831189a..9595a03 100644 (file)
@@ -58,7 +58,7 @@ class user_groups_editable extends \core\output\inplace_editable {
      */
     public function __construct($course, $context, $user, $coursegroups, $value) {
         // Check capabilities to get editable value.
-        $editable = has_capability('moodle/course:managegroups', $context);
+        $editable = has_capability('moodle/course:managegroups', $context) && !empty($coursegroups);
 
         // Invent an itemid.
         $itemid = $course->id . ':' . $user->id;
index 600dd20..2085962 100644 (file)
@@ -35,7 +35,7 @@ $string['availablelangs'] = 'Tilgængelige sprogpakker';
 $string['chooselanguagehead'] = 'Vælg et sprog';
 $string['chooselanguagesub'] = 'Vælg et sprog til brug under installationen. Dette sprog vil også blive brugt som standardsprog på webstedet, men det kan altid ændres til et andet sprog.';
 $string['clialreadyconfigured'] = 'Konfigurationsfilen config.php eksisterer allerede. Benyt venigst admin/cli/install_database.php til at installere Moodle for dette site.';
-$string['clialreadyinstalled'] = 'Filen config.php eksisterer allerede, brug venligst admin/cli/install_database.php hvis du ønsker at opgradere dette websted.';
+$string['clialreadyinstalled'] = 'Filen config.php eksisterer allerede, brug admin/cli/install_database.php hvis du ønsker at opgradere dette websted.';
 $string['cliinstallheader'] = 'Moodle {$a} kommandolinje-installationsprogram';
 $string['databasehost'] = 'Databasevært';
 $string['databasename'] = 'Databasenavn';
diff --git a/install/lang/dsb/langconfig.php b/install/lang/dsb/langconfig.php
new file mode 100644 (file)
index 0000000..121ecd3
--- /dev/null
@@ -0,0 +1,34 @@
+<?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/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package   installer
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['parentlanguage'] = 'de';
+$string['thislanguage'] = 'Dolnoserbski';
diff --git a/install/lang/kn/install.php b/install/lang/kn/install.php
new file mode 100644 (file)
index 0000000..db8e04c
--- /dev/null
@@ -0,0 +1,33 @@
+<?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/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package   installer
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['availablelangs'] = 'ಲಭ್ಯವಿರುವ ಭಾಷಾ ಪೊಟ್ಟಣಗಳು';
index 419620b..3e84641 100644 (file)
@@ -31,6 +31,7 @@
 defined('MOODLE_INTERNAL') || die();
 
 $string['language'] = 'Jazyk';
+$string['moodlelogo'] = 'Moodle logo';
 $string['next'] = 'Ďalší';
 $string['previous'] = 'Predchádzajúci';
 $string['reload'] = 'Reload';
index 9c82a4c..3a93449 100644 (file)
@@ -30,6 +30,9 @@
 
 defined('MOODLE_INTERNAL') || die();
 
+$string['cannotcreatedboninstall'] = '<p>Podatkovne zbirke ni bilo mogoče ustvariti.</p>
+<p>Navedena podatkovna zbirka ne obstaja in navedeni uporabnik nima dovoljenja, da ustvari podatkovno zbirko.</p>
+<p>Skrbnik spletnega mesta mora potrditi konfiguracijo podatkovne zbirke.</p>';
 $string['cannotcreatelangdir'] = 'Imenika jezika ni možno ustvariti.';
 $string['cannotcreatetempdir'] = 'Začasnega imenika ni možno ustvariti.';
 $string['cannotdownloadcomponents'] = 'Komponent ni možno prenesti.';
@@ -39,6 +42,7 @@ $string['cannotsavemd5file'] = 'Datoteke MD5 ni možno shraniti.';
 $string['cannotsavezipfile'] = 'Datoteke ZIP ni možno shraniti.';
 $string['cannotunzipfile'] = 'Datoteke ni možno razširiti (unzip).';
 $string['componentisuptodate'] = 'Komponenta je posodobljena.';
+$string['dmlexceptiononinstall'] = '<p>Prišlo je do napake podatkovne zbirke [{$a->errorcode}].<br />{$a->debuginfo}</p>';
 $string['downloadedfilecheckfailed'] = 'Preverjanje prenesene datoteke je spodletelo.';
 $string['invalidmd5'] = 'Neveljaven MD5';
 $string['missingrequiredfield'] = 'Eno izmed zahtevanih polj manjka';
index 81fa1bc..2af347e 100644 (file)
@@ -30,6 +30,7 @@ $string['analyticslogstore_help'] = 'The log store that will be used by the anal
 $string['analyticssettings'] = 'Analytics settings';
 $string['coursetoolong'] = 'The course is too long';
 $string['enabledtimesplittings'] = 'Time splitting methods';
+$string['disabledmodel'] = 'Disabled model';
 $string['erroralreadypredict'] = '{$a} file has already been used to predict';
 $string['errorcannotreaddataset'] = 'Dataset file {$a} can not be read';
 $string['errorcannotwritedataset'] = 'Dataset file {$a} can not be written';
@@ -53,7 +54,6 @@ $string['errorunexistingmodel'] = 'Unexisting model {$a}';
 $string['errorunknownaction'] = 'Unknown action';
 $string['eventpredictionactionstarted'] = 'Prediction action started';
 $string['insightmessagesubject'] = 'New insight for "{$a->contextname}": {$a->insightname}';
-$string['insightinfo'] = '{$a->insightname} - {$a->contextname}';
 $string['insightinfomessage'] = 'The system generated some insights for you: {$a}';
 $string['insightinfomessagehtml'] = 'The system generated some insights for you: <a href="{$a}">{$a}</a>.';
 $string['invalidtimesplitting'] = 'Model with id {$a} needs a time splitting method before it can be used to train';
index 60b8075..eccd233 100644 (file)
@@ -148,6 +148,7 @@ $string['importfromurl'] = 'Calendar URL';
 $string['importfrominstructions'] = 'Please provide either a URL to a remote calendar, or upload a file.';
 $string['invalidtimedurationminutes'] = 'The duration in minutes you have entered is invalid. Please enter the duration in minutes greater than 0 or select no duration.';
 $string['invalidtimedurationuntil'] = 'The date and time you selected for duration until is before the start time of the event. Please correct this before proceeding.';
+$string['invalideventtype'] = 'The event type you have selected is invalid.';
 $string['iwanttoexport'] = 'Export';
 $string['less'] = 'Less';
 $string['managesubscriptions'] = 'Manage subscriptions';
@@ -228,6 +229,7 @@ $string['tuesday'] = 'Tuesday';
 $string['typeclose'] = 'Close event';
 $string['typecourse'] = 'Course event';
 $string['typedue'] = 'Due event';
+$string['typegradingdue'] = 'Grading due event';
 $string['typegroup'] = 'Group event';
 $string['typeopen'] = 'Open event';
 $string['typesite'] = 'Site event';
index ae18f7c..666a8a5 100644 (file)
@@ -139,6 +139,7 @@ $string['unenrolroleusers'] = 'Unenrol users';
 $string['uninstallmigrating'] = 'Migrating "{$a}" enrolments';
 $string['unknowajaxaction'] = 'Unknown action requested';
 $string['unlimitedduration'] = 'Unlimited';
+$string['userremovedfromselectiona'] = 'User "{$a}" was removed from the selection.';
 $string['usersearch'] = 'Search ';
 $string['withselectedusers'] = 'With selected users';
 $string['extremovedaction'] = 'External unenrol action';
index e65d8f8..cdfa6b7 100644 (file)
@@ -506,6 +506,7 @@ $string['statsnodata'] = 'There is no available data for that combination of cou
 $string['storedfilecannotcreatefile'] = 'Can not create local file pool file, please verify permissions in dataroot and available disk space.';
 $string['storedfilecannotcreatefiledirs'] = 'Can not create local file pool directories, please verify permissions in dataroot.';
 $string['storedfilecannotread'] = 'Cannot read file. Either the file does not exist or there is a permission problem.';
+$string['storedfilecannotreadfile'] = 'Cannot read file \'{$a}\'. Either the file does not exist or there is a permission problem.';
 $string['storedfilenotcreated'] = 'Can not create file "{$a->contextid}/{$a->component}/{$a->filearea}/{$a->itemid}{$a->filepath}{$a->filename}"';
 $string['storedfileproblem'] = 'Unknown exception related to local files ({$a})';
 $string['tagdisabled'] = 'Tags are disabled!';
index e0f3592..6c18422 100644 (file)
@@ -1031,8 +1031,14 @@ $string['indicator:anywrite'] = 'Any write action';
 $string['indicator:anywrite_help'] = '';
 $string['indicator:completeduserprofile'] = 'User profile is completed';
 $string['indicator:completeduserprofile_help'] = '';
+$string['indicator:completionenabled'] = 'Completion enabled';
+$string['indicator:completionenabled_help'] = '';
 $string['indicator:noteacher'] = 'There are no teachers';
 $string['indicator:noteacher_help'] = '';
+$string['indicator:potentialcognitive'] = 'Potential cognitive depth';
+$string['indicator:potentialcognitive_help'] = '';
+$string['indicator:potentialsocial'] = 'Potential social breadth';
+$string['indicator:potentialsocial_help'] = '';
 $string['indicator:readactions'] = 'Read actions amount';
 $string['indicator:readactions_help'] = '';
 $string['indicator:userforumstracking'] = 'User is tracking forums';
index e0e9f5d..ce69373 100644 (file)
Binary files a/lib/amd/build/form-autocomplete.min.js and b/lib/amd/build/form-autocomplete.min.js differ
index 73071d8..3ff3e5a 100644 (file)
Binary files a/lib/amd/build/inplace_editable.min.js and b/lib/amd/build/inplace_editable.min.js differ
index 12f44eb..60c678f 100644 (file)
@@ -779,8 +779,9 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
                 return false;
             }
 
+            originalSelect.css('visibility', 'hidden').attr('aria-hidden', true);
+
             // Hide the original select.
-            originalSelect.hide().attr('aria-hidden', true);
 
             // Find or generate some ids.
             var state = {
@@ -816,10 +817,11 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
             var renderSelection = templates.render('core/form_autocomplete_selection', context);
 
             return $.when(renderInput, renderDatalist, renderSelection).then(function(input, suggestions, selection) {
-                // Add our new UI elements to the page.
+                originalSelect.hide();
                 originalSelect.after(suggestions);
                 originalSelect.after(input);
                 originalSelect.after(selection);
+
                 // Update the form label to point to the text input.
                 originalLabel.attr('for', state.inputId);
                 // Add the event handlers.
index 20ca0fb..3bd7587 100644 (file)
@@ -211,6 +211,7 @@ define(['jquery',
             var i,
                 inputelement = $('<select></select>').
                     attr('id', uniqueId('id_inplacevalue_', 20)).
+                    addClass('form-autocomplete-original-select').
                     addClass('custom-select'),
                 lbl = $('<label class="accesshide">' + mainelement.attr('data-editlabel') + '</label>')
                     .attr('for', inputelement.attr('id')),
@@ -274,11 +275,15 @@ define(['jquery',
             });
             saveelement.on('click', function(e) {
                 var val = JSON.stringify(inputelement.val());
+                // We need to empty the node to destroy all event handlers etc.
+                inputelement.empty();
                 turnEditingOff(el);
                 updateValue(el, val);
                 e.preventDefault();
             });
             cancelelement.on('click', function(e) {
+                // We need to empty the node to destroy all event handlers etc.
+                inputelement.empty();
                 turnEditingOff(el);
                 e.preventDefault();
             });
index 21937c4..ef54503 100644 (file)
@@ -75,14 +75,7 @@ class no_teaching extends \core_analytics\local\target\binary {
         $actions['viewcourse'] = new \core_analytics\prediction_action('viewcourse', $prediction,
             $url, $pix, get_string('view'));
 
-        if (has_capability('moodle/course:enrolreview', $sampledata['context'])) {
-            $url = new \moodle_url('/user/index.php', array('id' => $course->id));
-            $pix = new \pix_icon('i/enrolusers', get_string('enrolledusers', 'enrol'));
-            $actions['enrolusers'] = new \core_analytics\prediction_action('enrolusers', $prediction,
-                $url, $pix, get_string('enrolledusers', 'enrol'));
-        }
-
-        if (has_capability('moodle/course:viewparticipants', $sampledata['context'])) {
+        if (has_any_capability(['moodle/course:viewparticipants', 'moodle/course:enrolreview'], $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,
index a27e605..bba05f6 100644 (file)
@@ -44,7 +44,7 @@ class api {
 
     /**
      * Create a google ready OAuth 2 service.
-     * @return core\oauth2\issuer
+     * @return \core\oauth2\issuer
      */
     private static function create_google() {
         $record = (object) [
@@ -70,7 +70,7 @@ class api {
 
     /**
      * Create a facebook ready OAuth 2 service.
-     * @return core\oauth2\issuer
+     * @return \core\oauth2\issuer
      */
     private static function create_facebook() {
         // Facebook is a custom setup.
@@ -125,7 +125,7 @@ class api {
 
     /**
      * Create a microsoft ready OAuth 2 service.
-     * @return core\oauth2\issuer
+     * @return \core\oauth2\issuer
      */
     private static function create_microsoft() {
         // Microsoft is a custom setup.
@@ -200,7 +200,7 @@ class api {
 
     /**
      * List all the issuers, ordered by the sortorder field
-     * @return core\oauth2\issuer[]
+     * @return \core\oauth2\issuer[]
      */
     public static function get_all_issuers() {
         return issuer::get_records([], 'sortorder');
@@ -210,7 +210,7 @@ class api {
      * Get a single issuer by id.
      *
      * @param int $id
-     * @return core\oauth2\issuer
+     * @return \core\oauth2\issuer
      */
     public static function get_issuer($id) {
         return new issuer($id);
@@ -220,7 +220,7 @@ class api {
      * Get a single endpoint by id.
      *
      * @param int $id
-     * @return core\oauth2\endpoint
+     * @return \core\oauth2\endpoint
      */
     public static function get_endpoint($id) {
         return new endpoint($id);
@@ -230,7 +230,7 @@ class api {
      * Get a single user field mapping by id.
      *
      * @param int $id
-     * @return core\oauth2\user_field_mapping
+     * @return \core\oauth2\user_field_mapping
      */
     public static function get_user_field_mapping($id) {
         return new user_field_mapping($id);
@@ -241,7 +241,7 @@ class api {
      * Never ever ever expose this to a webservice because it contains the refresh token which grants API access.
      *
      * @param \core\oauth2\issuer $issuer
-     * @return \core\oauth2\client
+     * @return system_account|false
      */
     public static function get_system_account(issuer $issuer) {
         return system_account::get_record(['issuerid' => $issuer->get('id')]);
@@ -286,8 +286,9 @@ class api {
      * Get an authenticated oauth2 client using the system account.
      * This call uses the refresh token to get an access token.
      *
-     * @param core\oauth2\issuer $issuer
-     * @return core\oauth2\client
+     * @param \core\oauth2\issuer $issuer
+     * @return \core\oauth2\client|false An authenticated client (or false if the token could not be upgraded)
+     * @throws moodle_exception Request for token upgrade failed for technical reasons
      */
     public static function get_system_oauth_client(issuer $issuer) {
         $systemaccount = self::get_system_account($issuer);
@@ -311,10 +312,10 @@ class api {
      * Get an authenticated oauth2 client using the current user account.
      * This call does the redirect dance back to the current page after authentication.
      *
-     * @param core\oauth2\issuer $issuer The desired OAuth issuer
+     * @param \core\oauth2\issuer $issuer The desired OAuth issuer
      * @param moodle_url $currenturl The url to the current page.
      * @param string $additionalscopes The additional scopes required for authorization.
-     * @return core\oauth2\client
+     * @return \core\oauth2\client
      */
     public static function get_user_oauth_client(issuer $issuer, moodle_url $currenturl, $additionalscopes = '') {
         $client = new \core\oauth2\client($issuer, $currenturl, $additionalscopes);
@@ -325,8 +326,8 @@ class api {
     /**
      * Get the list of defined endpoints for this OAuth issuer
      *
-     * @param core\oauth2\issuer $issuer The desired OAuth issuer
-     * @return core\oauth2\endpoint[]
+     * @param \core\oauth2\issuer $issuer The desired OAuth issuer
+     * @return \core\oauth2\endpoint[]
      */
     public static function get_endpoints(issuer $issuer) {
         return endpoint::get_records(['issuerid' => $issuer->get('id')]);
@@ -335,8 +336,8 @@ class api {
     /**
      * Get the list of defined mapping from OAuth user fields to moodle user fields.
      *
-     * @param core\oauth2\issuer $issuer The desired OAuth issuer
-     * @return core\oauth2\user_field_mapping[]
+     * @param \core\oauth2\issuer $issuer The desired OAuth issuer
+     * @return \core\oauth2\user_field_mapping[]
      */
     public static function get_user_field_mappings(issuer $issuer) {
         return user_field_mapping::get_records(['issuerid' => $issuer->get('id')]);
@@ -345,7 +346,7 @@ class api {
     /**
      * Guess an image from the discovery URL.
      *
-     * @param core\oauth2\issuer $issuer The desired OAuth issuer
+     * @param \core\oauth2\issuer $issuer The desired OAuth issuer
      */
     protected static function guess_image($issuer) {
         if (empty($issuer->get('image'))) {
@@ -447,7 +448,7 @@ class api {
      * Take the data from the mform and update the issuer.
      *
      * @param stdClass $data
-     * @return core\oauth2\issuer
+     * @return \core\oauth2\issuer
      */
     public static function update_issuer($data) {
         require_capability('moodle/site:config', context_system::instance());
@@ -466,7 +467,7 @@ class api {
      * Take the data from the mform and create the issuer.
      *
      * @param stdClass $data
-     * @return core\oauth2\issuer
+     * @return \core\oauth2\issuer
      */
     public static function create_issuer($data) {
         require_capability('moodle/site:config', context_system::instance());
@@ -485,7 +486,7 @@ class api {
      * Take the data from the mform and update the endpoint.
      *
      * @param stdClass $data
-     * @return core\oauth2\endpoint
+     * @return \core\oauth2\endpoint
      */
     public static function update_endpoint($data) {
         require_capability('moodle/site:config', context_system::instance());
@@ -501,7 +502,7 @@ class api {
      * Take the data from the mform and create the endpoint.
      *
      * @param stdClass $data
-     * @return core\oauth2\endpoint
+     * @return \core\oauth2\endpoint
      */
     public static function create_endpoint($data) {
         require_capability('moodle/site:config', context_system::instance());
@@ -516,7 +517,7 @@ class api {
      * Take the data from the mform and update the user field mapping.
      *
      * @param stdClass $data
-     * @return core\oauth2\user_field_mapping
+     * @return \core\oauth2\user_field_mapping
      */
     public static function update_user_field_mapping($data) {
         require_capability('moodle/site:config', context_system::instance());
@@ -532,7 +533,7 @@ class api {
      * Take the data from the mform and create the user field mapping.
      *
      * @param stdClass $data
-     * @return core\oauth2\user_field_mapping
+     * @return \core\oauth2\user_field_mapping
      */
     public static function create_user_field_mapping($data) {
         require_capability('moodle/site:config', context_system::instance());
@@ -710,7 +711,7 @@ class api {
      *
      * Requires moodle/site:config capability at the system context.
      *
-     * @param core\oauth2\issuer $issuer
+     * @param \core\oauth2\issuer $issuer
      * @param moodle_url $returnurl The url to the current page (we will be redirected back here after authentication).
      * @return boolean
      */
index 528e511..07a775d 100644 (file)
@@ -30,7 +30,6 @@ require_once($CFG->libdir . '/filelib.php');
 
 use moodle_url;
 use moodle_exception;
-use curl;
 use stdClass;
 
 /**
@@ -172,6 +171,7 @@ class client extends \oauth2_client {
      *
      * @param \core\oauth2\system_account $systemaccount
      * @return boolean true if token is upgraded succesfully
+     * @throws moodle_exception Request for token upgrade failed for technical reasons
      */
     public function upgrade_refresh_token(system_account $systemaccount) {
         $refreshtoken = $systemaccount->get('refreshtoken');
@@ -233,7 +233,7 @@ class client extends \oauth2_client {
      * Fetch the user info from the user info endpoint and map all
      * the fields back into moodle fields.
      *
-     * @return array (Moodle user fields for the logged in user).
+     * @return array|false Moodle user fields for the logged in user (or false if request failed)
      */
     public function get_userinfo() {
         $url = $this->get_issuer()->get_endpoint_url('userinfo');
@@ -244,7 +244,7 @@ class client extends \oauth2_client {
         $userinfo = new stdClass();
         try {
             $userinfo = json_decode($response);
-        } catch (Exception $e) {
+        } catch (\Exception $e) {
             return false;
         }
 
index 22cb45c..66bb0d7 100644 (file)
 namespace core\oauth2;
 
 use \core\task\scheduled_task;
+use core_user;
+use moodle_exception;
 
 defined('MOODLE_INTERNAL') || die();
 
 /**
- * Simple task to delete old messaging records.
+ * Task to refresh system tokens regularly. Admins are notified in case an authorisation expires.
  * @package    core
  * @copyright  2017 Damyon Wiese
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
@@ -50,6 +52,7 @@ class refresh_system_tokens_task extends scheduled_task {
      * @param \core\oauth2\issuer $issuer
      */
     protected function notify_admins(\core\oauth2\issuer $issuer) {
+        global $CFG;
         $admins = get_admins();
 
         if (empty($admins)) {
@@ -62,8 +65,8 @@ class refresh_system_tokens_task extends scheduled_task {
             $message = new \core\message\message();
             $message->courseid          = SITEID;
             $message->component         = 'moodle';
-            $message->name              = 'oauthrefreshtokenexpired';
-            $message->userfrom          = core\user::get_noreply_user();
+            $message->name              = 'errors';
+            $message->userfrom          = core_user::get_noreply_user();
             $message->userto            = $admin;
             $message->subject           = $short;
             $message->fullmessage       = $long;
@@ -84,7 +87,14 @@ class refresh_system_tokens_task extends scheduled_task {
         $issuers = \core\oauth2\api::get_all_issuers();
         foreach ($issuers as $issuer) {
             if ($issuer->is_system_account_connected()) {
-                if (!\core\oauth2\api::get_system_oauth_client($issuer)) {
+                try {
+                    // Try to get an authenticated client; renew token if necessary.
+                    // Returns false or throws a moodle_exception on error.
+                    $success = \core\oauth2\api::get_system_oauth_client($issuer);
+                } catch (moodle_exception $e) {
+                    $success = false;
+                }
+                if ($success === false) {
                     $this->notify_admins($issuer);
                 }
             }
index ca48a35..b6d1af3 100644 (file)
@@ -25,6 +25,7 @@ namespace core\oauth2;
 
 use curl;
 use coding_exception;
+use stdClass;
 
 defined('MOODLE_INTERNAL') || die();
 
@@ -64,7 +65,8 @@ abstract class rest {
      * @param string $functionname
      * @param array $functionargs
      * @param string $rawpost Optional param to include in the body of a post.
-     * @return string|object
+     * @param string $contenttype The MIME type for the request's Content-Type header.
+     * @return string|stdClass
      */
     public function call($functionname, $functionargs, $rawpost = false, $contenttype = false) {
         $functions = $this->get_api_functions();
index 399b613..d35b5a8 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20170801" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20170814" COMMENT="XMLDB file for core Moodle tables"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
 >
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="modelid" TYPE="foreign" FIELDS="modelid" REFTABLE="analytics_models" REFFIELDS="id"/>
       </KEYS>
-      <INDEXES>
-        <INDEX NAME="modelid" UNIQUE="false" FIELDS="modelid" COMMENT="Index on modelid"/>
-      </INDEXES>
     </TABLE>
     <TABLE NAME="analytics_predictions" COMMENT="Predictions">
       <FIELDS>
         <FIELD NAME="contextid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="sampleid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="rangeindex" TYPE="int" LENGTH="5" NOTNULL="true" SEQUENCE="false"/>
-        <FIELD NAME="prediction" TYPE="int" LENGTH="2" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="prediction" TYPE="number" LENGTH="10" NOTNULL="true" SEQUENCE="false" DECIMALS="2"/>
         <FIELD NAME="predictionscore" TYPE="number" LENGTH="10" NOTNULL="true" SEQUENCE="false" DECIMALS="5"/>
         <FIELD NAME="calculations" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="modelid" TYPE="foreign" FIELDS="modelid" REFTABLE="analytics_models" REFFIELDS="id"/>
+        <KEY NAME="contextid" TYPE="foreign" FIELDS="contextid" REFTABLE="context" REFFIELDS="id"/>
       </KEYS>
       <INDEXES>
         <INDEX NAME="modelidandcontextid" UNIQUE="false" FIELDS="modelid, contextid" COMMENT="Index on modelid and contextid"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="modelid" TYPE="foreign" FIELDS="modelid" REFTABLE="analytics_models" REFFIELDS="id"/>
+        <KEY NAME="fileid" TYPE="foreign" FIELDS="fileid" REFTABLE="files" REFFIELDS="id"/>
       </KEYS>
       <INDEXES>
         <INDEX NAME="modelidandanalysableidandtimesplitting" UNIQUE="false" FIELDS="modelid, analysableid, timesplitting" COMMENT="Index on modelid and analysableid and timesplitting"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="modelid" TYPE="foreign" FIELDS="modelid" REFTABLE="analytics_models" REFFIELDS="id"/>
       </KEYS>
       <INDEXES>
         <INDEX NAME="modelidandanalysableidandtimesplittingandrangeindex" UNIQUE="false" FIELDS="modelid, analysableid, timesplitting, rangeindex" COMMENT="Index on modelid and analysableid and timesplitting"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="modelid" TYPE="foreign" FIELDS="modelid" REFTABLE="analytics_models" REFFIELDS="id"/>
+        <KEY NAME="fileid" TYPE="foreign" FIELDS="fileid" REFTABLE="files" REFFIELDS="id"/>
       </KEYS>
       <INDEXES>
         <INDEX NAME="modelidandfileidandaction" UNIQUE="false" FIELDS="modelid, fileid, action" COMMENT="Index on modelid and fileid and action"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="contextid" TYPE="foreign" FIELDS="contextid" REFTABLE="context" REFFIELDS="id"/>
       </KEYS>
       <INDEXES>
         <INDEX NAME="starttime-endtime-contextid" UNIQUE="false" FIELDS="starttime, endtime, contextid"/>
index fcc7154..945b3bf 100644 (file)
@@ -2369,5 +2369,72 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2017082300.01);
     }
 
+    if ($oldversion < 2017082500.00) {
+        // Handle FKs for the table 'analytics_models_log'.
+        $table = new xmldb_table('analytics_models_log');
+
+        // Remove the existing index before adding FK (which creates an index).
+        $index = new xmldb_index('modelid', XMLDB_INDEX_NOTUNIQUE, array('modelid'));
+
+        // Conditionally launch drop index.
+        if ($dbman->index_exists($table, $index)) {
+            $dbman->drop_index($table, $index);
+        }
+
+        // Now, add the FK.
+        $key = new xmldb_key('modelid', XMLDB_KEY_FOREIGN, array('modelid'), 'analytics_models', array('id'));
+        $dbman->add_key($table, $key);
+
+        // Handle FKs for the table 'analytics_predictions'.
+        $table = new xmldb_table('analytics_predictions');
+        $key = new xmldb_key('modelid', XMLDB_KEY_FOREIGN, array('modelid'), 'analytics_models', array('id'));
+        $dbman->add_key($table, $key);
+
+        $key = new xmldb_key('contextid', XMLDB_KEY_FOREIGN, array('contextid'), 'context', array('id'));
+        $dbman->add_key($table, $key);
+
+        // Handle FKs for the table 'analytics_train_samples'.
+        $table = new xmldb_table('analytics_train_samples');
+        $key = new xmldb_key('modelid', XMLDB_KEY_FOREIGN, array('modelid'), 'analytics_models', array('id'));
+        $dbman->add_key($table, $key);
+
+        $key = new xmldb_key('fileid', XMLDB_KEY_FOREIGN, array('fileid'), 'files', array('id'));
+        $dbman->add_key($table, $key);
+
+        // Handle FKs for the table 'analytics_predict_samples'.
+        $table = new xmldb_table('analytics_predict_samples');
+        $key = new xmldb_key('modelid', XMLDB_KEY_FOREIGN, array('modelid'), 'analytics_models', array('id'));
+        $dbman->add_key($table, $key);
+
+        // Handle FKs for the table 'analytics_used_files'.
+        $table = new xmldb_table('analytics_used_files');
+        $key = new xmldb_key('modelid', XMLDB_KEY_FOREIGN, array('modelid'), 'analytics_models', array('id'));
+        $dbman->add_key($table, $key);
+
+        $key = new xmldb_key('fileid', XMLDB_KEY_FOREIGN, array('fileid'), 'files', array('id'));
+        $dbman->add_key($table, $key);
+
+        // Handle FKs for the table 'analytics_indicator_calc'.
+        $table = new xmldb_table('analytics_indicator_calc');
+        $key = new xmldb_key('contextid', XMLDB_KEY_FOREIGN, array('contextid'), 'context', array('id'));
+        $dbman->add_key($table, $key);
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2017082500.00);
+    }
+
+    if ($oldversion < 2017082800.00) {
+
+        // Changing type of field prediction on table analytics_predictions to number.
+        $table = new xmldb_table('analytics_predictions');
+        $field = new xmldb_field('prediction', XMLDB_TYPE_NUMBER, '10, 2', null, XMLDB_NOTNULL, null, null, 'rangeindex');
+
+        // Launch change of type for field prediction.
+        $dbman->change_field_type($table, $field);
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2017082800.00);
+    }
+
     return true;
 }
index 4b74d41..05cac2d 100644 (file)
@@ -731,11 +731,22 @@ function file_get_drafarea_files($draftitemid, $filepath = '/') {
                 $item->url = $itemurl->out();
                 $item->icon = $OUTPUT->image_url(file_file_icon($file, 24))->out(false);
                 $item->thumbnail = $OUTPUT->image_url(file_file_icon($file, 90))->out(false);
-                if ($imageinfo = $file->get_imageinfo()) {
-                    $item->realthumbnail = $itemurl->out(false, array('preview' => 'thumb', 'oid' => $file->get_timemodified()));
-                    $item->realicon = $itemurl->out(false, array('preview' => 'tinyicon', 'oid' => $file->get_timemodified()));
-                    $item->image_width = $imageinfo['width'];
-                    $item->image_height = $imageinfo['height'];
+
+                // The call to $file->get_imageinfo() fails with an exception if the file can't be read on the file system.
+                // We still want to add such files to the list, so the owner can view and delete them if needed. So, we only call
+                // get_imageinfo() on files that can be read, and we also spoof the file status based on whether it was found.
+                // We'll use the same status types used by stored_file->get_status(), where 0 = OK. 1 = problem, as these will be
+                // used by the widget to display a warning about the problem files.
+                // The value of stored_file->get_status(), and the file record are unaffected by this. It's only superficially set.
+                $item->status = $fs->get_file_system()->is_file_readable_remotely_by_storedfile($file) ? 0 : 1;
+                if ($item->status == 0) {
+                    if ($imageinfo = $file->get_imageinfo()) {
+                        $item->realthumbnail = $itemurl->out(false, array('preview' => 'thumb',
+                            'oid' => $file->get_timemodified()));
+                        $item->realicon = $itemurl->out(false, array('preview' => 'tinyicon', 'oid' => $file->get_timemodified()));
+                        $item->image_width = $imageinfo['width'];
+                        $item->image_height = $imageinfo['height'];
+                    }
                 }
             }
             $list[] = $item;
index fb2d0eb..7a6644e 100644 (file)
@@ -228,7 +228,7 @@ class MoodleQuickForm_date_time_selector extends MoodleQuickForm_group {
                 }
                 break;
             case 'createElement':
-                if ($arg[2]['optional']) {
+                if (isset($arg[2]['optional']) && $arg[2]['optional']) {
                     // When using the function addElement, rather than createElement, we still
                     // enter this case, making this check necessary.
                     if ($this->_usedcreateelement) {
index 217fc4a..3c92da3 100644 (file)
Binary files a/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-debug.js and b/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-debug.js differ
index ca09d67..238b10e 100644 (file)
Binary files a/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-min.js and b/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-min.js differ
index 217fc4a..3c92da3 100644 (file)
Binary files a/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector.js and b/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector.js differ
index bf84531..12b7eee 100644 (file)
@@ -1,5 +1,10 @@
 /* global CALENDAR, MOODLECALENDAR */
 
+var DIALOGUE_SELECTOR = ' [role=dialog]',
+    MENUBAR_SELECTOR = '[role=menubar]',
+    DOT = '.',
+    HAS_ZINDEX = 'moodle-has-zindex';
+
 /**
  * Add some custom methods to the node class to make our lives a little
  * easier within this module.
@@ -71,10 +76,19 @@ M.form.dateselector = {
             constrain: true // constrain panel to viewport.
         });
         this.panel.render(document.body);
-        // zIndex is added by panel.render() and is set to 0.
-        // Remove zIndex from panel, as this should be set by CSS. This can be done by removeAttr but
-        // ie8 fails and there is know issue for it.
-        Y.one('#dateselector-calendar-panel').setStyle('zIndex', null);
+
+        // Determine the correct zindex by looking at all existing dialogs and menubars in the page.
+        var highestzindex = 0;
+        Y.all(DIALOGUE_SELECTOR + ', ' + MENUBAR_SELECTOR + ', ' + DOT + HAS_ZINDEX).each(function(node) {
+            var zindex = this.findZIndex(node);
+            if (zindex > highestzindex) {
+                highestzindex = zindex;
+            }
+        }, this);
+        // Only set the zindex if we found a wrapper.
+        var zindexvalue = (highestzindex + 1).toString();
+        Y.one('#dateselector-calendar-panel').setStyle('zIndex', zindexvalue);
+
         this.panel.on('heightChange', this.fix_position, this);
 
         Y.one('#dateselector-calendar-panel').on('click', function(e) {
@@ -98,6 +112,14 @@ M.form.dateselector = {
                 config.sat]
         });
     },
+    findZIndex: function(node) {
+        // In most cases the zindex is set on the parent of the dialog.
+        var zindex = node.getStyle('zIndex') || node.ancestor().getStyle('zIndex');
+        if (zindex) {
+            return parseInt(zindex, 10);
+        }
+        return 0;
+    },
     cancel_any_timeout: function() {
         if (this.hidetimeout) {
             clearTimeout(this.hidetimeout);
index 66527b6..0a2293a 100644 (file)
@@ -1390,6 +1390,27 @@ abstract class moodleform {
             $_POST = $simulatedsubmitteddata;
         }
     }
+
+    /**
+     * Used by tests to generate valid submit keys for moodle forms that are
+     * submitted with ajax data.
+     *
+     * @throws \moodle_exception If called outside unit test environment
+     * @param array  $data Existing form data you wish to add the keys to.
+     * @return array
+     */
+    public static function mock_generate_submit_keys($data = []) {
+        if (!defined('PHPUNIT_TEST') || !PHPUNIT_TEST) {
+            throw new \moodle_exception("This function can only be used for unit testing.");
+        }
+
+        $formidentifier = get_called_class();
+        $formidentifier = str_replace('\\', '_', $formidentifier); // See MDL-56233 for more information.
+        $data['sesskey'] = sesskey();
+        $data['_qf__' . $formidentifier] = 1;
+
+        return $data;
+    }
 }
 
 /**
index 1981102..99458a1 100644 (file)
@@ -38,7 +38,7 @@ use Phpml\ModelManager;
  * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class processor implements \core_analytics\predictor {
+class processor implements \core_analytics\classifier, \core_analytics\regressor {
 
     /**
      * Size of training / prediction batches.
@@ -73,14 +73,14 @@ class processor implements \core_analytics\predictor {
     }
 
     /**
-     * Trains a machine learning algorithm with the provided training set.
+     * Train this processor classification model using the provided supervised learning dataset.
      *
      * @param string $uniqueid
      * @param \stored_file $dataset
      * @param string $outputdir
      * @return \stdClass
      */
-    public function train($uniqueid, \stored_file $dataset, $outputdir) {
+    public function train_classification($uniqueid, \stored_file $dataset, $outputdir) {
 
         // Output directory is already unique to the model.
         $modelfilepath = $outputdir . DIRECTORY_SEPARATOR . self::MODEL_FILENAME;
@@ -134,14 +134,14 @@ class processor implements \core_analytics\predictor {
     }
 
     /**
-     * Predicts the provided samples
+     * Classifies the provided dataset samples.
      *
      * @param string $uniqueid
      * @param \stored_file $dataset
      * @param string $outputdir
      * @return \stdClass
      */
-    public function predict($uniqueid, \stored_file $dataset, $outputdir) {
+    public function classify($uniqueid, \stored_file $dataset, $outputdir) {
 
         // Output directory is already unique to the model.
         $modelfilepath = $outputdir . DIRECTORY_SEPARATOR . self::MODEL_FILENAME;
@@ -199,7 +199,7 @@ class processor implements \core_analytics\predictor {
     }
 
     /**
-     * Evaluates the provided dataset.
+     * Evaluates this processor classification model using the provided supervised learning dataset.
      *
      * During evaluation we need to shuffle the evaluation dataset samples to detect deviated results,
      * if the dataset is massive we can not load everything into memory. We know that 2GB is the
@@ -216,7 +216,7 @@ class processor implements \core_analytics\predictor {
      * @param string $outputdir
      * @return \stdClass
      */
-    public function evaluate($uniqueid, $maxdeviation, $niterations, \stored_file $dataset, $outputdir) {
+    public function evaluate_classification($uniqueid, $maxdeviation, $niterations, \stored_file $dataset, $outputdir) {
         $fh = $dataset->get_content_file_handle();
 
         // The first lines are var names and the second one values.
@@ -351,6 +351,47 @@ class processor implements \core_analytics\predictor {
         return $resultobj;
     }
 
+    /**
+     * Train this processor regression model using the provided supervised learning dataset.
+     *
+     * @throws new \coding_exception
+     * @param string $uniqueid
+     * @param \stored_file $dataset
+     * @param string $outputdir
+     * @return \stdClass
+     */
+    public function train_regression($uniqueid, \stored_file $dataset, $outputdir) {
+        throw new \coding_exception('This predictor does not support regression yet.');
+    }
+
+    /**
+     * Estimates linear values for the provided dataset samples.
+     *
+     * @throws new \coding_exception
+     * @param string $uniqueid
+     * @param \stored_file $dataset
+     * @param mixed $outputdir
+     * @return void
+     */
+    public function estimate($uniqueid, \stored_file $dataset, $outputdir) {
+        throw new \coding_exception('This predictor does not support regression yet.');
+    }
+
+    /**
+     * Evaluates this processor regression model using the provided supervised learning dataset.
+     *
+     * @throws new \coding_exception
+     * @param string $uniqueid
+     * @param float $maxdeviation
+     * @param int $niterations
+     * @param \stored_file $dataset
+     * @param string $outputdir
+     * @return \stdClass
+     */
+    public function evaluate_regression($uniqueid, $maxdeviation, $niterations, \stored_file $dataset, $outputdir) {
+        throw new \coding_exception('This predictor does not support regression yet.');
+    }
+
     /**
      * Returns the Phi correlation coefficient.
      *
index 5b05fa6..84889e2 100644 (file)
@@ -33,7 +33,7 @@ defined('MOODLE_INTERNAL') || die();
  * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class processor implements \core_analytics\predictor {
+class processor implements  \core_analytics\classifier, \core_analytics\regressor {
 
     /**
      * The required version of the python package that performs all calculations.
@@ -79,7 +79,7 @@ class processor implements \core_analytics\predictor {
      * @param string $outputdir
      * @return \stdClass
      */
-    public function train($uniqueid, \stored_file $dataset, $outputdir) {
+    public function train_classification($uniqueid, \stored_file $dataset, $outputdir) {
 
         // Obtain the physical route to the file.
         $datasetpath = $this->get_file_path($dataset);
@@ -113,14 +113,14 @@ class processor implements \core_analytics\predictor {
     }
 
     /**
-     * Returns predictions for the provided dataset samples.
+     * Classifies the provided dataset samples.
      *
      * @param string $uniqueid
      * @param \stored_file $dataset
      * @param string $outputdir
      * @return \stdClass
      */
-    public function predict($uniqueid, \stored_file $dataset, $outputdir) {
+    public function classify($uniqueid, \stored_file $dataset, $outputdir) {
 
         // Obtain the physical route to the file.
         $datasetpath = $this->get_file_path($dataset);
@@ -154,7 +154,7 @@ class processor implements \core_analytics\predictor {
     }
 
     /**
-     * Evaluates the provided dataset.
+     * Evaluates this processor classification model using the provided supervised learning dataset.
      *
      * @param string $uniqueid
      * @param float $maxdeviation
@@ -163,7 +163,7 @@ class processor implements \core_analytics\predictor {
      * @param string $outputdir
      * @return \stdClass
      */
-    public function evaluate($uniqueid, $maxdeviation, $niterations, \stored_file $dataset, $outputdir) {
+    public function evaluate_classification($uniqueid, $maxdeviation, $niterations, \stored_file $dataset, $outputdir) {
 
         // Obtain the physical route to the file.
         $datasetpath = $this->get_file_path($dataset);
@@ -195,6 +195,47 @@ class processor implements \core_analytics\predictor {
         return $resultobj;
     }
 
+    /**
+     * Train this processor regression model using the provided supervised learning dataset.
+     *
+     * @throws new \coding_exception
+     * @param string $uniqueid
+     * @param \stored_file $dataset
+     * @param string $outputdir
+     * @return \stdClass
+     */
+    public function train_regression($uniqueid, \stored_file $dataset, $outputdir) {
+        throw new \coding_exception('This predictor does not support regression yet.');
+    }
+
+    /**
+     * Estimates linear values for the provided dataset samples.
+     *
+     * @throws new \coding_exception
+     * @param string $uniqueid
+     * @param \stored_file $dataset
+     * @param mixed $outputdir
+     * @return void
+     */
+    public function estimate($uniqueid, \stored_file $dataset, $outputdir) {
+        throw new \coding_exception('This predictor does not support regression yet.');
+    }
+
+    /**
+     * Evaluates this processor regression model using the provided supervised learning dataset.
+     *
+     * @throws new \coding_exception
+     * @param string $uniqueid
+     * @param float $maxdeviation
+     * @param int $niterations
+     * @param \stored_file $dataset
+     * @param string $outputdir
+     * @return \stdClass
+     */
+    public function evaluate_regression($uniqueid, $maxdeviation, $niterations, \stored_file $dataset, $outputdir) {
+        throw new \coding_exception('This predictor does not support regression yet.');
+    }
+
     /**
      * Returns the path to the dataset file.
      *
diff --git a/lib/templates/overlay_loading.mustache b/lib/templates/overlay_loading.mustache
new file mode 100644 (file)
index 0000000..4cf2a2d
--- /dev/null
@@ -0,0 +1,38 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core/overlay_loading
+
+    Moodle overlay loading template.
+
+    The purpose of this template is to render a loading animation using a overlay effect.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    *
+
+    Example context (json):
+    {}
+}}
+<span class="overlay-icon-container hidden" data-region="overlay-icon-container">
+    {{> core/loading }}
+</span>
index 9b5d2c7..582602f 100644 (file)
@@ -532,7 +532,7 @@ class behat_forms extends behat_base {
      * @param string $item
      */
     public function i_click_on_item_in_the_autocomplete_list($item) {
-        $xpathtarget = "//ul[@class='form-autocomplete-suggestions']//*[contains(.,'" . $item . "')]";
+        $xpathtarget = "//ul[@class='form-autocomplete-suggestions']//*[contains(concat('|', string(.), '|'),'|" . $item . "|')]";
 
         $this->execute('behat_general::i_click_on', [$xpathtarget, 'xpath_element']);
 
index 981ad27..0146519 100644 (file)
@@ -240,7 +240,10 @@ class restore_assign_activity_structure_step extends restore_activity_structure_
             $flags->userid = $this->get_mappingid('user', $data->userid);
             $DB->insert_record('assign_user_flags', $flags);
         }
-
+        // Fix null grades that were rescaled.
+        if ($data->grade < 0 && $data->grade != ASSIGN_GRADE_NOT_SET) {
+            $data->grade = ASSIGN_GRADE_NOT_SET;
+        }
         $newitemid = $DB->insert_record('assign_grades', $data);
 
         // Note - the old contextid is required in order to be able to restore files stored in
index 98aa899..f75dd47 100644 (file)
@@ -46,22 +46,11 @@ class cognitive_depth extends activity_base {
         return new \lang_string('indicator:cognitivedepth', 'mod_assign');
     }
 
-    /**
-     * get_indicator_type
-     *
-     * @return string
-     */
-    protected function get_indicator_type() {
+    public function get_indicator_type() {
         return self::INDICATOR_COGNITIVE;
     }
 
-    /**
-     * get_cognitive_depth_level
-     *
-     * @param \cm_info $cm
-     * @return int
-     */
-    protected function get_cognitive_depth_level(\cm_info $cm) {
+    public function get_cognitive_depth_level(\cm_info $cm) {
         return 5;
     }
 
index 7874285..66f0d48 100644 (file)
@@ -46,22 +46,11 @@ class social_breadth extends activity_base {
         return new \lang_string('indicator:socialbreadth', 'mod_assign');
     }
 
-    /**
-     * get_indicator_type
-     *
-     * @return string
-     */
-    protected function get_indicator_type() {
+    public function get_indicator_type() {
         return self::INDICATOR_SOCIAL;
     }
 
-    /**
-     * get_social_breadth_level
-     *
-     * @param \cm_info $cm
-     * @return int
-     */
-    protected function get_social_breadth_level(\cm_info $cm) {
+    public function get_social_breadth_level(\cm_info $cm) {
         return 2;
     }
 }
index f96c369..4a564ad 100644 (file)
@@ -155,5 +155,18 @@ function xmldb_assign_upgrade($oldversion) {
         upgrade_mod_savepoint(true, 2017061200, 'assign');
     }
 
+    if ($oldversion < 2017061205) {
+        require_once($CFG->dirroot.'/mod/assign/upgradelib.php');
+        $brokenassigns = get_assignments_with_rescaled_null_grades();
+
+        // Set config value.
+        foreach ($brokenassigns as $assign) {
+            set_config('has_rescaled_null_grades_' . $assign, 1, 'assign');
+        }
+
+        // Main savepoint reached.
+        upgrade_mod_savepoint(true, 2017061205, 'assign');
+    }
+
     return true;
 }
index bf19d14..91fc23b 100644 (file)
@@ -106,6 +106,7 @@ class combined_document {
                     case \core_files\conversion::STATUS_IN_PROGRESS:
                     case \core_files\conversion::STATUS_PENDING:
                         $pending = true;
+                        break;
 
                     case \core_files\conversion::STATUS_FAILED:
                         return self::STATUS_FAILED;
index 921c54c..e6c042d 100644 (file)
@@ -196,6 +196,9 @@ $string['expandreviewpanel'] = 'Expand review panel';
 $string['extensionduedate'] = 'Extension due date';
 $string['extensionnotafterduedate'] = 'Extension date must be after the due date';
 $string['extensionnotafterfromdate'] = 'Extension date must be after the allow submissions from date';
+$string['fixrescalednullgrades'] = 'This assignment contains some erroneous grades. You can <a href="{$a->link}">automatically fix these grades</a>. This may affect course totals.';
+$string['fixrescalednullgradesconfirm'] = 'Are you sure you want to fix erroneous grades? All affected grades will be removed. This may affect course totals.';
+$string['fixrescalednullgradesdone'] = 'Grades fixed.';
 $string['gradecanbechanged'] = 'Grade can be changed';
 $string['gradersubmissionupdatedtext'] = '{$a->username} has updated their assignment submission
 for \'{$a->assignment}\' at {$a->timeupdated}
index 7c6b896..4e7c7ec 100644 (file)
@@ -1585,7 +1585,8 @@ function assign_rescale_activity_grades($course, $cm, $oldmin, $oldmax, $newmin,
         'a' => $cm->instance
     );
 
-    $sql = 'UPDATE {assign_grades} set grade = (((grade - :p1) * :p2) + :p3) where assignment = :a';
+    // Only rescale grades that are greater than or equal to 0. Anything else is a special value.
+    $sql = 'UPDATE {assign_grades} set grade = (((grade - :p1) * :p2) + :p3) where assignment = :a and grade >= 0';
     $dbupdate = $DB->execute($sql, $params);
     if (!$dbupdate) {
         return false;
index fab4a6b..14103ad 100644 (file)
@@ -50,6 +50,9 @@ define('ASSIGN_ATTEMPT_REOPEN_METHOD_UNTILPASS', 'untilpass');
 // Special value means allow unlimited attempts.
 define('ASSIGN_UNLIMITED_ATTEMPTS', -1);
 
+// Special value means no grade has been set.
+define('ASSIGN_GRADE_NOT_SET', -1);
+
 // Grading states.
 define('ASSIGN_GRADING_STATUS_GRADED', 'graded');
 define('ASSIGN_GRADING_STATUS_NOT_GRADED', 'notgraded');
@@ -600,6 +603,8 @@ class assign {
             $o .= $this->view_batch_markingallocation($mform);
         } else if ($action == 'viewsubmitforgradingerror') {
             $o .= $this->view_error_page(get_string('submitforgrading', 'assign'), $notices);
+        } else if ($action == 'fixrescalednullgrades') {
+            $o .= $this->view_fix_rescaled_null_grades();
         } else {
             $o .= $this->view_submission_page();
         }
@@ -4234,6 +4239,8 @@ class assign {
         $this->require_view_grades();
         require_once($CFG->dirroot . '/mod/assign/gradeform.php');
 
+        $this->add_grade_notices();
+
         // Only load this if it is.
         $o .= $this->view_grading_table();
 
@@ -5207,6 +5214,8 @@ class assign {
 
         $instance = $this->get_instance();
 
+        $this->add_grade_notices();
+
         $o = '';
 
         $postfix = '';
@@ -8540,6 +8549,92 @@ class assign {
         $completion = new completion_info($this->get_course());
         $completion->set_module_viewed($this->get_course_module());
     }
+
+    /**
+     * Checks for any grade notices, and adds notifications. Will display on assignment main page and grading table.
+     *
+     * @return void The notifications API will render the notifications at the appropriate part of the page.
+     */
+    protected function add_grade_notices() {
+        if (has_capability('mod/assign:grade', $this->get_context()) && get_config('assign', 'has_rescaled_null_grades_' . $this->get_instance()->id)) {
+            $link = new \moodle_url('/mod/assign/view.php', array('id' => $this->get_course_module()->id, 'action' => 'fixrescalednullgrades'));
+            \core\notification::warning(get_string('fixrescalednullgrades', 'mod_assign', ['link' => $link->out()]));
+        }
+    }
+
+    /**
+     * View fix rescaled null grades.
+     *
+     * @return bool True if null all grades are now fixed.
+     */
+    protected function fix_null_grades() {
+        global $DB;
+        $result = $DB->set_field_select(
+            'assign_grades',
+            'grade',
+            ASSIGN_GRADE_NOT_SET,
+            'grade <> ? AND grade < 0',
+            [ASSIGN_GRADE_NOT_SET]
+        );
+        $assign = clone $this->get_instance();
+        $assign->cmidnumber = $this->get_course_module()->idnumber;
+        assign_update_grades($assign);
+        return $result;
+    }
+
+    /**
+     * View fix rescaled null grades.
+     *
+     * @return void The notifications API will render the notifications at the appropriate part of the page.
+     */
+    protected function view_fix_rescaled_null_grades() {
+        global $OUTPUT;
+
+        $o = '';
+
+        require_capability('mod/assign:grade', $this->get_context());
+
+        $instance = $this->get_instance();
+
+        $o .= $this->get_renderer()->render(
+            new assign_header(
+                $instance,
+                $this->get_context(),
+                $this->show_intro(),
+                $this->get_course_module()->id
+            )
+        );
+
+        $confirm = optional_param('confirm', 0, PARAM_BOOL);
+
+        if ($confirm) {
+            confirm_sesskey();
+
+            // Fix the grades.
+            $this->fix_null_grades();
+            unset_config('has_rescaled_null_grades_' . $instance->id, 'assign');
+
+            // Display the notice.
+            $o .= $this->get_renderer()->notification(get_string('fixrescalednullgradesdone', 'assign'), 'notifysuccess');
+            $url = new moodle_url(
+                '/mod/assign/view.php',
+                array(
+                    'id' => $this->get_course_module()->id,
+                    'action' => 'grading'
+                )
+            );
+            $o .= $this->get_renderer()->continue_button($url);
+        } else {
+            // Ask for confirmation.
+            $continue = new \moodle_url('/mod/assign/view.php', array('id' => $this->get_course_module()->id, 'action' => 'fixrescalednullgrades', 'confirm' => true, 'sesskey' => sesskey()));
+            $cancel = new \moodle_url('/mod/assign/view.php', array('id' => $this->get_course_module()->id));
+            $o .= $OUTPUT->confirm(get_string('fixrescalednullgradesconfirm', 'mod_assign'), $continue, $cancel);
+        }
+
+        $o .= $this->view_footer();
+
+        return $o;
+    }
 }
 
 /**
index 09666b0..71caab6 100644 (file)
@@ -48,15 +48,6 @@ function assignsubmission_comments_comment_validate(stdClass $options) {
     if ($assignment->get_instance()->id != $submission->assignment) {
         throw new comment_exception('invalidcontext');
     }
-    $canview = false;
-    if ($submission->userid) {
-        $canview = $assignment->can_view_submission($submission->userid);
-    } else {
-        $canview = $assignment->can_view_group_submission($submission->groupid);
-    }
-    if (!$canview) {
-        throw new comment_exception('nopermissiontocomment');
-    }
 
     return true;
 }
index 16ac94d..d3b4a3b 100644 (file)
@@ -69,7 +69,6 @@ class assign_submission_comments extends assign_submission_plugin {
         $options->displaycancel = true;
 
         $comment = new comment($options);
-        $comment->set_view_permission(true);
 
         $o = $this->assignment->get_renderer()->container($comment->output(true), 'commentscontainer');
         return $o;
diff --git a/mod/assign/tests/behat/assign_comments_no_error.feature b/mod/assign/tests/behat/assign_comments_no_error.feature
new file mode 100644 (file)
index 0000000..96e6fb3
--- /dev/null
@@ -0,0 +1,26 @@
+@mod @mod_assign
+Feature: Switch role does not cause an error message in assignsubmission_comments
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname |
+      | Course 1 | C1        |
+    And the following "users" exist:
+      | username |
+      | teacher1 |
+    And the following "course enrolments" exist:
+      | course | user     | role           |
+      | C1     | teacher1 | editingteacher |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I turn editing mode on
+    And I add a "Assignment" to section "1" and I fill the form with:
+      | Assignment name           | Test assignment              |
+      | Description               | This is the description text |
+      | Students submit in groups | Yes                          |
+
+  Scenario: I switch role to student and an error doesn't occur
+    When I follow "Switch role to..." in the user menu
+    And I press "Student"
+    And I follow "Test assignment"
+    Then I should see "This is the description text"
index 75b0701..ddb7609 100644 (file)
@@ -12,10 +12,12 @@ Feature: Check that the assignment grade can be rescaled when the max grade is c
       | username | firstname | lastname | email |
       | teacher1 | Teacher | 1 | teacher1@example.com |
       | student1 | Student | 1 | student10@example.com |
+      | student2 | Student | 2 | student10@example.com |
     And the following "course enrolments" exist:
       | user | course | role |
       | teacher1 | C1 | editingteacher |
       | student1 | C1 | student |
+      | student2 | C1 | student |
     And the following "groups" exist:
       | name | course | idnumber |
       | Group 1 | C1 | G1 |
@@ -69,3 +71,17 @@ Feature: Check that the assignment grade can be rescaled when the max grade is c
     When I press "Save and display"
     And I navigate to "View all submissions" in current page administration
     Then "Student 1" row "Grade" column of "generaltable" table should contain "20.00"
+
+  Scenario: Rescaling should not produce negative grades
+    Given I navigate to "View all submissions" in current page administration
+    And I click on "Grade" "link" in the "Student 2" "table_row"
+    And I wait until the page is ready
+    And I follow "Assignment: Test assignment name"
+    And I navigate to "Edit settings" in current page administration
+    And I expand all fieldsets
+    And I set the field "Rescale existing grades" to "Yes"
+    And I set the field "Maximum grade" to "50"
+    When I press "Save and display"
+    And I navigate to "View all submissions" in current page administration
+    # Make sure the student did not receive a negative grade.
+    Then "Student 2" row "Grade" column of "generaltable" table should not contain "-0.50"
index 3603c76..322cb85 100644 (file)
@@ -655,4 +655,33 @@ class mod_assign_lib_testcase extends mod_assign_base_testcase {
         $this->assertEquals(mod_assign_get_completion_active_rule_descriptions($moddefaults), $activeruledescriptions);
         $this->assertEquals(mod_assign_get_completion_active_rule_descriptions(new stdClass()), []);
     }
+
+    /**
+     * Test that if some grades are not set, they are left alone and not rescaled
+     */
+    public function test_assign_rescale_activity_grades_some_unset() {
+        $this->resetAfterTest();
+
+        // As a teacher...
+        $this->setUser($this->editingteachers[0]);
+        $assign = $this->create_instance();
+
+        // Grade the student.
+        $data = ['grade' => 50];
+        $assign->testable_apply_grade_to_user((object)$data, $this->students[0]->id, 0);
+
+        // Try getting another students grade. This will give a grade of ASSIGN_GRADE_NOT_SET (-1).
+        $assign->get_user_grade($this->students[1]->id, true);
+
+        // Rescale.
+        assign_rescale_activity_grades($this->course, $assign->get_course_module(), 0, 100, 0, 10);
+
+        // Get the grades for both students.
+        $student0grade = $assign->get_user_grade($this->students[0]->id, true);
+        $student1grade = $assign->get_user_grade($this->students[1]->id, true);
+
+        // Make sure the real grade is scaled, but the ASSIGN_GRADE_NOT_SET stays the same.
+        $this->assertEquals($student0grade->grade, 5);
+        $this->assertEquals($student1grade->grade, ASSIGN_GRADE_NOT_SET);
+    }
 }
index 193f7fa..43391b6 100644 (file)
@@ -2953,4 +2953,147 @@ Anchor link 2:<a title=\"bananas\" href=\"../logo-240x60.gif\">Link text</a>
         $completiondata = $completion->get_data($cm, false, $student2->id);
         $this->assertEquals(1, $completiondata->completionstate);
     }
+
+    public function get_assignments_with_rescaled_null_grades_provider() {
+        return [
+            'Negative less than one is errant' => [
+                'grade' => -0.64,
+                'count' => 1,
+            ],
+            'Negative more than one is errant' => [
+                'grade' => -30.18,
+                'count' => 1,
+            ],
+            'Negative one exactly is not errant' => [
+                'grade' => ASSIGN_GRADE_NOT_SET,
+                'count' => 0,
+            ],
+            'Positive grade is not errant' => [
+                'grade' => 1,
+                'count' => 0,
+            ],
+            'Large grade is not errant' => [
+                'grade' => 100,
+                'count' => 0,
+            ],
+            'Zero grade is not errant' => [
+                'grade' => 0,
+                'count' => 0,
+            ],
+        ];
+    }
+
+    /**
+     * Test determining if the assignment as any null grades that were rescaled.
+     * @dataProvider get_assignments_with_rescaled_null_grades_provider
+     */
+    public function test_get_assignments_with_rescaled_null_grades($grade, $count) {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $assign = $this->create_instance(array('grade' => 100));
+
+        // Try getting a student's grade. This will give a grade of -1.
+        // Then we can override it with a bad negative grade.
+        $assign->get_user_grade($this->students[0]->id, true);
+
+        // Set the grade to something errant.
+        $DB->set_field(
+            'assign_grades',
+            'grade',
+            $grade,
+            [
+                'userid' => $this->students[0]->id,
+                'assignment' => $assign->get_instance()->id,
+            ]
+        );
+
+        $this->assertCount($count, get_assignments_with_rescaled_null_grades());
+    }
+
+    /**
+     * Data provider for test_fix_null_grades
+     * @return array[] Test data for test_fix_null_grades. Each element should contain grade, expectedcount and gradebookvalue
+     */
+    public function fix_null_grades_provider() {
+        return [
+            'Negative less than one is errant' => [
+                'grade' => -0.64,
+                'gradebookvalue' => null,
+            ],
+            'Negative more than one is errant' => [
+                'grade' => -30.18,
+                'gradebookvalue' => null,
+            ],
+            'Negative one exactly is not errant, but shouldn\'t be pushed to gradebook' => [
+                'grade' => ASSIGN_GRADE_NOT_SET,
+                'gradebookvalue' => null,
+            ],
+            'Positive grade is not errant' => [
+                'grade' => 1,
+                'gradebookvalue' => 1,
+            ],
+            'Large grade is not errant' => [
+                'grade' => 100,
+                'gradebookvalue' => 100,
+            ],
+            'Zero grade is not errant' => [
+                'grade' => 0,
+                'gradebookvalue' => 0,
+            ],
+        ];
+    }
+
+    /**
+     * Test fix_null_grades
+     * @param number $grade The grade we should set in the assign grading table.
+     * @param number $expectedcount The finalgrade we expect in the gradebook after fixing the grades.
+     * @dataProvider fix_null_grades_provider
+     */
+    public function test_fix_null_grades($grade, $gradebookvalue) {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $studentid = $this->students[0]->id;
+
+        $assign = $this->create_instance();
+
+        // Try getting a student's grade. This will give a grade of -1.
+        // Then we can override it with a bad negative grade.
+        $assign->get_user_grade($studentid, true);
+
+        // Set the grade to something errant.
+        $DB->set_field(
+            'assign_grades',
+            'grade',
+            $grade,
+            [
+                'userid' => $studentid,
+                'assignment' => $assign->get_instance()->id,
+            ]
+        );
+        $assign->grade = $grade;
+        $assigntemp = clone $assign->get_instance();
+        $assigntemp->cmidnumber = $assign->get_course_module()->idnumber;
+        assign_update_grades($assigntemp);
+
+        // Check that the gradebook was updated with the assign grade. So we can guarentee test results later on.
+        $expectedgrade = $grade == -1 ? null : $grade; // Assign sends null to the gradebook for -1 grades.
+        $gradegrade = grade_grade::fetch(array('userid' => $studentid, 'itemid' => $assign->get_grade_item()->id));
+        $this->assertEquals($expectedgrade, $gradegrade->rawgrade);
+
+        // Call fix_null_grades().
+        $method = new ReflectionMethod(assign::class, 'fix_null_grades');
+        $method->setAccessible(true);
+        $result = $method->invoke($assign);
+
+        $this->assertSame(true, $result);
+
+        $gradegrade = grade_grade::fetch(array('userid' => $studentid, 'itemid' => $assign->get_grade_item()->id));
+
+        // Check that the grade was updated in the gradebook by fix_null_grades.
+        $this->assertEquals($gradebookvalue, $gradegrade->finalgrade);
+    }
 }
index c4fff77..10ab600 100644 (file)
@@ -414,3 +414,27 @@ class assign_upgrade_manager {
         return $newcm;
     }
 }
+
+/**
+ * Determines if the assignment as any null grades that were rescaled.
+ *
+ * Null grades are stored as -1 but should never be rescaled.
+ *
+ * @return int[] Array of the ids of all the assignments with rescaled null grades.
+ */
+function get_assignments_with_rescaled_null_grades() {
+    global $DB;
+
+    $query = 'SELECT id, assignment FROM {assign_grades}
+              WHERE grade < 0 AND grade <> -1';
+
+    $assignments = array_values($DB->get_records_sql($query));
+
+    $getassignmentid = function ($assignment) {
+        return $assignment->assignment;
+    };
+
+    $assignments = array_map($getassignmentid, $assignments);
+
+    return $assignments;
+}
index 2c7e0e0..9bcfe69 100644 (file)
@@ -25,6 +25,6 @@
 defined('MOODLE_INTERNAL') || die();
 
 $plugin->component = 'mod_assign'; // Full name of the plugin (used for diagnostics).
-$plugin->version  = 2017061200;    // The current module version (Date: YYYYMMDDXX).
+$plugin->version  = 2017061205;    // The current module version (Date: YYYYMMDDXX).
 $plugin->requires = 2017050500;    // Requires this Moodle version.
 $plugin->cron     = 60;
index d5ebbd0..33164e7 100644 (file)
@@ -34,4 +34,13 @@ defined('MOODLE_INTERNAL') || die();
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 abstract class activity_base extends \core_analytics\local\indicator\community_of_inquiry_activity {
+
+    /**
+     * No need to fetch grades for resources.
+     *
+     * @param \core_analytics\course $course
+     * @return void
+     */
+    public function fetch_student_grades(\core_analytics\course $course) {
+    }
 }
index 705a537..0fdf7c9 100644 (file)
@@ -46,22 +46,11 @@ class cognitive_depth extends activity_base {
         return new \lang_string('indicator:cognitivedepth', 'mod_book');
     }
 
-    /**
-     * get_indicator_type
-     *
-     * @return string
-     */
-    protected function get_indicator_type() {
+    public function get_indicator_type() {
         return self::INDICATOR_COGNITIVE;
     }
 
-    /**
-     * get_cognitive_depth_level
-     *
-     * @param \cm_info $cm
-     * @return int
-     */
-    protected function get_cognitive_depth_level(\cm_info $cm) {
+    public function get_cognitive_depth_level(\cm_info $cm) {
         return 1;
     }
 }
index 45ec925..e58e6a0 100644 (file)
@@ -46,22 +46,11 @@ class social_breadth extends activity_base {
         return new \lang_string('indicator:socialbreadth', 'mod_book');
     }
 
-    /**
-     * get_indicator_type
-     *
-     * @return string
-     */
-    protected function get_indicator_type() {
+    public function get_indicator_type() {
         return self::INDICATOR_SOCIAL;
     }
 
-    /**
-     * get_social_breadth_level
-     *
-     * @param \cm_info $cm
-     * @return int
-     */
-    protected function get_social_breadth_level(\cm_info $cm) {
+    public function get_social_breadth_level(\cm_info $cm) {
         return 1;
     }
 }
index 4cfaa82..4890052 100644 (file)
@@ -46,21 +46,10 @@ class cognitive_depth extends activity_base {
         return new \lang_string('indicator:cognitivedepth', 'mod_chat');
     }
 
-    /**
-     * get_indicator_type
-     *
-     * @return string
-     */
-    protected function get_indicator_type() {
+    public function get_indicator_type() {
         return self::INDICATOR_COGNITIVE;
     }