Merge branch 'MDL-65397-master-enfix' of git://github.com/mudrd8mz/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 30 Apr 2019 23:42:55 +0000 (01:42 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 30 Apr 2019 23:42:55 +0000 (01:42 +0200)
156 files changed:
admin/settings/analytics.php
analytics/classes/analysis.php
analytics/classes/local/time_splitting/periodic.php
analytics/classes/local/time_splitting/upcoming_periodic.php
analytics/tests/analysis_test.php [new file with mode: 0644]
analytics/tests/fixtures/test_timesplitting_upcoming_seconds.php [new file with mode: 0644]
backup/moodle2/backup_stepslib.php
backup/moodle2/restore_stepslib.php
badges/classes/privacy/provider.php
badges/tests/privacy_test.php
calendar/lib.php
completion/criteria/completion_criteria.php
completion/cron.php [deleted file]
course/classes/management/helper.php
course/tests/behat/behat_course.php
grade/grading/classes/privacy/gradingform_legacy_polyfill.php
grade/grading/tests/privacy_legacy_polyfill_test.php
install/lang/it_wp/langconfig.php [new file with mode: 0644]
install/lang/no_wp/langconfig.php [new file with mode: 0644]
install/lang/pt/install.php
lang/en/analytics.php
lang/en/cache.php
lang/en/moodle.php
lang/en/question.php
lib/badgeslib.php
lib/classes/event/question_base.php [new file with mode: 0644]
lib/classes/event/question_category_base.php [new file with mode: 0644]
lib/classes/event/question_category_created.php
lib/classes/event/question_category_deleted.php [new file with mode: 0644]
lib/classes/event/question_category_moved.php [new file with mode: 0644]
lib/classes/event/question_category_updated.php [new file with mode: 0644]
lib/classes/event/question_category_viewed.php [new file with mode: 0644]
lib/classes/event/question_created.php [new file with mode: 0644]
lib/classes/event/question_deleted.php [new file with mode: 0644]
lib/classes/event/question_moved.php [new file with mode: 0644]
lib/classes/event/question_updated.php [new file with mode: 0644]
lib/classes/event/question_viewed.php [new file with mode: 0644]
lib/classes/event/questions_exported.php [new file with mode: 0644]
lib/classes/event/questions_imported.php [new file with mode: 0644]
lib/classes/external/exporter.php
lib/classes/output/icon_system_fontawesome.php
lib/classes/task/completion_daily_task.php
lib/classes/task/completion_regular_task.php
lib/completionlib.php
lib/db/caches.php
lib/db/install.xml
lib/db/upgrade.php
lib/formslib.php
lib/questionlib.php
lib/tests/exporter_test.php
lib/tests/time_splittings_test.php
message/output/email/classes/output/email_digest.php
message/output/email/classes/task/send_email_task.php
message/output/email/templates/email_digest_html.mustache
message/output/email/templates/email_digest_text.mustache
mod/choice/mod_form.php
mod/data/mod_form.php
mod/feedback/classes/completion.php
mod/feedback/classes/structure.php
mod/feedback/item/feedback_item_form_class.php
mod/feedback/item/multichoice/multichoice_form.php
mod/feedback/item/multichoicerated/multichoicerated_form.php
mod/feedback/lib.php
mod/feedback/tests/behat/question_types.feature
mod/feedback/tests/behat/question_types_non_anon.feature
mod/feedback/tests/lib_test.php
mod/feedback/tests/privacy_test.php
mod/forum/amd/build/discussion_list.min.js
mod/forum/amd/build/favourite_toggle.min.js [new file with mode: 0644]
mod/forum/amd/build/pin_toggle.min.js [new file with mode: 0644]
mod/forum/amd/build/repository.min.js
mod/forum/amd/build/selectors.min.js
mod/forum/amd/src/discussion_list.js
mod/forum/amd/src/favourite_toggle.js [new file with mode: 0644]
mod/forum/amd/src/pin_toggle.js [new file with mode: 0644]
mod/forum/amd/src/repository.js
mod/forum/amd/src/selectors.js
mod/forum/classes/local/builders/exported_discussion.php [new file with mode: 0644]
mod/forum/classes/local/builders/exported_discussion_summaries.php
mod/forum/classes/local/entities/discussion.php
mod/forum/classes/local/exporters/discussion.php
mod/forum/classes/local/exporters/discussion_summaries.php
mod/forum/classes/local/exporters/discussion_summary.php
mod/forum/classes/local/factories/builder.php
mod/forum/classes/local/factories/exporter.php
mod/forum/classes/local/factories/renderer.php
mod/forum/classes/local/factories/url.php
mod/forum/classes/local/managers/capability.php
mod/forum/classes/local/renderers/discussion.php
mod/forum/classes/local/renderers/discussion_list.php
mod/forum/classes/local/vaults/author.php
mod/forum/classes/local/vaults/db_table_vault.php
mod/forum/classes/local/vaults/discussion.php
mod/forum/classes/local/vaults/discussion_list.php
mod/forum/classes/local/vaults/forum.php
mod/forum/classes/local/vaults/post.php
mod/forum/classes/local/vaults/post_read_receipt_collection.php
mod/forum/db/access.php
mod/forum/db/services.php
mod/forum/externallib.php
mod/forum/lang/en/forum.php
mod/forum/lib.php
mod/forum/mod_form.php
mod/forum/pix/t/star.png [new file with mode: 0644]
mod/forum/pix/t/star.svg [new file with mode: 0644]
mod/forum/styles.css
mod/forum/templates/discussion_favourite_toggle.mustache [new file with mode: 0644]
mod/forum/templates/discussion_list.mustache
mod/forum/templates/discussion_lock_toggle.mustache
mod/forum/templates/discussion_pin_toggle.mustache [new file with mode: 0644]
mod/forum/templates/discussion_subscription_toggle.mustache
mod/forum/templates/forum_action_menu.mustache [new file with mode: 0644]
mod/forum/templates/forum_discussion.mustache
mod/forum/templates/forum_discussion_favourite_toggle.mustache [new file with mode: 0644]
mod/forum/tests/behat/behat_mod_forum.php
mod/forum/tests/behat/discussion_lock.feature
mod/forum/tests/behat/favourite_discussion.feature [new file with mode: 0644]
mod/forum/tests/externallib_test.php
mod/forum/tests/vaults_discussion_list_test.php
mod/forum/version.php
mod/glossary/edit_form.php
mod/glossary/mod_form.php
mod/lesson/mod_form.php
mod/lti/mod_form.php
mod/page/mod_form.php
mod/quiz/mod_form.php
mod/resource/mod_form.php
mod/scorm/mod_form.php
mod/url/mod_form.php
mod/workshop/mod_form.php
pix/t/emptystar.png [new file with mode: 0644]
pix/t/emptystar.svg [new file with mode: 0644]
question/category.php
question/category_class.php
question/edit.php
question/export.php
question/format.php
question/import.php
question/preview.php
question/tests/events_test.php
question/type/multichoice/amd/build/clearchoice.min.js [new file with mode: 0644]
question/type/multichoice/amd/src/clearchoice.js [new file with mode: 0644]
question/type/multichoice/lang/en/qtype_multichoice.php
question/type/multichoice/question.php
question/type/multichoice/renderer.php
question/type/multichoice/tests/behat/preview.feature
question/type/multichoice/tests/question_multi_test.php
question/type/multichoice/tests/question_single_test.php
question/type/multichoice/tests/walkthrough_test.php
question/type/questiontypebase.php
tag/classes/external/tag_area_exporter.php
theme/boost/scss/moodle/modules.scss
theme/boost/scss/moodle/question.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
version.php

index dfbb1d6..5270745 100644 (file)
 defined('MOODLE_INTERNAL') || die();
 
 if ($hassiteconfig) {
+
+    $settings = new admin_settingpage('analyticssite', new lang_string('analyticssiteinfo', 'analytics'));
+    $ADMIN->add('analytics', $settings);
+
+    if ($ADMIN->fulltree) {
+        $modeinstructions = [
+            'facetoface' => get_string('modeinstructionfacetoface', 'analytics'),
+            'blendedhybrid' => get_string('modeinstructionblendedhybrid', 'analytics'),
+            'fullyonline' => get_string('modeinstructionfullyonline', 'analytics'),
+        ];
+        $settings->add(new admin_setting_configmultiselect('analytics/modeinstruction', get_string('modeinstruction', 'analytics'),
+            '', [], $modeinstructions));
+
+        $settings->add(new admin_setting_configtext_with_maxlength('analytics/percentonline',
+            get_string('percentonline', 'analytics'),
+            get_string('percentonline_help', 'analytics'), '', PARAM_INT, 3, 3));
+
+        $typeinstitutions = [
+            'typeinstitutionacademic' => get_string('typeinstitutionacademic', 'analytics'),
+            'typeinstitutiontraining' => get_string('typeinstitutiontraining', 'analytics'),
+            'typeinstitutionngo' => get_string('typeinstitutionngo', 'analytics'),
+        ];
+        $settings->add(new admin_setting_configmultiselect('analytics/typeinstitution', get_string('typeinstitution', 'analytics'),
+            '', [], $typeinstitutions));
+
+        $levelinstitutions = [
+            'levelinstitutionisced0' => get_string('levelinstitutionisced0', 'analytics'),
+            'levelinstitutionisced1' => get_string('levelinstitutionisced1', 'analytics'),
+            'levelinstitutionisced2' => get_string('levelinstitutionisced2', 'analytics'),
+            'levelinstitutionisced3' => get_string('levelinstitutionisced3', 'analytics'),
+            'levelinstitutionisced4' => get_string('levelinstitutionisced4', 'analytics'),
+            'levelinstitutionisced5' => get_string('levelinstitutionisced5', 'analytics'),
+            'levelinstitutionisced6' => get_string('levelinstitutionisced6', 'analytics'),
+            'levelinstitutionisced7' => get_string('levelinstitutionisced7', 'analytics'),
+            'levelinstitutionisced8' => get_string('levelinstitutionisced8', 'analytics'),
+        ];
+        $settings->add(new admin_setting_configmultiselect('analytics/levelinstitution',
+            get_string('levelinstitution', 'analytics'), '', [], $levelinstitutions));
+    }
+
     $settings = new admin_settingpage('analyticssettings', new lang_string('analyticssettings', 'analytics'));
     $ADMIN->add('analytics', $settings);
 
     if ($ADMIN->fulltree) {
+
+
         // Select the site prediction's processor.
         $predictionprocessors = \core_analytics\manager::get_all_prediction_processors();
         $predictors = array();
index bcb621c..270a9f6 100644 (file)
@@ -67,6 +67,9 @@ class analysis {
         $this->analyser = $analyser;
         $this->includetarget = $includetarget;
         $this->result = $result;
+
+        // We cache the first time analysables were analysed because time-splitting methods can depend on these info.
+        self::fill_firstanalyses_cache($this->analyser->get_modelid());
     }
 
     /**
@@ -81,10 +84,6 @@ class analysis {
         // Time limit control.
         $modeltimelimit = intval(get_config('analytics', 'modeltimelimit'));
 
-        $filesbytimesplitting = array();
-
-        $alreadyprocessedanalysables = $this->get_processed_analysables();
-
         if ($this->includetarget) {
             $action = 'training';
         } else {
@@ -92,6 +91,8 @@ class analysis {
         }
         $analysables = $this->analyser->get_analysables_iterator($action);
 
+        $processedanalysables = $this->get_processed_analysables();
+
         $inittime = microtime(true);
         foreach ($analysables as $analysable) {
             $processed = false;
@@ -121,13 +122,16 @@ class analysis {
                 }
             }
 
-            // Updated regardless of how well the analysis went.
-            if ($this->analyser->get_target()->always_update_analysis_time() || $processed) {
-                $this->update_analysable_analysed_time($alreadyprocessedanalysables, $analysable->get_id());
-            }
-
-            // Apply time limit.
             if (!$options['evaluation']) {
+
+                if (empty($processedanalysables[$analysable->get_id()]) ||
+                        $this->analyser->get_target()->always_update_analysis_time() || $processed) {
+                    // We store the list of processed analysables even if the target does not always_update_analysis_time(),
+                    // what always_update_analysis_time controls is the update of the data.
+                    $this->update_analysable_analysed_time($processedanalysables, $analysable->get_id());
+                }
+
+                // Apply time limit.
                 $timespent = microtime(true) - $inittime;
                 if ($modeltimelimit <= $timespent) {
                     break;
@@ -150,7 +154,7 @@ class analysis {
 
         // Weird select fields ordering for performance (analysableid key matching, analysableid is also unique by modelid).
         return $DB->get_records_select('analytics_used_analysables', $select,
-            $params, 'timeanalysed DESC', 'analysableid, modelid, action, timeanalysed, id AS primarykey');
+            $params, 'timeanalysed DESC', 'analysableid, modelid, action, firstanalysis, timeanalysed, id AS primarykey');
     }
 
     /**
@@ -590,13 +594,16 @@ class analysis {
     protected function update_analysable_analysed_time(array $processedanalysables, int $analysableid) {
         global $DB;
 
+        $now = time();
+
         if (!empty($processedanalysables[$analysableid])) {
             $obj = $processedanalysables[$analysableid];
 
             $obj->id = $obj->primarykey;
             unset($obj->primarykey);
 
-            $obj->timeanalysed = time();
+            $obj->timeanalysed = $now;
+
             $DB->update_record('analytics_used_analysables', $obj);
 
         } else {
@@ -605,10 +612,54 @@ class analysis {
             $obj->modelid = $this->analyser->get_modelid();
             $obj->action = ($this->includetarget) ? 'training' : 'prediction';
             $obj->analysableid = $analysableid;
-            $obj->timeanalysed = time();
+            $obj->firstanalysis = $now;
+            $obj->timeanalysed = $now;
+
+            $obj->primarykey = $DB->insert_record('analytics_used_analysables', $obj);
+
+            // Update the cache just in case it is used in the same request.
+            $key = $this->analyser->get_modelid() . '_' . $analysableid;
+            $cache = \cache::make('core', 'modelfirstanalyses');
+            $cache->set($key, $now);
+        }
+    }
+
+    /**
+     * Fills a cache containing the first time each analysable in the provided model was analysed.
+     *
+     * @param int $modelid
+     * @param int|null $analysableid
+     * @return null
+     */
+    public static function fill_firstanalyses_cache(int $modelid, ?int $analysableid = null) {
+        global $DB;
 
-            $DB->insert_record('analytics_used_analysables', $obj);
+        // Using composed keys instead of cache $identifiers because of MDL-65358.
+        $primarykey = $DB->sql_concat($modelid, "'_'", 'analysableid');
+        $sql = "SELECT $primarykey AS id, MIN(firstanalysis) AS firstanalysis
+                  FROM {analytics_used_analysables} aua
+                 WHERE modelid = :modelid";
+        $params = ['modelid' => $modelid];
+
+        if ($analysableid) {
+            $sql .= " AND analysableid = :analysableid";
+            $params['analysableid'] = $analysableid;
         }
+
+        $sql .= " GROUP BY modelid, analysableid ORDER BY analysableid";
+
+        $firstanalyses = $DB->get_records_sql($sql, $params);
+        if ($firstanalyses) {
+            $cache = \cache::make('core', 'modelfirstanalyses');
+
+            $firstanalyses = array_map(function($record) {
+                return $record->firstanalysis;
+            }, $firstanalyses);
+
+            $cache->set_many($firstanalyses);
+        }
+
+        return $firstanalyses;
     }
 
     /**
index 4a52370..891820e 100644 (file)
@@ -64,12 +64,12 @@ abstract class periodic extends base {
 
         $periodicity = $this->periodicity();
 
-        $now = new \DateTimeImmutable('now', \core_date::get_server_timezone_object());
-
         if ($this->analysable->get_end()) {
             $end = (new \DateTimeImmutable())->setTimestamp($this->analysable->get_end());
         }
-        $next = (new \DateTimeImmutable())->setTimestamp($this->analysable->get_start());
+        $next = (new \DateTimeImmutable())->setTimestamp($this->get_first_start());
+
+        $now = new \DateTimeImmutable('now', \core_date::get_server_timezone_object());
 
         $ranges = [];
         while ($next < $now &&
@@ -140,4 +140,13 @@ abstract class periodic extends base {
             'time' => $end
         ];
     }
+
+    /**
+     * Get the start of the first time range.
+     *
+     * @return int A timestamp.
+     */
+    protected function get_first_start() {
+        return $this->analysable->get_start();
+    }
 }
index 7cc4054..4960c73 100644 (file)
@@ -68,4 +68,26 @@ abstract class upcoming_periodic extends periodic {
     public function valid_for_evaluation(): bool {
         return false;
     }
+
+    /**
+     * Get the start of the first time range.
+     *
+     * Overwriten to start generating predictions about upcoming stuff from time().
+     *
+     * @return int A timestamp.
+     */
+    protected function get_first_start() {
+        global $DB;
+
+        $cache = \cache::make('core', 'modelfirstanalyses');
+
+        $key = $this->modelid . '_' . $this->analysable->get_id();
+        $firstanalysis = $cache->get($key);
+        if (!empty($firstanalysis)) {
+            return $firstanalysis;
+        }
+
+        // This analysable has not yet been analysed, the start is therefore now (-1 so ready_to_predict can be executed).
+        return time() - 1;
+    }
 }
diff --git a/analytics/tests/analysis_test.php b/analytics/tests/analysis_test.php
new file mode 100644 (file)
index 0000000..9d763cd
--- /dev/null
@@ -0,0 +1,70 @@
+<?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 the analysis class.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Unit tests for the analysis class.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class analytics_analysis_testcase extends advanced_testcase {
+
+    /**
+     * Test fill_firstanalyses_cache.
+     * @return null
+     */
+    public function test_fill_firstanalyses_cache() {
+        $this->resetAfterTest();
+
+        $this->insert_used(1, 1, 'training', 123);
+        $this->insert_used(1, 2, 'training', 124);
+        $this->insert_used(1, 1, 'prediction', 125);
+
+        $firstanalyses = \core_analytics\analysis::fill_firstanalyses_cache(1);
+        $this->assertCount(2, $firstanalyses);
+        $this->assertEquals(123, $firstanalyses['1_1']);
+        $this->assertEquals(124, $firstanalyses['1_2']);
+
+        // The cached elements gets refreshed.
+        $this->insert_used(1, 1, 'prediction', 122);
+        $firstanalyses = \core_analytics\analysis::fill_firstanalyses_cache(1, 1);
+        $this->assertCount(1, $firstanalyses);
+        $this->assertEquals(122, $firstanalyses['1_1']);
+    }
+
+    private function insert_used($modelid, $analysableid, $action, $timestamp) {
+        global $DB;
+
+        $obj = new \stdClass();
+        $obj->modelid = $modelid;
+        $obj->action = $action;
+        $obj->analysableid = $analysableid;
+        $obj->firstanalysis = $timestamp;
+        $obj->timeanalysed = $timestamp;
+        $obj->id = $DB->insert_record('analytics_used_analysables', $obj);
+    }
+}
diff --git a/analytics/tests/fixtures/test_timesplitting_upcoming_seconds.php b/analytics/tests/fixtures/test_timesplitting_upcoming_seconds.php
new file mode 100644 (file)
index 0000000..9309f75
--- /dev/null
@@ -0,0 +1,52 @@
+<?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/>.
+
+/**
+ * Test time splitting.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Test time splitting.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class test_timesplitting_upcoming_seconds extends \core_analytics\local\time_splitting\upcoming_periodic {
+
+    /**
+     * Every second.
+     * @return \DateInterval
+     */
+    public function periodicity() {
+        return new \DateInterval('PT1S');
+    }
+
+    /**
+     * Just to comply with the interface.
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        return new \lang_string('error');
+    }
+}
index 5f638ec..11dd4ce 100644 (file)
@@ -806,12 +806,25 @@ class backup_badges_structure_step extends backup_structure_step {
         $badge = new backup_nested_element('badge', array('id'), array('name', 'description',
                 'timecreated', 'timemodified', 'usercreated', 'usermodified', 'issuername',
                 'issuerurl', 'issuercontact', 'expiredate', 'expireperiod', 'type', 'courseid',
-                'message', 'messagesubject', 'attachment', 'notification', 'status', 'nextcron'));
+                'message', 'messagesubject', 'attachment', 'notification', 'status', 'nextcron',
+                'version', 'language', 'imageauthorname', 'imageauthoremail', 'imageauthorurl',
+                'imagecaption'));
 
         $criteria = new backup_nested_element('criteria');
         $criterion = new backup_nested_element('criterion', array('id'), array('badgeid',
                 'criteriatype', 'method', 'description', 'descriptionformat'));
 
+        $endorsement = new backup_nested_element('endorsement', array('id'), array('badgeid',
+                'issuername', 'issuerurl', 'issueremail', 'claimid', 'claimcomment', 'dateissued'));
+
+        $alignments = new backup_nested_element('alignments');
+        $alignment = new backup_nested_element('alignment', array('id'), array('badgeid',
+                'targetname', 'targeturl', 'targetdescription', 'targetframework', 'targetcode'));
+
+        $relatedbadges = new backup_nested_element('relatedbadges');
+        $relatedbadge = new backup_nested_element('relatedbadge', array('id'), array('badgeid',
+                'relatedbadgeid'));
+
         $parameters = new backup_nested_element('parameters');
         $parameter = new backup_nested_element('parameter', array('id'), array('critid',
                 'name', 'value', 'criteriatype'));
@@ -827,6 +840,11 @@ class backup_badges_structure_step extends backup_structure_step {
         $criteria->add_child($criterion);
         $criterion->add_child($parameters);
         $parameters->add_child($parameter);
+        $badge->add_child($endorsement);
+        $badge->add_child($alignments);
+        $alignments->add_child($alignment);
+        $badge->add_child($relatedbadges);
+        $relatedbadges->add_child($relatedbadge);
         $badge->add_child($manual_awards);
         $manual_awards->add_child($manual_award);
 
@@ -834,6 +852,10 @@ class backup_badges_structure_step extends backup_structure_step {
 
         $badge->set_source_table('badge', array('courseid' => backup::VAR_COURSEID));
         $criterion->set_source_table('badge_criteria', array('badgeid' => backup::VAR_PARENTID));
+        $endorsement->set_source_table('badge_endorsement', array('badgeid' => backup::VAR_PARENTID));
+
+        $alignment->set_source_table('badge_alignment', array('badgeid' => backup::VAR_PARENTID));
+        $relatedbadge->set_source_table('badge_related', array('badgeid' => backup::VAR_PARENTID));
 
         $parametersql = 'SELECT cp.*, c.criteriatype
                              FROM {badge_criteria_param} cp JOIN {badge_criteria} c
@@ -850,6 +872,10 @@ class backup_badges_structure_step extends backup_structure_step {
         $badge->annotate_ids('user', 'usermodified');
         $criterion->annotate_ids('badge', 'badgeid');
         $parameter->annotate_ids('criterion', 'critid');
+        $endorsement->annotate_ids('badge', 'badgeid');
+        $alignment->annotate_ids('badge', 'badgeid');
+        $relatedbadge->annotate_ids('badge', 'badgeid');
+        $relatedbadge->annotate_ids('badge', 'relatedbadgeid');
         $badge->annotate_files('badges', 'badgeimage', 'id');
         $manual_award->annotate_ids('badge', 'badgeid');
         $manual_award->annotate_ids('user', 'recipientid');
index ff9e937..fe699b3 100644 (file)
@@ -2517,6 +2517,9 @@ class restore_badges_structure_step extends restore_structure_step {
         $paths[] = new restore_path_element('badge', '/badges/badge');
         $paths[] = new restore_path_element('criterion', '/badges/badge/criteria/criterion');
         $paths[] = new restore_path_element('parameter', '/badges/badge/criteria/criterion/parameters/parameter');
+        $paths[] = new restore_path_element('endorsement', '/badges/badge/endorsement');
+        $paths[] = new restore_path_element('alignment', '/badges/badge/alignments/alignment');
+        $paths[] = new restore_path_element('relatedbadge', '/badges/badge/relatedbadges/relatedbadge');
         $paths[] = new restore_path_element('manual_award', '/badges/badge/manual_awards/manual_award');
 
         return $paths;
@@ -2561,13 +2564,87 @@ class restore_badges_structure_step extends restore_structure_step {
                 'attachment'     => $data->attachment,
                 'notification'   => $data->notification,
                 'status'         => BADGE_STATUS_INACTIVE,
-                'nextcron'       => $data->nextcron
+                'nextcron'       => $data->nextcron,
+                'version'        => $data->version,
+                'language'       => $data->language,
+                'imageauthorname' => $data->imageauthorname,
+                'imageauthoremail' => $data->imageauthoremail,
+                'imageauthorurl' => $data->imageauthorurl,
+                'imagecaption'   => $data->imagecaption
         );
 
         $newid = $DB->insert_record('badge', $params);
         $this->set_mapping('badge', $data->id, $newid, $restorefiles);
     }
 
+    /**
+     * Create an endorsement for a badge.
+     *
+     * @param mixed $data
+     * @return void
+     */
+    public function process_endorsement($data) {
+        global $DB;
+
+        $data = (object)$data;
+
+        $params = [
+            'badgeid' => $this->get_new_parentid('badge'),
+            'issuername' => $data->issuername,
+            'issuerurl' => $data->issuerurl,
+            'issueremail' => $data->issueremail,
+            'claimid' => $data->claimid,
+            'claimcomment' => $data->claimcomment,
+            'dateissued' => $this->apply_date_offset($data->dateissued)
+        ];
+        $newid = $DB->insert_record('badge_endorsement', $params);
+        $this->set_mapping('endorsement', $data->id, $newid);
+    }
+
+    /**
+     * Link to related badges for a badge. This relies on post processing in after_execute().
+     *
+     * @param mixed $data
+     * @return void
+     */
+    public function process_relatedbadge($data) {
+        global $DB;
+
+        $data = (object)$data;
+        $relatedbadgeid = $data->relatedbadgeid;
+
+        if ($relatedbadgeid) {
+            // Only backup and restore related badges if they are contained in the backup file.
+            $params = array(
+                    'badgeid'           => $this->get_new_parentid('badge'),
+                    'relatedbadgeid'    => $relatedbadgeid
+            );
+            $newid = $DB->insert_record('badge_related', $params);
+        }
+    }
+
+    /**
+     * Link to an alignment for a badge.
+     *
+     * @param mixed $data
+     * @return void
+     */
+    public function process_alignment($data) {
+        global $DB;
+
+        $data = (object)$data;
+        $params = array(
+                'badgeid'           => $this->get_new_parentid('badge'),
+                'targetname'        => $data->targetname,
+                'targeturl'         => $data->targeturl,
+                'targetdescription' => $data->targetdescription,
+                'targetframework'   => $data->targetframework,
+                'targetcode'        => $data->targetcode
+        );
+        $newid = $DB->insert_record('badge_alignment', $params);
+        $this->set_mapping('alignment', $data->id, $newid);
+    }
+
     public function process_criterion($data) {
         global $DB;
 
@@ -2580,6 +2657,7 @@ class restore_badges_structure_step extends restore_structure_step {
                 'description'       => isset($data->description) ? $data->description : '',
                 'descriptionformat' => isset($data->descriptionformat) ? $data->descriptionformat : 0,
         );
+
         $newid = $DB->insert_record('badge_criteria', $params);
         $this->set_mapping('criterion', $data->id, $newid);
     }
@@ -2613,6 +2691,14 @@ class restore_badges_structure_step extends restore_structure_step {
             } else {
                 return;
             }
+        } else if ($data->criteriatype == BADGE_CRITERIA_TYPE_COMPETENCY) {
+            $competencyid = $this->get_mappingid('competency', $data->value);
+            if (!empty($competencyid)) {
+                $params['name'] = 'competency_' . $competencyid;
+                $params['value'] = $competencyid;
+            } else {
+                return;
+            }
         }
 
         if (!$DB->record_exists('badge_criteria_param', $params)) {
@@ -2645,8 +2731,38 @@ class restore_badges_structure_step extends restore_structure_step {
     }
 
     protected function after_execute() {
+        global $DB;
         // Add related files.
         $this->add_related_files('badges', 'badgeimage', 'badge');
+
+        $badgeid = $this->get_new_parentid('badge');
+        // Remap any related badges.
+        // We do this in the DB directly because this is backup/restore it is not valid to call into
+        // the component API.
+        $params = array('badgeid' => $badgeid);
+        $query = "SELECT DISTINCT br.id, br.badgeid, br.relatedbadgeid
+                    FROM {badge_related} br
+                   WHERE (br.badgeid = :badgeid)";
+        $relatedbadges = $DB->get_records_sql($query, $params);
+        $newrelatedids = [];
+        foreach ($relatedbadges as $relatedbadge) {
+            $relatedid = $this->get_mappingid('badge', $relatedbadge->relatedbadgeid);
+            $params['relatedbadgeid'] = $relatedbadge->relatedbadgeid;
+            $DB->delete_records_select('badge_related', '(badgeid = :badgeid AND relatedbadgeid = :relatedbadgeid)', $params);
+            if ($relatedid) {
+                $newrelatedids[] = $relatedid;
+            }
+        }
+        if (!empty($newrelatedids)) {
+            $relatedbadges = [];
+            foreach ($newrelatedids as $relatedid) {
+                $relatedbadge = new stdClass();
+                $relatedbadge->badgeid = $badgeid;
+                $relatedbadge->relatedbadgeid = $relatedid;
+                $relatedbadges[] = $relatedbadge;
+            }
+            $DB->insert_records('badge_related', $relatedbadges);
+        }
     }
 }
 
index 7bb97b0..a0bafb4 100644 (file)
@@ -377,18 +377,40 @@ class provider implements
 
             // Export the badges.
             $uniqueid = $DB->sql_concat_join("'-'", ['b.id', 'COALESCE(bc.id, 0)', 'COALESCE(bi.id, 0)',
-                'COALESCE(bma.id, 0)', 'COALESCE(bcm.id, 0)']);
+                'COALESCE(bma.id, 0)', 'COALESCE(bcm.id, 0)', 'COALESCE(brb.id, 0)', 'COALESCE(ba.id, 0)']);
             $sql = "
                 SELECT $uniqueid AS uniqueid, b.id,
                        bi.id AS biid, bi.dateissued, bi.dateexpire, bi.uniquehash,
                        bma.id AS bmaid, bma.datemet, bma.issuerid,
                        bcm.id AS bcmid,
                        c.fullname AS coursename,
+                       be.id AS beid,
+                       be.issuername AS beissuername,
+                       be.issuerurl AS beissuerurl,
+                       be.issueremail AS beissueremail,
+                       be.claimid AS beclaimid,
+                       be.claimcomment AS beclaimcomment,
+                       be.dateissued AS bedateissued,
+                       brb.id as rbid,
+                       brb.badgeid as rbbadgeid,
+                       brb.relatedbadgeid as rbrelatedbadgeid,
+                       ba.id as baid,
+                       ba.targetname as batargetname,
+                       ba.targeturl as batargeturl,
+                       ba.targetdescription as batargetdescription,
+                       ba.targetframework as batargetframework,
+                       ba.targetcode as batargetcode,
                        $ctxfields
                   FROM {badge} b
              LEFT JOIN {badge_issued} bi
                     ON bi.badgeid = b.id
                    AND bi.userid = :userid1
+            LEFT JOIN {badge_related} brb
+                    ON ( b.id = brb.badgeid OR b.id = brb.relatedbadgeid )
+             LEFT JOIN {badge_alignment} ba
+                    ON ( b.id = ba.badgeid )
+             LEFT JOIN {badge_endorsement} be
+                    ON be.badgeid = b.id
              LEFT JOIN {badge_manual_award} bma
                     ON bma.badgeid = b.id
                    AND bma.recipientid = :userid2
@@ -422,9 +444,16 @@ class provider implements
                 if ($carry === null) {
                     $carry = [
                         'name' => $badge->name,
+                        'version' => $badge->version,
+                        'language' => $badge->language,
+                        'imageauthorname' => $badge->imageauthorname,
+                        'imageauthoremail' => $badge->imageauthoremail,
+                        'imageauthorurl' => $badge->imageauthorurl,
+                        'imagecaption' => $badge->imagecaption,
                         'issued' => null,
                         'manual_award' => null,
-                        'criteria_met' => []
+                        'criteria_met' => [],
+                        'endorsement' => null,
                     ];
 
                     if ($badge->type == BADGE_TYPE_COURSE) {
@@ -432,6 +461,17 @@ class provider implements
                         $carry['course'] = format_string($record->coursename, true, ['context' => $badge->get_context()]);
                     }
 
+                    if (!empty($record->beid)) {
+                        $carry['endorsement'] = [
+                            'issuername' => $record->beissuername,
+                            'issuerurl' => $record->beissuerurl,
+                            'issueremail' => $record->beissueremail,
+                            'claimid' => $record->beclaimid,
+                            'claimcomment' => $record->beclaimcomment,
+                            'dateissued' => $record->bedateissued ? transform::datetime($record->bedateissued) : null
+                        ];
+                    }
+
                     if (!empty($record->biid)) {
                         $carry['issued'] = [
                             'issued_on' => transform::datetime($record->dateissued),
@@ -447,6 +487,52 @@ class provider implements
                         ];
                     }
                 }
+                if (!empty($record->rbid)) {
+                    if (empty($carry['related_badge'])) {
+                        $carry['related_badge'] = [];
+                    }
+                    $rbid = $record->rbbadgeid;
+                    if ($rbid == $record->id) {
+                        $rbid = $record->rbrelatedbadgeid;
+                    }
+                    $exists = false;
+                    foreach ($carry['related_badge'] as $related) {
+                        if ($related['badgeid'] == $rbid) {
+                            $exists = true;
+                            break;
+                        }
+                    }
+                    if (!$exists) {
+                        $relatedbadge = new badge($rbid);
+                        $carry['related_badge'][] = [
+                            'badgeid' => $rbid,
+                            'badgename' => $relatedbadge->name
+                        ];
+                    }
+                }
+
+                if (!empty($record->baid)) {
+                    if (empty($carry['alignment'])) {
+                        $carry['alignment'] = [];
+                    }
+                    $exists = false;
+                    $newalignment = [
+                        'targetname' => $record->batargetname,
+                        'targeturl' => $record->batargeturl,
+                        'targetdescription' => $record->batargetdescription,
+                        'targetframework' => $record->batargetframework,
+                        'targetcode' => $record->batargetcode,
+                    ];
+                    foreach ($carry['alignment'] as $alignment) {
+                        if ($alignment == $newalignment) {
+                            $exists = true;
+                            break;
+                        }
+                    }
+                    if (!$exists) {
+                        $carry['alignment'][] = $newalignment;
+                    }
+                }
 
                 // Export the details of the criteria met.
                 // We only do that once, when we find that a least one criteria was met.
index 1bfb93a..fb75770 100644 (file)
@@ -306,7 +306,11 @@ class core_badges_privacy_testcase extends provider_testcase {
         $u2ctx = context_user::instance($u2->id);
 
         $b1 = $this->create_badge(['usercreated' => $u3->id]);
+        $this->endorse_badge(['badgeid' => $b1->id]);
+        $this->align_badge(['badgeid' => $b1->id], ' (1)');
+        $this->align_badge(['badgeid' => $b1->id], ' (2)');
         $b2 = $this->create_badge(['type' => BADGE_TYPE_COURSE, 'courseid' => $c1->id, 'usermodified' => $u3->id]);
+        $this->relate_badge($b1->id, $b2->id);
         $b3 = $this->create_badge();
         $b3crit = $this->create_criteria_manual($b3->id);
         $b4 = $this->create_badge();
@@ -333,6 +337,12 @@ class core_badges_privacy_testcase extends provider_testcase {
         $path = [get_string('badges', 'core_badges'), "{$b1->name} ({$b1->id})"];
         $data = writer::with_context($u1ctx)->get_data($path);
         $this->assertEquals($b1->name, $data->name);
+        $this->assertEquals($b1->version, $data->version);
+        $this->assertEquals($b1->language, $data->language);
+        $this->assertEquals($b1->imageauthorname, $data->imageauthorname);
+        $this->assertEquals($b1->imageauthoremail, $data->imageauthoremail);
+        $this->assertEquals($b1->imageauthorurl, $data->imageauthorurl);
+        $this->assertEquals($b1->imagecaption, $data->imagecaption);
         $this->assertNotEmpty($data->issued);
         $this->assertEmpty($data->manual_award);
         $this->assertEmpty($data->criteria_met);
@@ -340,6 +350,33 @@ class core_badges_privacy_testcase extends provider_testcase {
         $this->assertEquals('yoohoo', $data->issued['unique_hash']);
         $this->assertNull($data->issued['expires_on']);
 
+        $this->assertNotEmpty($data->endorsement);
+        $this->assertNotEmpty($data->endorsement['issuername']);
+        $this->assertNotEmpty($data->endorsement['issuerurl']);
+        $this->assertNotEmpty($data->endorsement['issueremail']);
+        $this->assertNotEmpty($data->endorsement['claimid']);
+        $this->assertNotEmpty($data->endorsement['claimcomment']);
+        $this->assertNotEmpty($data->endorsement['dateissued']);
+
+        $this->assertNotEmpty($data->related_badge);
+        $this->assertNotEmpty($data->related_badge[0]);
+        $this->assertEquals($data->related_badge[0]['badgeid'], $b2->id);
+        $this->assertEquals($data->related_badge[0]['badgename'], $b2->name);
+
+        $this->assertNotEmpty($data->alignment);
+        $this->assertNotEmpty($data->alignment[0]);
+        $this->assertNotEmpty($data->alignment[0]['targetname']);
+        $this->assertNotEmpty($data->alignment[0]['targeturl']);
+        $this->assertNotEmpty($data->alignment[0]['targetdescription']);
+        $this->assertNotEmpty($data->alignment[0]['targetframework']);
+        $this->assertNotEmpty($data->alignment[0]['targetcode']);
+        $this->assertNotEmpty($data->alignment[1]);
+        $this->assertNotEmpty($data->alignment[1]['targetname']);
+        $this->assertNotEmpty($data->alignment[1]['targeturl']);
+        $this->assertNotEmpty($data->alignment[1]['targetdescription']);
+        $this->assertNotEmpty($data->alignment[1]['targetframework']);
+        $this->assertNotEmpty($data->alignment[1]['targetcode']);
+
         $path = [get_string('badges', 'core_badges'), "{$b2->name} ({$b2->id})"];
         $data = writer::with_context($u1ctx)->get_data($path);
         $this->assertEquals($b2->name, $data->name);
@@ -598,12 +635,79 @@ class core_badges_privacy_testcase extends provider_testcase {
             'attachment' => 1,
             'notification' => 0,
             'status' => BADGE_STATUS_ACTIVE,
+            'version' => OPEN_BADGES_V2,
+            'language' => 'en',
+            'imageauthorname' => 'Image author',
+            'imageauthoremail' => 'author@example.com',
+            'imageauthorurl' => 'http://image.example.com/',
+            'imagecaption' => 'Image caption'
         ], $params);
         $record->id = $DB->insert_record('badge', $record);
 
         return $record;
     }
 
+    /**
+     * Relate a badge.
+     *
+     * @param int $badgeid The badge ID.
+     * @param int $relatedbadgeid The related badge ID.
+     * @return object
+     */
+    protected function relate_badge(int $badgeid, int $relatedbadgeid) {
+        global $DB;
+        $record = (object) [
+            'badgeid' => $badgeid,
+            'relatedbadgeid' => $relatedbadgeid
+        ];
+        $record->id = $DB->insert_record('badge_related', $record);
+
+        return $record;
+    }
+
+    /**
+     * Align a badge.
+     *
+     * @param array $params Parameters.
+     * @return object
+     */
+    protected function align_badge(array $params = [], $suffix = '') {
+        global $DB;
+        $record = (object) array_merge([
+            'badgeid' => null,
+            'targetname' => "Alignment name" . $suffix,
+            'targeturl' => "http://issuer-url.domain.co.nz",
+            'targetdescription' => "Description" . $suffix,
+            'targetframework' => "Framework" . $suffix,
+            'targetcode' => "Code . $suffix"
+        ], $params);
+        $record->id = $DB->insert_record('badge_alignment', $record);
+
+        return $record;
+    }
+
+    /**
+     * Endorse a badge.
+     *
+     * @param array $params Parameters.
+     * @return object
+     */
+    protected function endorse_badge(array $params = []) {
+        global $DB;
+        $record = (object) array_merge([
+            'badgeid' => null,
+            'issuername' => "External issuer name",
+            'issuerurl' => "http://issuer-url.domain.co.nz",
+            'issueremail' => "issuer@example.com",
+            'claimid' => "Claim ID",
+            'claimcomment' => "Claim comment",
+            'dateissued' => time()
+        ], $params);
+        $record->id = $DB->insert_record('badge_endorsement', $record);
+
+        return $record;
+    }
+
     /**
      * Create a backpack.
      *
index 12bc80b..6ef434f 100644 (file)
@@ -608,49 +608,44 @@ class calendar_event {
             $updaterepeated = (!empty($this->properties->repeatid) && !empty($this->properties->repeateditall));
 
             if ($updaterepeated) {
-                // Update all.
+
+                $sqlset = 'name = ?,
+                           description = ?,
+                           timeduration = ?,
+                           timemodified = ?,
+                           groupid = ?,
+                           courseid = ?';
+
+                // Note: Group and course id may not be set. If not, keep their current values.
+                $params = [
+                    $this->properties->name,
+                    $this->properties->description,
+                    $this->properties->timeduration,
+                    time(),
+                    isset($this->properties->groupid) ? $this->properties->groupid : $event->groupid,
+                    isset($this->properties->courseid) ? $this->properties->courseid : $event->courseid,
+                ];
+
+                // Note: Only update start date, if it was changed by the user.
                 if ($this->properties->timestart != $event->timestart) {
                     $timestartoffset = $this->properties->timestart - $event->timestart;
-                    $sql = "UPDATE {event}
-                               SET name = ?,
-                                   description = ?,
-                                   timestart = timestart + ?,
-                                   timeduration = ?,
-                                   timemodified = ?,
-                                   groupid = ?,
-                                   courseid = ?
-                             WHERE repeatid = ?";
-                    // Note: Group and course id may not be set. If not, keep their current values.
-                    $params = [
-                        $this->properties->name,
-                        $this->properties->description,
-                        $timestartoffset,
-                        $this->properties->timeduration,
-                        time(),
-                        isset($this->properties->groupid) ? $this->properties->groupid : $event->groupid,
-                        isset($this->properties->courseid) ? $this->properties->courseid : $event->courseid,
-                        $event->repeatid
-                    ];
-                } else {
-                    $sql = "UPDATE {event}
-                               SET name = ?,
-                                   description = ?,
-                                   timeduration = ?,
-                                   timemodified = ?,
-                                   groupid = ?,
-                                   courseid = ?
-                            WHERE repeatid = ?";
-                    // Note: Group and course id may not be set. If not, keep their current values.
-                    $params = [
-                        $this->properties->name,
-                        $this->properties->description,
-                        $this->properties->timeduration,
-                        time(),
-                        isset($this->properties->groupid) ? $this->properties->groupid : $event->groupid,
-                        isset($this->properties->courseid) ? $this->properties->courseid : $event->courseid,
-                        $event->repeatid
-                    ];
+                    $sqlset .= ', timestart = timestart + ?';
+                    $params[] = $timestartoffset;
+                }
+
+                // Note: Only update location, if it was changed by the user.
+                $updatelocation = (!empty($this->properties->location) && $this->properties->location !== $event->location);
+                if ($updatelocation) {
+                    $sqlset .= ', location = ?';
+                    $params[] = $this->properties->location;
                 }
+
+                // Update all.
+                $sql = "UPDATE {event}
+                           SET $sqlset
+                         WHERE repeatid = ?";
+
+                $params[] = $event->repeatid;
                 $DB->execute($sql, $params);
 
                 // Trigger an update event for each of the calendar event.
index bec45a2..baa3977 100644 (file)
@@ -77,7 +77,9 @@ define('COMPLETION_CRITERIA_TYPE_ROLE',         7);
 define('COMPLETION_CRITERIA_TYPE_COURSE',       8);
 
 /**
- * Criteria type constant to class name mapping
+ * Criteria type constant to class name mapping.
+ *
+ * This global variable would be improved if it was implemented as a cache.
  */
 global $COMPLETION_CRITERIA_TYPES;
 $COMPLETION_CRITERIA_TYPES = array(
diff --git a/completion/cron.php b/completion/cron.php
deleted file mode 100644 (file)
index f56e5dd..0000000
+++ /dev/null
@@ -1,393 +0,0 @@
-<?php
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
-
-/**
- * Code used by scheduled tasks for reviewing and aggregating course completion criteria.
- *
- * @package core_completion
- * @category completion
- * @copyright 2009 Catalyst IT Ltd
- * @author Aaron Barnes <aaronb@catalyst.net.nz>
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-defined('MOODLE_INTERNAL') || die();
-require_once($CFG->libdir.'/completionlib.php');
-
-/**
- * Mark users as started if the config option is set
- *
- * @return void
- */
-function completion_cron_mark_started() {
-    global $CFG, $DB;
-
-    if (debugging()) {
-        mtrace('Marking users as started');
-    }
-
-    if (!empty($CFG->gradebookroles)) {
-        $roles = ' AND ra.roleid IN ('.$CFG->gradebookroles.')';
-    } else {
-        // This causes it to default to everyone (if there is no student role)
-        $roles = '';
-    }
-
-    /**
-     * A quick explaination of this horrible looking query
-     *
-     * It's purpose is to locate all the active participants
-     * of a course with course completion enabled.
-     *
-     * We also only want the users with no course_completions
-     * record as this functions job is to create the missing
-     * ones :)
-     *
-     * We want to record the user's enrolment start time for the
-     * course. This gets tricky because there can be multiple
-     * enrolment plugins active in a course, hence the possibility
-     * of multiple records for each couse/user in the results
-     */
-    $sql = "
-        SELECT
-            c.id AS course,
-            u.id AS userid,
-            crc.id AS completionid,
-            ue.timestart AS timeenrolled,
-            ue.timecreated
-        FROM
-            {user} u
-        INNER JOIN
-            {user_enrolments} ue
-         ON ue.userid = u.id
-        INNER JOIN
-            {enrol} e
-         ON e.id = ue.enrolid
-        INNER JOIN
-            {course} c
-         ON c.id = e.courseid
-        INNER JOIN
-            {role_assignments} ra
-         ON ra.userid = u.id
-        LEFT JOIN
-            {course_completions} crc
-         ON crc.course = c.id
-        AND crc.userid = u.id
-        WHERE
-            c.enablecompletion = 1
-        AND crc.timeenrolled IS NULL
-        AND ue.status = 0
-        AND e.status = 0
-        AND u.deleted = 0
-        AND ue.timestart < ?
-        AND (ue.timeend > ? OR ue.timeend = 0)
-            $roles
-        ORDER BY
-            course,
-            userid
-    ";
-
-    $now = time();
-    $rs = $DB->get_recordset_sql($sql, array($now, $now, $now, $now));
-
-    // Check if result is empty
-    if (!$rs->valid()) {
-        $rs->close(); // Not going to iterate (but exit), close rs
-        return;
-    }
-
-    /**
-     * An explaination of the following loop
-     *
-     * We are essentially doing a group by in the code here (as I can't find
-     * a decent way of doing it in the sql).
-     *
-     * Since there can be multiple enrolment plugins for each course, we can have
-     * multiple rows for each particpant in the query result. This isn't really
-     * a problem until you combine it with the fact that the enrolment plugins
-     * can save the enrol start time in either timestart or timeenrolled.
-     *
-     * The purpose of this loop is to find the earliest enrolment start time for
-     * each participant in each course.
-     */
-    $prev = null;
-    while ($rs->valid() || $prev) {
-
-        $current = $rs->current();
-
-        if (!isset($current->course)) {
-            $current = false;
-        }
-        else {
-            // Not all enrol plugins fill out timestart correctly, so use whichever
-            // is non-zero
-            $current->timeenrolled = max($current->timecreated, $current->timeenrolled);
-        }
-
-        // If we are at the last record,
-        // or we aren't at the first and the record is for a diff user/course
-        if ($prev &&
-            (!$rs->valid() ||
-            ($current->course != $prev->course || $current->userid != $prev->userid))) {
-
-            $completion = new completion_completion();
-            $completion->userid = $prev->userid;
-            $completion->course = $prev->course;
-            $completion->timeenrolled = (string) $prev->timeenrolled;
-            $completion->timestarted = 0;
-            $completion->reaggregate = time();
-
-            if ($prev->completionid) {
-                $completion->id = $prev->completionid;
-            }
-
-            $completion->mark_enrolled();
-
-            if (debugging()) {
-                mtrace('Marked started user '.$prev->userid.' in course '.$prev->course);
-            }
-        }
-        // Else, if this record is for the same user/course
-        elseif ($prev && $current) {
-            // Use oldest timeenrolled
-            $current->timeenrolled = min($current->timeenrolled, $prev->timeenrolled);
-        }
-
-        // Move current record to previous
-        $prev = $current;
-
-        // Move to next record
-        $rs->next();
-    }
-
-    $rs->close();
-}
-
-/**
- * Run installed criteria's data aggregation methods
- *
- * Loop through each installed criteria and run the
- * cron() method if it exists
- *
- * @return void
- */
-function completion_cron_criteria() {
-
-    // Process each criteria type
-    global $CFG, $COMPLETION_CRITERIA_TYPES;
-
-    foreach ($COMPLETION_CRITERIA_TYPES as $type) {
-
-        $object = 'completion_criteria_'.$type;
-        require_once $CFG->dirroot.'/completion/criteria/'.$object.'.php';
-
-        $class = new $object();
-
-        // Run the criteria type's cron method, if it has one
-        if (method_exists($class, 'cron')) {
-
-            if (debugging()) {
-                mtrace('Running '.$object.'->cron()');
-            }
-            $class->cron();
-        }
-    }
-}
-
-/**
- * Aggregate each user's criteria completions
- */
-function completion_cron_completions() {
-    global $DB;
-
-    if (debugging()) {
-        mtrace('Aggregating completions');
-    }
-
-    // Save time started
-    $timestarted = time();
-
-    // Grab all criteria and their associated criteria completions
-    $sql = '
-        SELECT DISTINCT
-            c.id AS course,
-            cr.id AS criteriaid,
-            crc.userid AS userid,
-            cr.criteriatype AS criteriatype,
-            cc.timecompleted AS timecompleted
-        FROM
-            {course_completion_criteria} cr
-        INNER JOIN
-            {course} c
-         ON cr.course = c.id
-        INNER JOIN
-            {course_completions} crc
-         ON crc.course = c.id
-        LEFT JOIN
-            {course_completion_crit_compl} cc
-         ON cc.criteriaid = cr.id
-        AND crc.userid = cc.userid
-        WHERE
-            c.enablecompletion = 1
-        AND crc.timecompleted IS NULL
-        AND crc.reaggregate > 0
-        AND crc.reaggregate < :timestarted
-        ORDER BY
-            course,
-            userid
-    ';
-
-    $rs = $DB->get_recordset_sql($sql, array('timestarted' => $timestarted));
-
-    // Check if result is empty
-    if (!$rs->valid()) {
-        $rs->close(); // Not going to iterate (but exit), close rs
-        return;
-    }
-
-    $current_user = null;
-    $current_course = null;
-    $completions = array();
-
-    while (1) {
-
-        // Grab records for current user/course
-        foreach ($rs as $record) {
-            // If we are still grabbing the same users completions
-            if ($record->userid === $current_user && $record->course === $current_course) {
-                $completions[$record->criteriaid] = $record;
-            } else {
-                break;
-            }
-        }
-
-        // Aggregate
-        if (!empty($completions)) {
-
-            if (debugging()) {
-                mtrace('Aggregating completions for user '.$current_user.' in course '.$current_course);
-            }
-
-            // Get course info object
-            $info = new completion_info((object)array('id' => $current_course));
-
-            // Setup aggregation
-            $overall = $info->get_aggregation_method();
-            $activity = $info->get_aggregation_method(COMPLETION_CRITERIA_TYPE_ACTIVITY);
-            $prerequisite = $info->get_aggregation_method(COMPLETION_CRITERIA_TYPE_COURSE);
-            $role = $info->get_aggregation_method(COMPLETION_CRITERIA_TYPE_ROLE);
-
-            $overall_status = null;
-            $activity_status = null;
-            $prerequisite_status = null;
-            $role_status = null;
-
-            // Get latest timecompleted
-            $timecompleted = null;
-
-            // Check each of the criteria
-            foreach ($completions as $params) {
-                $timecompleted = max($timecompleted, $params->timecompleted);
-
-                $completion = new completion_criteria_completion((array)$params, false);
-
-                // Handle aggregation special cases
-                if ($params->criteriatype == COMPLETION_CRITERIA_TYPE_ACTIVITY) {
-                    completion_cron_aggregate($activity, $completion->is_complete(), $activity_status);
-                } else if ($params->criteriatype == COMPLETION_CRITERIA_TYPE_COURSE) {
-                    completion_cron_aggregate($prerequisite, $completion->is_complete(), $prerequisite_status);
-                } else if ($params->criteriatype == COMPLETION_CRITERIA_TYPE_ROLE) {
-                    completion_cron_aggregate($role, $completion->is_complete(), $role_status);
-                } else {
-                    completion_cron_aggregate($overall, $completion->is_complete(), $overall_status);
-                }
-            }
-
-            // Include role criteria aggregation in overall aggregation
-            if ($role_status !== null) {
-                completion_cron_aggregate($overall, $role_status, $overall_status);
-            }
-
-            // Include activity criteria aggregation in overall aggregation
-            if ($activity_status !== null) {
-                completion_cron_aggregate($overall, $activity_status, $overall_status);
-            }
-
-            // Include prerequisite criteria aggregation in overall aggregation
-            if ($prerequisite_status !== null) {
-                completion_cron_aggregate($overall, $prerequisite_status, $overall_status);
-            }
-
-            // If aggregation status is true, mark course complete for user
-            if ($overall_status) {
-                if (debugging()) {
-                    mtrace('Marking complete');
-                }
-
-                $ccompletion = new completion_completion(array('course' => $params->course, 'userid' => $params->userid));
-                $ccompletion->mark_complete($timecompleted);
-            }
-        }
-
-        // If this is the end of the recordset, break the loop
-        if (!$rs->valid()) {
-            $rs->close();
-            break;
-        }
-
-        // New/next user, update user details, reset completions
-        $current_user = $record->userid;
-        $current_course = $record->course;
-        $completions = array();
-        $completions[$record->criteriaid] = $record;
-    }
-
-    // Mark all users as aggregated
-    $sql = "
-        UPDATE
-            {course_completions}
-        SET
-            reaggregate = 0
-        WHERE
-            reaggregate < :timestarted
-        AND reaggregate > 0
-    ";
-
-    $DB->execute($sql, array('timestarted' => $timestarted));
-}
-
-/**
- * Aggregate criteria status's as per configured aggregation method
- *
- * @param int $method COMPLETION_AGGREGATION_* constant
- * @param bool $data Criteria completion status
- * @param bool|null $state Aggregation state
- */
-function completion_cron_aggregate($method, $data, &$state) {
-    if ($method == COMPLETION_AGGREGATION_ALL) {
-        if ($data && $state !== false) {
-            $state = true;
-        } else {
-            $state = false;
-        }
-    } elseif ($method == COMPLETION_AGGREGATION_ANY) {
-        if ($data) {
-            $state = true;
-        } else if (!$data && $state === null) {
-            $state = false;
-        }
-    }
-}
index 39c2c5e..1a96fbf 100644 (file)
@@ -764,7 +764,7 @@ class helper {
      */
     public static function get_management_viewmodes() {
         return array(
-            'combined' => new \lang_string('categoriesandcoures'),
+            'combined' => new \lang_string('categoriesandcourses'),
             'categories' => new \lang_string('categories'),
             'courses' => new \lang_string('courses')
         );
index 7f2b057..3546584 100644 (file)
@@ -83,7 +83,7 @@ class behat_course extends behat_base {
 
         // Select Miscellaneous category.
         $this->i_click_on_category_in_the_management_interface(get_string('miscellaneous'));
-        $this->execute("behat_course::i_should_see_the_courses_management_page", get_string('categoriesandcoures'));
+        $this->execute("behat_course::i_should_see_the_courses_management_page", get_string('categoriesandcourses'));
 
         // Click create new course.
         $this->execute('behat_general::i_click_on_in_the',
index 799e827..5973857 100644 (file)
@@ -58,7 +58,7 @@ trait gradingform_legacy_polyfill {
      * This method is used to export any user data this sub-plugin has using the object to get the context and userid.
      *
      * @deprecated Since Moodle 3.6 MDL-62535 Please use the methods in the gradingform_provider_v2 interface.
-     * @todo MDL-63137 remove this method.
+     * @todo MDL-63167 remove this method.
      *
      * @param context $context Context owner of the data.
      * @param stdClass $definition Grading definition entry to export.
@@ -75,7 +75,7 @@ trait gradingform_legacy_polyfill {
      * Any call to this method should delete all user data for the context defined.
      *
      * @deprecated Since Moodle 3.6 MDL-62535 Please use the methods in the gradingform_provider_v2 interface.
-     * @todo MDL-63137 remove this method.
+     * @todo MDL-63167 remove this method.
      *
      * @param context $context Context owner of the data.
      */
@@ -88,7 +88,7 @@ trait gradingform_legacy_polyfill {
      * A call to this method should delete user data (where practicle) from the userid and context.
      *
      * @deprecated Since Moodle 3.6 MDL-62535 Please use the methods in the gradingform_provider_v2 interface.
-     * @todo MDL-63137 remove this method.
+     * @todo MDL-63167 remove this method.
      *
      * @param int $userid The user whose information is to be deleted.
      * @param context $context Context owner of the data.
index 241224a..63465c8 100644 (file)
@@ -174,7 +174,7 @@ class test_legacy_polyfill_gradingform_provider implements
      * This method is used to export any user data this sub-plugin has using the object to get the context and userid.
      *
      * @deprecated Since Moodle 3.6 MDL-62535 Please use the methods in the gradingform_provider_v2 interface.
-     * @todo MDL-63137 remove this method.
+     * @todo MDL-63167 remove this method.
      *
      * @param context $context Context owner of the data.
      * @param stdClass $definition Grading definition entry to export.
@@ -190,7 +190,7 @@ class test_legacy_polyfill_gradingform_provider implements
      * Any call to this method should delete all user data for the context defined.
      *
      * @deprecated Since Moodle 3.6 MDL-62535 Please use the methods in the gradingform_provider_v2 interface.
-     * @todo MDL-63137 remove this method.
+     * @todo MDL-63167 remove this method.
      *
      * @param context $context Context owner of the data.
      */
@@ -202,7 +202,7 @@ class test_legacy_polyfill_gradingform_provider implements
      * A call to this method should delete user data (where practicle) from the userid and context.
      *
      * @deprecated Since Moodle 3.6 MDL-62535 Please use the methods in the gradingform_provider_v2 interface.
-     * @todo MDL-63137 remove this method.
+     * @todo MDL-63167 remove this method.
      *
      * @param int $userid The user whose information is to be deleted.
      * @param context $context Context owner of the data.
diff --git a/install/lang/it_wp/langconfig.php b/install/lang/it_wp/langconfig.php
new file mode 100644 (file)
index 0000000..7592849
--- /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'] = 'it';
+$string['thislanguage'] = 'Italiano per Workplace';
diff --git a/install/lang/no_wp/langconfig.php b/install/lang/no_wp/langconfig.php
new file mode 100644 (file)
index 0000000..4b5771a
--- /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'] = 'no';
+$string['thislanguage'] = 'Norsk Workplace';
index 8a6fa41..4ff4110 100644 (file)
@@ -49,7 +49,7 @@ $string['environmentsub2'] = 'Cada nova versão do Moodle tem pré-requisitos m
 $string['errorsinenvironment'] = 'A verificação do sistema falhou!';
 $string['installation'] = 'Instalação';
 $string['langdownloaderror'] = 'Não foi possível descarregar o idioma <b>{$a}</b> . O processo de instalação continuará em Inglês.';
-$string['memorylimithelp'] = '<p>O limite de memória para o PHP definido atualmente no servidor é <b>{$a}</b>.</p><p>Um número elevado de módulos em utilização ou de utilizadores registados pode fazer com que o Moodle apresente problemas de falta de memória.</p><p>É recomendado que o PHP seja configurado com um limite de memória de pelo menos 40MB. Esta configuração pode ser definida de diversas formas:</p><ol><li>Compilação do PHP com o parâmetro <b>--enable-memory-limit</b>. Esta definição permitirá ao próprio Moodle definir o valor a utilizar.</li><li>Alteração do parâmetro <b>memory_limit</b> no ficheiro de configuração do PHP para um valor igual ou superior a 40MB.</li><li>Criação de um ficheiro <b>.htaccess</b> na raiz da pasta do Moodle com a linha <b>php_value memory_limit 40M</b><p>ATENÇÃO: Em alguns servidores esta configuração impedirá o funcionamento de <b>todas</b> as páginas PHP. Nestes casos, não poderá ser utilizado o ficheiro <b>.htaccess</b>.</p></li></ol>';
+$string['memorylimithelp'] = '<p>O limite de memória para o PHP definido atualmente no servidor é <b>{$a}</b>.</p><p>Um número elevado de módulos em utilização ou de utilizadores registados pode fazer com que o Moodle apresente problemas de falta de memória.</p><p>É recomendado que o PHP seja configurado com um limite de memória de pelo menos 40MB. Esta configuração pode ser definida de diversas formas:</p><ol><li>Compilação do PHP com o parâmetro <b>--enable-memory-limit</b>. Esta definição permitirá ao próprio Moodle definir o valor a utilizar.</li><li>Alteração do parâmetro <b>memory_limit</b> no ficheiro de configuração do PHP para um valor igual ou superior a 40MB.</li><li>Criação de um ficheiro <b>.htaccess</b> na raiz da pasta do Moodle com a linha <b>php_value memory_limit 40M</b><p><strong>Atenção</strong>: Em alguns servidores esta configuração impedirá o funcionamento de <b>todas</b> as páginas PHP. Nestes casos, não poderá ser utilizado o ficheiro <b>.htaccess</b>.</p></li></ol>';
 $string['paths'] = 'Caminhos';
 $string['pathserrcreatedataroot'] = 'O programa de instalação não conseguiu criar a pasta de dados <b>{$a->dataroot}</b>.';
 $string['pathshead'] = 'Confirmar caminhos';
index 837cf54..b0f5244 100644 (file)
@@ -29,6 +29,7 @@ $string['analytics'] = 'Analytics';
 $string['analyticslogstore'] = 'Log store used for analytics';
 $string['analyticslogstore_help'] = 'The log store that will be used by the analytics API to read users\' activity';
 $string['analyticssettings'] = 'Analytics settings';
+$string['analyticssiteinfo'] = 'Site information';
 $string['defaulttimesplittingmethods'] = 'Default time-splitting methods for model\'s evaluation';
 $string['defaulttimesplittingmethods_help'] = 'The time-splitting method divides the course duration into parts; the predictions engine will run at the end of these parts. The model evaluation process will iterate through these time-splitting methods unless a specific time-splitting method is specified (the ability to specify a time-splitting method is only available when evaluating models using the command line script).';
 $string['defaultpredictionsprocessor'] = 'Default predictions processor';
@@ -71,7 +72,21 @@ $string['insightinfomessagehtml'] = 'The system generated an insight for you.';
 $string['insightinfomessageaction'] = '{$a->text}: {$a->url}';
 $string['invalidtimesplitting'] = 'Model with ID {$a} needs a time-splitting method before it can be used to train.';
 $string['invalidanalysablefortimesplitting'] = 'It cannot be analysed using {$a} time-splitting method.';
+$string['levelinstitution'] = 'Level of education';
+$string['levelinstitutionisced0'] = 'Early childhood education (‘less than primary’ for educational attainment)';
+$string['levelinstitutionisced1'] = 'Primary education';
+$string['levelinstitutionisced2'] = 'Lower secondary education';
+$string['levelinstitutionisced3'] = 'Upper secondary education';
+$string['levelinstitutionisced4'] = 'Post-secondary non-tertiary education (may include corporate or community/NGO training)';
+$string['levelinstitutionisced5'] = 'Short-cycle tertiary education (may include corporate or community/NGO training)';
+$string['levelinstitutionisced6'] = 'Bachelor’s or equivalent level';
+$string['levelinstitutionisced7'] = 'Master’s or equivalent level';
+$string['levelinstitutionisced8'] = 'Doctoral or equivalent level';
 $string['nocourses'] = 'No courses to analyse';
+$string['modeinstruction'] = 'Modes of instruction';
+$string['modeinstructionfacetoface'] = 'Face to Face';
+$string['modeinstructionblendedhybrid'] = 'Blended/Hybrid';
+$string['modeinstructionfullyonline'] = 'Fully Online';
 $string['modeloutputdir'] = 'Models output directory';
 $string['modeloutputdirinfo'] = 'Directory where prediction processors store all evaluation info. Useful for debugging and research.';
 $string['modeltimelimit'] = 'Analysis time limit per model';
@@ -91,6 +106,8 @@ $string['novaliddata'] = 'No valid data available';
 $string['novalidsamples'] = 'No valid samples available';
 $string['onlycli'] = 'Analytics processes execution via command line only';
 $string['onlycliinfo'] = 'Analytics processes like evaluating models, training machine learning algorithms or getting predictions can take some time, they will run as cron tasks and they can be forced via command line. Disable this setting if you want your site managers to be able to run these processes manually via web interface';
+$string['percentonline'] = 'Percent online';
+$string['percentonline_help'] = 'If your institution offers blended or hybrid courses, what percentage of the student work is conducted online in Moodle? Use a numeric value from 0 to 100.';
 $string['predictionsprocessor'] = 'Predictions processor';
 $string['predictionsprocessor_help'] = 'A predictions processor is the machine-learning backend that processes the datasets generated by calculating models\' indicators and targets. Each model can use a different processor. The one specified here will be the default.';
 $string['privacy:metadata:analytics:indicatorcalc'] = 'Indicator calculations';
@@ -121,8 +138,12 @@ $string['privacy:metadata:analytics:predictionactions:timecreated'] = 'When the
 $string['processingsitecontents'] = 'Processing site contents';
 $string['successfullyanalysed'] = 'Successfully analysed';
 $string['timesplittingmethod'] = 'Time-splitting method';
-$string['timesplittingmethod_help'] = 'The time-splitting method is what defines when the system will calculate predictions and the portion of activity logs that will be considered for those predictions. E.g. they can divide the course duration in parts and generate a prediction at the end of these parts.';
+$string['timesplittingmethod_help'] = 'The time-splitting method is what defines when the system will calculate predictions and the portion of activity logs that will be considered for those predictions. E.g. They can divide the course duration in parts and generate a prediction at the end of these parts.';
 $string['timesplittingmethod_link'] = 'Time_splitting_methods';
+$string['typeinstitution'] = 'Type of institution';
+$string['typeinstitutionacademic'] = 'Academic';
+$string['typeinstitutiontraining'] = 'Corporate training';
+$string['typeinstitutionngo'] = 'Non-governmental organization (NGO)';
 $string['viewdetails'] = 'View details';
 $string['viewinsight'] = 'View insight';
 $string['viewinsightdetails'] = 'View insight details';
index eaef860..441b191 100644 (file)
@@ -56,6 +56,7 @@ $string['cachedef_groupdata'] = 'Course group information';
 $string['cachedef_htmlpurifier'] = 'HTML Purifier - cleaned content';
 $string['cachedef_langmenu'] = 'List of available languages';
 $string['cachedef_message_time_last_message_between_users'] = 'Time created for most recent message in a conversation';
+$string['cachedef_modelfirstanalyses'] = 'First analysis by model and analysable';
 $string['cachedef_locking'] = 'Locking';
 $string['cachedef_message_processors_enabled'] = "Message processors enabled status";
 $string['cachedef_contextwithinsights'] = 'Context with insights';
index e95f173..df841d6 100644 (file)
@@ -225,7 +225,7 @@ $string['cachecontrols'] = 'Cache controls';
 $string['cancel'] = 'Cancel';
 $string['cancelled'] = 'Cancelled';
 $string['categories'] = 'Course categories';
-$string['categoriesandcoures'] = 'Course categories and courses';
+$string['categoriesandcourses'] = 'Course categories and courses';
 $string['category'] = 'Category';
 $string['categoryadded'] = 'The category \'{$a}\' was added';
 $string['categorybulkaction'] = 'Bulk actions for selected categories';
index d9df15e..7dc290e 100644 (file)
@@ -142,6 +142,17 @@ $string['errorprocessingresponses'] = 'An error occurred while processing your r
 $string['errorsavingcomment'] = 'Error saving the comment for question {$a->name} in the database.';
 $string['errorupdatingattempt'] = 'Error updating attempt {$a->id} in the database.';
 $string['eventquestioncategorycreated'] = 'Question category created';
+$string['eventquestioncategorydeleted'] = 'Question category deleted';
+$string['eventquestioncategorymoved'] = 'Question category moved';
+$string['eventquestioncategoryupdated'] = 'Question category updated';
+$string['eventquestioncategoryviewed'] = 'Question category viewed';
+$string['eventquestioncreated'] = 'Question created';
+$string['eventquestiondeleted'] = 'Question deleted';
+$string['eventquestionmoved'] = 'Question moved';
+$string['eventquestionviewed'] = 'Question viewed';
+$string['eventquestionsexported'] = 'Questions exported';
+$string['eventquestionsimported'] = 'Questions imported';
+$string['eventquestionupdated'] = 'Question updated';
 $string['export'] = 'Export';
 $string['exportcategory'] = 'Export category';
 $string['exportcategory_help'] = 'This setting determines the category from which the exported questions will be taken.
index 3b05402..4c1bb90 100644 (file)
@@ -324,6 +324,28 @@ class badge {
                 $crit->make_clone($new);
             }
 
+            // Copy endorsement.
+            $endorsement = $this->get_endorsement();
+            if ($endorsement) {
+                unset($endorsement->id);
+                $endorsement->badgeid = $new;
+                $newbadge->save_endorsement($endorsement);
+            }
+
+            // Copy alignments.
+            $alignments = $this->get_alignments();
+            foreach ($alignments as $alignment) {
+                unset($alignment->id);
+                $alignment->badgeid = $new;
+                $newbadge->save_alignment($alignment);
+            }
+
+            // Copy related badges.
+            $related = $this->get_related_badges(true);
+            if (!empty($related)) {
+                $newbadge->add_related_badges(array_keys($related));
+            }
+
             // Trigger event, badge duplicated.
             $eventparams = array('objectid' => $new, 'context' => $PAGE->context);
             $event = \core\event\badge_duplicated::create($eventparams);
diff --git a/lib/classes/event/question_base.php b/lib/classes/event/question_base.php
new file mode 100644 (file)
index 0000000..2f07b85
--- /dev/null
@@ -0,0 +1,129 @@
+<?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/>.
+
+/**
+ * Base class for question events.
+ *
+ * @package    core
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Base class for question events.
+ *
+ * @package    core
+ * @since      Moodle 3.7
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class question_base extends base {
+
+    /**
+     * Init method.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'question';
+        $this->data['edulevel'] = self::LEVEL_TEACHING;
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        if ($this->courseid) {
+            $cat = $this->other['categoryid'] . ',' . $this->contextid;
+            if ($this->contextlevel == CONTEXT_MODULE) {
+                return new \moodle_url('/question/edit.php',
+                        ['cmid' => $this->contextinstanceid, 'cat' => $cat, 'lastchanged' => $this->objectid]);
+            }
+            return new \moodle_url('/question/edit.php',
+                    ['courseid' => $this->courseid, 'cat' => $cat, 'lastchanged' => $this->objectid]);
+        }
+        // Lets try viewing from the frontpage for contexts above course.
+        return new \moodle_url('/question/category.php',
+                ['courseid' => SITEID, 'edit' => $this->other['categoryid'], 'lastchanged' => $this->objectid]);
+    }
+
+    /**
+     * Custom validations.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['categoryid'])) {
+            throw new \coding_exception('The \'categoryid\' must be set in \'other\'.');
+        }
+    }
+
+    /**
+     * Returns DB mappings used with backup / restore.
+     *
+     * @return array
+     */
+    public static function get_objectid_mapping() {
+        return ['db' => 'question', 'restore' => 'question'];
+    }
+
+    /**
+     * Used for maping events on restore
+     *
+     * @return array
+     */
+    public static function get_other_mapping() {
+
+        $othermapped = [];
+        $othermapped['categoryid'] = ['db' => 'question_categories', 'restore' => 'question_categories'];
+        return $othermapped;
+    }
+
+    /**
+     * Create a event from question object
+     *
+     * @param object $question
+     * @param object|null $context
+     * @param array|null $other will override the categoryid pre-filled out on the first line.
+     * @return base
+     * @throws \coding_exception
+     */
+    public static function create_from_question_instance($question, $context = null, $other = null) {
+
+        $params = ['objectid' => $question->id, 'other' => ['categoryid' => $question->category]];
+
+        if (!empty($question->contextid)) {
+            $params['contextid'] = $question->contextid;
+        }
+
+        $params['context'] = $context;
+
+        if (!empty($other)) {
+            $params['other'] = $other;
+        }
+
+        $event = self::create($params);
+        return $event;
+    }
+}
+
diff --git a/lib/classes/event/question_category_base.php b/lib/classes/event/question_category_base.php
new file mode 100644 (file)
index 0000000..fa1ffc7
--- /dev/null
@@ -0,0 +1,95 @@
+<?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/>.
+
+/**
+ * Base class for question category events.
+ *
+ * @package    core
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Base class for question category events
+ *
+ * @package    core
+ * @since      Moodle 3.7
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class question_category_base extends base {
+
+    /**
+     * Init method.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'question_categories';
+        $this->data['edulevel'] = self::LEVEL_TEACHING;
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        if ($this->courseid) {
+            $cat = $this->objectid . ',' . $this->contextid;
+            if ($this->contextlevel == CONTEXT_MODULE) {
+                return new \moodle_url('/question/edit.php', ['cmid' => $this->contextinstanceid, 'cat' => $cat]);
+            }
+            return new \moodle_url('/question/edit.php', ['courseid' => $this->courseid, 'cat' => $cat]);
+        }
+        // Lets try viewing from the frontpage for contexts above course.
+        return new \moodle_url('/question/category.php', ['courseid' => SITEID, 'edit' => $this->objectid]);
+    }
+
+    /**
+     * Returns DB mappings used with backup / restore.
+     *
+     * @return array
+     */
+    public static function get_objectid_mapping() {
+        return ['db' => 'question_categories', 'restore' => 'question_categories'];
+    }
+
+    /**
+     * Create a event from question category object
+     *
+     * @param object $category
+     * @param object|null $context
+     * @return base
+     * @throws \coding_exception
+     */
+    public static function create_from_question_category_instance($category, $context = null) {
+
+        $params = ['objectid' => $category->id];
+
+        if (!empty($category->contextid)) {
+            $params['contextid'] = $category->contextid;
+        }
+
+        $params['context'] = $context;
+
+        $event = self::create($params);
+        return $event;
+    }
+}
+
index dd011ec..2b6baf1 100644 (file)
@@ -34,15 +34,14 @@ defined('MOODLE_INTERNAL') || die();
  * @copyright  2014 Mark Nelson <markn@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class question_category_created extends base {
+class question_category_created extends question_category_base {
 
     /**
      * Init method.
      */
     protected function init() {
-        $this->data['objecttable'] = 'question_categories';
+        parent::init();
         $this->data['crud'] = 'c';
-        $this->data['edulevel'] = self::LEVEL_TEACHING;
     }
 
     /**
@@ -63,26 +62,6 @@ class question_category_created extends base {
         return "The user with id '$this->userid' created the question category with id '$this->objectid'.";
     }
 
-    /**
-     * Returns relevant URL.
-     *
-     * @return \moodle_url
-     */
-    public function get_url() {
-        if ($this->courseid) {
-            $cat = $this->objectid . ',' . $this->contextid;
-            if ($this->contextlevel == CONTEXT_MODULE) {
-                return new \moodle_url('/question/edit.php', array('cmid' => $this->contextinstanceid, 'cat' => $cat));
-            }
-            return new \moodle_url('/question/edit.php', array('courseid' => $this->courseid, 'cat' => $cat));
-        }
-
-        // Bad luck, there does not seem to be any simple intelligent way
-        // to go to specific question category in context above course,
-        // let's try to edit it from frontpage which may surprisingly work.
-        return new \moodle_url('/question/category.php', array('courseid' => SITEID, 'edit' => $this->objectid));
-    }
-
     /**
      * Return the legacy event log data.
      *
@@ -97,7 +76,4 @@ class question_category_created extends base {
         return null;
     }
 
-    public static function get_objectid_mapping() {
-        return array('db' => 'question_categories', 'restore' => 'question_category');
-    }
 }
diff --git a/lib/classes/event/question_category_deleted.php b/lib/classes/event/question_category_deleted.php
new file mode 100644 (file)
index 0000000..94c5e76
--- /dev/null
@@ -0,0 +1,65 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Question category deleted event.
+ *
+ * @package    core
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Question category deleted event class.
+ *
+ * @package    core
+ * @since      Moodle 3.7
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class question_category_deleted extends question_category_base {
+
+    /**
+     * Init method.
+     */
+    protected function init() {
+        parent::init();
+        $this->data['crud'] = 'd';
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventquestioncategorydeleted', 'question');
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' deleted the question category with id '$this->objectid'.";
+    }
+
+}
diff --git a/lib/classes/event/question_category_moved.php b/lib/classes/event/question_category_moved.php
new file mode 100644 (file)
index 0000000..05e867b
--- /dev/null
@@ -0,0 +1,65 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Question category moved event.
+ *
+ * @package    core
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Question category moved event class.
+ *
+ * @package    core
+ * @since      Moodle 3.7
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class question_category_moved extends question_category_base {
+
+    /**
+     * Init method.
+     */
+    protected function init() {
+        parent::init();
+        $this->data['crud'] = 'u';
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventquestioncategorymoved', 'question');
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' moved the question category with id '$this->objectid'.";
+    }
+
+}
diff --git a/lib/classes/event/question_category_updated.php b/lib/classes/event/question_category_updated.php
new file mode 100644 (file)
index 0000000..e5b0e24
--- /dev/null
@@ -0,0 +1,64 @@
+<?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/>.
+
+/**
+ * Question category updated event.
+ *
+ * @package    core
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Question category updated event class.
+ *
+ * @package    core
+ * @since      Moodle 3.7
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class question_category_updated extends question_category_base {
+
+    /**
+     * Init method.
+     */
+    protected function init() {
+         parent::init();
+        $this->data['crud'] = 'u';
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventquestioncategoryupdated', 'question');
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' updated the question category with id '$this->objectid'.";
+    }
+}
diff --git a/lib/classes/event/question_category_viewed.php b/lib/classes/event/question_category_viewed.php
new file mode 100644 (file)
index 0000000..d6595eb
--- /dev/null
@@ -0,0 +1,65 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Question category viewed event.
+ *
+ * @package    core
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Question category viewed event class.
+ *
+ * @package    core
+ * @since      Moodle 3.7
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class question_category_viewed extends question_category_base {
+
+    /**
+     * Init method.
+     */
+    protected function init() {
+        parent::init();
+        $this->data['crud'] = 'r';
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventquestioncategoryviewed', 'question');
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' viewed the question category with id '$this->objectid'.";
+    }
+
+}
diff --git a/lib/classes/event/question_created.php b/lib/classes/event/question_created.php
new file mode 100644 (file)
index 0000000..b05e1fb
--- /dev/null
@@ -0,0 +1,87 @@
+<?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/>.
+
+/**
+ * Question created event.
+ *
+ * @package    core
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Question created event class.
+ *
+ * @property-read array $other {
+ *      Extra information about the event.
+ *
+ *      - int categoryid: The ID of the category where the question resides
+ * }
+ *
+ * @package    core
+ * @since      Moodle 3.7
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class question_created extends question_base {
+
+    /**
+     * Init method.
+     */
+    protected function init() {
+        parent::init();
+        $this->data['crud'] = 'c';
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventquestioncreated', 'question');
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' created a question with the id of '$this->objectid'" .
+                " in the category with the id '" . $this->other['categoryid'] . "'.";
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        if ($this->courseid) {
+            if ($this->contextlevel == CONTEXT_MODULE) {
+                return new \moodle_url('/question/preview.php', ['cmid' => $this->contextinstanceid, 'id' => $this->objectid]);
+            }
+            return new \moodle_url('/question/preview.php', ['courseid' => $this->courseid, 'id' => $this->objectid]);
+        }
+        // Lets try editing from the frontpage for contexts above course.
+        return new \moodle_url('/question/preview.php', ['courseid' => SITEID, 'id' => $this->objectid]);
+    }
+}
diff --git a/lib/classes/event/question_deleted.php b/lib/classes/event/question_deleted.php
new file mode 100644 (file)
index 0000000..17363d1
--- /dev/null
@@ -0,0 +1,82 @@
+<?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/>.
+
+/**
+ * Question deleted event.
+ *
+ * @package    core
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Question deleted event class.
+ *
+ * @property-read array $other {
+ *      Extra information about the event.
+ *
+ *      - int categoryid: The ID of the category where the question resides
+ * }
+ *
+ * @package    core
+ * @since      Moodle 3.7
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class question_deleted extends question_base {
+
+    /**
+     * Init method.
+     */
+    protected function init() {
+        parent::init();
+        $this->data['crud'] = 'd';
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventquestiondeleted', 'question');
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' deleted the question with id '$this->objectid'" .
+                " from the category with the id '" . $this->other['categoryid'] . "'.";
+    }
+
+    /**
+     * Returns relevant URL.
+     * This is needed to override the function in question_base
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        // No URL for delete.
+        return null;
+    }
+}
diff --git a/lib/classes/event/question_moved.php b/lib/classes/event/question_moved.php
new file mode 100644 (file)
index 0000000..a798f81
--- /dev/null
@@ -0,0 +1,132 @@
+<?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/>.
+
+/**
+ * Question moved event.
+ *
+ * @package    core
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Question moved event class.
+ *
+ * @property-read array $other {
+ *      Extra information about the event.
+ *
+ *      - int newcategoryid: The ID of the new category of the question
+ *      - int oldcategoryid: The ID of the old category of the question
+ * }
+ *
+ * @package    core
+ * @since      Moodle 3.7
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class question_moved extends question_base {
+
+    /**
+     * Init method.
+     */
+    protected function init() {
+        parent::init();
+        $this->data['crud'] = 'u';
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventquestionmoved', 'question');
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' moved the question with the id of '$this->objectid'" .
+                " from the category with the id of '" . $this->other['oldcategoryid'] .
+                "' to the category with the id of '" . $this->other['newcategoryid'] . "'.";
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        if ($this->courseid) {
+            $cat = $this->other['newcategoryid'] . ',' . $this->contextid;
+            if ($this->contextlevel == CONTEXT_MODULE) {
+                return new \moodle_url('/question/edit.php',
+                        ['cmid' => $this->contextinstanceid, 'cat' => $cat, 'lastchanged' => $this->objectid]);
+            }
+            return new \moodle_url('/question/edit.php',
+                    ['courseid' => $this->courseid, 'cat' => $cat, 'lastchanged' => $this->objectid]);
+        }
+        // Lets try viewing from the frontpage for contexts above course.
+        return new \moodle_url('/question/category.php',
+                ['courseid' => SITEID, 'edit' => $this->other['newcategoryid'], 'lastchanged' => $this->objectid]);
+    }
+
+    /**
+     * Custom validations.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+
+        if (!isset($this->other['oldcategoryid'])) {
+            throw new \coding_exception('The \'oldcategoryid\' must be set in \'other\'.');
+        }
+        if (!isset($this->other['newcategoryid'])) {
+            throw new \coding_exception('The \'newcategoryid\' must be set in \'other\'.');
+        }
+    }
+
+    /**
+     * Returns DB mappings used with backup / restore.
+     *
+     * @return array
+     */
+    public static function get_objectid_mapping() {
+        return ['db' => 'question', 'restore' => 'question'];
+    }
+
+    /**
+     * Used for maping events on restore
+     *
+     * @return array
+     */
+    public static function get_other_mapping() {
+
+        $othermapped = [];
+        $othermapped['newcategoryid'] = ['db' => 'question_categories', 'restore' => 'question_categories'];
+        $othermapped['oldcategoryid'] = ['db' => 'question_categories', 'restore' => 'question_categories'];
+
+        return $othermapped;
+    }
+}
diff --git a/lib/classes/event/question_updated.php b/lib/classes/event/question_updated.php
new file mode 100644 (file)
index 0000000..4e5bdf9
--- /dev/null
@@ -0,0 +1,71 @@
+<?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/>.
+
+/**
+ * Question updated event.
+ *
+ * @package    core
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Question updated event class.
+ *
+ * @property-read array $other {
+ *      Extra information about the event.
+ *
+ *      - int categoryid: The ID of the category where the question resides
+ * }
+ *
+ * @package    core
+ * @since      Moodle 3.7
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class question_updated extends question_base {
+
+    /**
+     * Init method.
+     */
+    protected function init() {
+        parent::init();
+        $this->data['crud'] = 'u';
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventquestionupdated', 'question');
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' updated the question with the id of '$this->objectid'.";
+    }
+
+}
diff --git a/lib/classes/event/question_viewed.php b/lib/classes/event/question_viewed.php
new file mode 100644 (file)
index 0000000..9ef0d37
--- /dev/null
@@ -0,0 +1,71 @@
+<?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/>.
+
+/**
+ * Question previewed event.
+ *
+ * @package    core
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Question previewed event class.
+ *
+ * @property-read array $other {
+ *      Extra information about the event.
+ *
+ *      - int categoryid: The ID of the category where the question resides
+ * }
+ *
+ * @package    core
+ * @since      Moodle 3.7
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class question_viewed extends question_base {
+
+    /**
+     * Init method.
+     */
+    protected function init() {
+        parent::init();
+        $this->data['crud'] = 'r';
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventquestionviewed', 'question');
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' viewed the question with the id of '$this->objectid'.";
+    }
+
+}
diff --git a/lib/classes/event/questions_exported.php b/lib/classes/event/questions_exported.php
new file mode 100644 (file)
index 0000000..adc3117
--- /dev/null
@@ -0,0 +1,116 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Questions exported event.
+ *
+ * @package    core
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Question category exported event class.
+ *
+ * @property-read array $other {
+ *      Extra information about the event.
+ *
+ *      - int categoryid: The ID of the category where the question resides
+ *      - string format: The format of file export
+ * }
+ *
+ * @package    core
+ * @since      Moodle 3.7
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class questions_exported extends question_base {
+
+    /**
+     * Init method.
+     */
+    protected function init() {
+        $this->data['crud'] = 'r';
+        $this->data['edulevel'] = self::LEVEL_TEACHING;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventquestionsexported', 'question');
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' exported questions in '" . $this->other['format'] .
+                "' format from the category with id '" . $this->other['categoryid'] . "'.";
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        if ($this->courseid) {
+            $cat = $this->other['categoryid'] . ',' . $this->contextid;
+            if ($this->contextlevel == CONTEXT_MODULE) {
+                return new \moodle_url('/question/edit.php', ['cmid' => $this->contextinstanceid, 'cat' => $cat]);
+            }
+            return new \moodle_url('/question/edit.php', ['courseid' => $this->courseid, 'cat' => $cat]);
+        }
+        return new \moodle_url('/question/category.php', ['courseid' => SITEID, 'edit' => $this->other['categoryid']]);
+    }
+
+    /**
+     * Custom validations.
+     *
+     * other['categoryid'] and other['format'] is required.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['format'])) {
+            throw new \coding_exception('The \'format\' must be set in \'other\'.');
+        }
+    }
+
+    /**
+     * Returns DB mappings used with backup / restore.
+     * This is needed to override the function in question_base
+     *
+     * @return array
+     */
+    public static function get_objectid_mapping() {
+        // No mappings.
+        return [];
+    }
+
+}
diff --git a/lib/classes/event/questions_imported.php b/lib/classes/event/questions_imported.php
new file mode 100644 (file)
index 0000000..d774c40
--- /dev/null
@@ -0,0 +1,115 @@
+<?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/>.
+
+/**
+ * Questions imported event.
+ *
+ * @package    core
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Question category imported event class.
+ *
+ * @property-read array $other {
+ *      Extra information about the event.
+ *
+ *      - int categoryid: The ID of the category where the question resides
+ *      - string format: The format of file import
+ * }
+ *
+ * @package    core
+ * @since      Moodle 3.7
+ * @copyright  2019 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class questions_imported extends question_base {
+
+    /**
+     * Init method.
+     */
+    protected function init() {
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_TEACHING;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventquestionsimported', 'question');
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' imported questions in '" . $this->other['format'] .
+                "' format into the category with id '" . $this->other['categoryid'] . "'.";
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        if ($this->courseid) {
+            $cat = $this->other['categoryid'] . ',' . $this->contextid;
+            if ($this->contextlevel == CONTEXT_MODULE) {
+                return new \moodle_url('/question/edit.php', ['cmid' => $this->contextinstanceid, 'cat' => $cat]);
+            }
+            return new \moodle_url('/question/edit.php', ['courseid' => $this->courseid, 'cat' => $cat]);
+        }
+        return new \moodle_url('/question/category.php', ['courseid' => SITEID, 'edit' => $this->other['categoryid']]);
+    }
+
+    /**
+     * Custom validations.
+     *
+     * other['categoryid'] and other['format'] is required.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['format'])) {
+            throw new \coding_exception('The \'format\' must be set in \'other\'.');
+        }
+    }
+
+    /**
+     * Returns DB mappings used with backup / restore.
+     * This is needed to override the function in question_base
+     *
+     * @return array
+     */
+    public static function get_objectid_mapping() {
+        // No mappings.
+        return [];
+    }
+}
index 2eee814..6195288 100644 (file)
@@ -79,14 +79,16 @@ abstract class exporter {
             }
 
             $missingdataerr = 'Exporter class is missing required related data: (' . get_called_class() . ') ';
+            $scalartypes = ['string', 'int', 'bool', 'float'];
+            $scalarcheck = 'is_' . $classname;
 
-            if ($nullallowed && array_key_exists($key, $related) && $related[$key] === null) {
-                $this->related[$key] = $related[$key];
+            if ($nullallowed && (!array_key_exists($key, $related) || $related[$key] === null)) {
+                $this->related[$key] = null;
 
             } else if ($isarray) {
                 if (array_key_exists($key, $related) && is_array($related[$key])) {
                     foreach ($related[$key] as $index => $value) {
-                        if (!$value instanceof $classname) {
+                        if (!$value instanceof $classname && !$scalarcheck($value)) {
                             throw new coding_exception($missingdataerr . $key . ' => ' . $classname . '[]');
                         }
                     }
@@ -96,8 +98,6 @@ abstract class exporter {
                 }
 
             } else {
-                $scalartypes = ['string', 'int', 'bool', 'float'];
-                $scalarcheck = 'is_' . $classname;
                 if (array_key_exists($key, $related) &&
                         ((in_array($classname, $scalartypes) && $scalarcheck($related[$key])) ||
                         ($related[$key] instanceof $classname))) {
index 249961e..aa138d6 100644 (file)
@@ -356,6 +356,7 @@ class icon_system_fontawesome extends icon_system_font {
             'core:t/edit' => 'fa-cog',
             'core:t/emailno' => 'fa-ban',
             'core:t/email' => 'fa-envelope-o',
+            'core:t/emptystar' => 'fa-star-o',
             'core:t/enrolusers' => 'fa-user-plus',
             'core:t/expanded' => 'fa-caret-down',
             'core:t/go' => 'fa-play',
index a6597d8..2457200 100644 (file)
@@ -44,13 +44,97 @@ class completion_daily_task extends scheduled_task {
      * Throw exceptions on errors (the job will be retried).
      */
     public function execute() {
-        global $CFG;
+        global $CFG, $DB;
 
         if ($CFG->enablecompletion) {
-            // Daily Completion cron.
-            require_once($CFG->dirroot.'/completion/cron.php');
-            completion_cron_mark_started();
+            require_once($CFG->libdir . "/completionlib.php");
+
+            if (debugging()) {
+                mtrace('Marking users as started');
+            }
+
+            // This causes it to default to everyone (if there is no student role).
+            $sqlroles = '';
+            if (!empty($CFG->gradebookroles)) {
+                $sqlroles = ' AND ra.roleid IN (' . $CFG->gradebookroles.')';
+            }
+
+            // It's purpose is to locate all the active participants of a course with course completion enabled.
+            // We also only want the users with no course_completions record as this functions job is to create
+            // the missing ones :)
+            // We want to record the user's enrolment start time for the course. This gets tricky because there can be
+            // multiple enrolment plugins active in a course, hence the possibility of multiple records for each
+            // couse/user in the results.
+            $sql = "SELECT c.id AS course, u.id AS userid, crc.id AS completionid, ue.timestart AS timeenrolled,
+                           ue.timecreated
+                      FROM {user} u
+                INNER JOIN {user_enrolments} ue ON ue.userid = u.id
+                INNER JOIN {enrol} e ON e.id = ue.enrolid
+                INNER JOIN {course} c ON c.id = e.courseid
+                INNER JOIN {role_assignments} ra ON ra.userid = u.id
+                 LEFT JOIN {course_completions} crc ON crc.course = c.id AND crc.userid = u.id
+                     WHERE c.enablecompletion = 1
+                       AND crc.timeenrolled IS NULL
+                       AND ue.status = 0
+                       AND e.status = 0
+                       AND u.deleted = 0
+                       AND ue.timestart < ?
+                       AND (ue.timeend > ? OR ue.timeend = 0)
+                       $sqlroles
+                  ORDER BY course, userid";
+            $now = time();
+            $rs = $DB->get_recordset_sql($sql, [$now, $now, $now, $now]);
+
+            // Check if result is empty.
+            if (!$rs->valid()) {
+                // Not going to iterate (but exit), close rs.
+                $rs->close();
+                return;
+            }
+
+            // We are essentially doing a group by in the code here (as I can't find a decent way of doing it
+            // in the sql). Since there can be multiple enrolment plugins for each course, we can have multiple rows
+            // for each participant in the query result. This isn't really a problem until you combine it with the fact
+            // that the enrolment plugins can save the enrol start time in either timestart or timeenrolled.
+            // The purpose of the loop is to find the earliest enrolment start time for each participant in each course.
+            $prev = null;
+            while ($rs->valid() || $prev) {
+                $current = $rs->current();
+                if (!isset($current->course)) {
+                    $current = false;
+                } else {
+                    // Not all enrol plugins fill out timestart correctly, so use whichever is non-zero.
+                    $current->timeenrolled = max($current->timecreated, $current->timeenrolled);
+                }
+
+                // If we are at the last record, or we aren't at the first and the record is for a diff user/course.
+                if ($prev && (!$rs->valid() ||
+                        ($current->course != $prev->course || $current->userid != $prev->userid))) {
+
+                    $completion = new \completion_completion();
+                    $completion->userid = $prev->userid;
+                    $completion->course = $prev->course;
+                    $completion->timeenrolled = (string) $prev->timeenrolled;
+                    $completion->timestarted = 0;
+                    $completion->reaggregate = time();
+                    if ($prev->completionid) {
+                        $completion->id = $prev->completionid;
+                    }
+                    $completion->mark_enrolled();
+
+                    if (debugging()) {
+                        mtrace('Marked started user ' . $prev->userid . ' in course ' . $prev->course);
+                    }
+                } else if ($prev && $current) {
+                    // Else, if this record is for the same user/course use oldest timeenrolled.
+                    $current->timeenrolled = min($current->timeenrolled, $prev->timeenrolled);
+                }
+                // Move current record to previous.
+                $prev = $current;
+                // Move to next record.
+                $rs->next();
+            }
+            $rs->close();
         }
     }
-
 }
index 19be43f..40d4572 100644 (file)
@@ -44,13 +44,155 @@ class completion_regular_task extends scheduled_task {
      * Throw exceptions on errors (the job will be retried).
      */
     public function execute() {
-        global $CFG;
+        global $CFG, $COMPLETION_CRITERIA_TYPES, $DB;
 
         if ($CFG->enablecompletion) {
-            // Regular Completion cron.
-            require_once($CFG->dirroot.'/completion/cron.php');
-            completion_cron_criteria();
-            completion_cron_completions();
+            require_once($CFG->libdir . "/completionlib.php");
+
+            // Process each criteria type.
+            foreach ($COMPLETION_CRITERIA_TYPES as $type) {
+                $object = 'completion_criteria_' . $type;
+                require_once($CFG->dirroot . '/completion/criteria/' . $object . '.php');
+
+                $class = new $object();
+                // Run the criteria type's cron method, if it has one.
+                if (method_exists($class, 'cron')) {
+                    if (debugging()) {
+                        mtrace('Running '.$object.'->cron()');
+                    }
+                    $class->cron();
+                }
+            }
+
+            if (debugging()) {
+                mtrace('Aggregating completions');
+            }
+
+            // Save time started.
+            $timestarted = time();
+
+            // Grab all criteria and their associated criteria completions.
+            $sql = 'SELECT DISTINCT c.id AS course, cr.id AS criteriaid, crc.userid AS userid,
+                                    cr.criteriatype AS criteriatype, cc.timecompleted AS timecompleted
+                      FROM {course_completion_criteria} cr
+                INNER JOIN {course} c ON cr.course = c.id
+                INNER JOIN {course_completions} crc ON crc.course = c.id
+                 LEFT JOIN {course_completion_crit_compl} cc ON cc.criteriaid = cr.id AND crc.userid = cc.userid
+                     WHERE c.enablecompletion = 1
+                       AND crc.timecompleted IS NULL
+                       AND crc.reaggregate > 0
+                       AND crc.reaggregate < :timestarted
+                  ORDER BY course, userid';
+            $rs = $DB->get_recordset_sql($sql, ['timestarted' => $timestarted]);
+
+            // Check if result is empty.
+            if (!$rs->valid()) {
+                $rs->close();
+                return;
+            }
+
+            $currentuser = null;
+            $currentcourse = null;
+            $completions = [];
+            while (1) {
+                // Grab records for current user/course.
+                foreach ($rs as $record) {
+                    // If we are still grabbing the same users completions.
+                    if ($record->userid === $currentuser && $record->course === $currentcourse) {
+                        $completions[$record->criteriaid] = $record;
+                    } else {
+                        break;
+                    }
+                }
+
+                // Aggregate.
+                if (!empty($completions)) {
+                    if (debugging()) {
+                        mtrace('Aggregating completions for user ' . $currentuser . ' in course ' . $currentcourse);
+                    }
+
+                    // Get course info object.
+                    $info = new \completion_info((object)['id' => $currentcourse]);
+
+                    // Setup aggregation.
+                    $overall = $info->get_aggregation_method();
+                    $activity = $info->get_aggregation_method(COMPLETION_CRITERIA_TYPE_ACTIVITY);
+                    $prerequisite = $info->get_aggregation_method(COMPLETION_CRITERIA_TYPE_COURSE);
+                    $role = $info->get_aggregation_method(COMPLETION_CRITERIA_TYPE_ROLE);
+
+                    $overallstatus = null;
+                    $activitystatus = null;
+                    $prerequisitestatus = null;
+                    $rolestatus = null;
+
+                    // Get latest timecompleted.
+                    $timecompleted = null;
+
+                    // Check each of the criteria.
+                    foreach ($completions as $params) {
+                        $timecompleted = max($timecompleted, $params->timecompleted);
+                        $completion = new \completion_criteria_completion((array)$params, false);
+
+                        // Handle aggregation special cases.
+                        if ($params->criteriatype == COMPLETION_CRITERIA_TYPE_ACTIVITY) {
+                            completion_cron_aggregate($activity, $completion->is_complete(), $activitystatus);
+                        } else if ($params->criteriatype == COMPLETION_CRITERIA_TYPE_COURSE) {
+                            completion_cron_aggregate($prerequisite, $completion->is_complete(), $prerequisitestatus);
+                        } else if ($params->criteriatype == COMPLETION_CRITERIA_TYPE_ROLE) {
+                            completion_cron_aggregate($role, $completion->is_complete(), $rolestatus);
+                        } else {
+                            completion_cron_aggregate($overall, $completion->is_complete(), $overallstatus);
+                        }
+                    }
+
+                    // Include role criteria aggregation in overall aggregation.
+                    if ($rolestatus !== null) {
+                        completion_cron_aggregate($overall, $rolestatus, $overallstatus);
+                    }
+
+                    // Include activity criteria aggregation in overall aggregation.
+                    if ($activitystatus !== null) {
+                        completion_cron_aggregate($overall, $activitystatus, $overallstatus);
+                    }
+
+                    // Include prerequisite criteria aggregation in overall aggregation.
+                    if ($prerequisitestatus !== null) {
+                        completion_cron_aggregate($overall, $prerequisitestatus, $overallstatus);
+                    }
+
+                    // If aggregation status is true, mark course complete for user.
+                    if ($overallstatus) {
+                        if (debugging()) {
+                            mtrace('Marking complete');
+                        }
+
+                        $ccompletion = new \completion_completion([
+                            'course' => $params->course,
+                            'userid' => $params->userid
+                        ]);
+                        $ccompletion->mark_complete($timecompleted);
+                    }
+                }
+
+                // If this is the end of the recordset, break the loop.
+                if (!$rs->valid()) {
+                    $rs->close();
+                    break;
+                }
+
+                // New/next user, update user details, reset completions.
+                $currentuser = $record->userid;
+                $currentcourse = $record->course;
+                $completions = [];
+                $completions[$record->criteriaid] = $record;
+            }
+
+            // Mark all users as aggregated.
+            $sql = "UPDATE {course_completions}
+                       SET reaggregate = 0
+                     WHERE reaggregate < :timestarted
+                       AND reaggregate > 0";
+            $DB->execute($sql, ['timestarted' => $timestarted]);
         }
     }
 
index cac848f..db3abeb 100644 (file)
@@ -1370,3 +1370,26 @@ class completion_info {
             $CFG->wwwroot.'/course/view.php?id='.$this->course->id,null,$error);
     }
 }
+
+/**
+ * Aggregate criteria status's as per configured aggregation method.
+ *
+ * @param int $method COMPLETION_AGGREGATION_* constant.
+ * @param bool $data Criteria completion status.
+ * @param bool|null $state Aggregation state.
+ */
+function completion_cron_aggregate($method, $data, &$state) {
+    if ($method == COMPLETION_AGGREGATION_ALL) {
+        if ($data && $state !== false) {
+            $state = true;
+        } else {
+            $state = false;
+        }
+    } else if ($method == COMPLETION_AGGREGATION_ANY) {
+        if ($data) {
+            $state = true;
+        } else if (!$data && $state === null) {
+            $state = false;
+        }
+    }
+}
index 567a146..71ebe33 100644 (file)
@@ -384,4 +384,11 @@ $definitions = array(
         'simpledata' => true,
         'ttl' => 1800
     ),
+
+    // Caches the first time we analysed models' analysables.
+    'modelfirstanalyses' => array(
+        'mode' => cache_store::MODE_REQUEST,
+        'simplekeys' => true,
+        'simpledata' => true,
+    ),
 );
index 434974d..e901acb 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20190403" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20190412" COMMENT="XMLDB file for core Moodle tables"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
 >
         <FIELD NAME="modelid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="action" TYPE="char" LENGTH="50" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="analysableid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="firstanalysis" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="timeanalysed" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
       </FIELDS>
       <KEYS>
index dea5215..744561c 100644 (file)
@@ -3128,12 +3128,13 @@ function xmldb_main_upgrade($oldversion) {
             $mua->timecreated = $message->timeread;
 
             $DB->insert_record('message_user_actions', $mua);
+
+            // The self-conversation message has been migrated. Delete the record from the legacy table as soon as possible
+            // to avoid migrate it twice.
+            $DB->delete_records('message_read', ['id' => $message->id]);
         }
         $legacyselfmessagesrs->close();
 
-        // We can now delete the records from legacy table because the self-conversations have been migrated from the legacy tables.
-        $DB->delete_records_select('message_read', $select);
-
         // STEP 3. For existing users without self-conversations, create and star it.
 
         // Get all the users without a self-conversation.
@@ -3270,5 +3271,35 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2019042300.03);
     }
 
+    if ($oldversion < 2019042700.01) {
+
+        // Define field firstanalysis to be added to analytics_used_analysables.
+        $table = new xmldb_table('analytics_used_analysables');
+
+        // Declaring it as null initially (although it is NOT NULL).
+        $field = new xmldb_field('firstanalysis', XMLDB_TYPE_INTEGER, '10', null, null, null, null, 'analysableid');
+
+        // Conditionally launch add field firstanalysis.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+
+            // Set existing values to the current timeanalysed value.
+            $recordset = $DB->get_recordset('analytics_used_analysables');
+            foreach ($recordset as $record) {
+                $record->firstanalysis = $record->timeanalysed;
+                $DB->update_record('analytics_used_analysables', $record);
+            }
+            $recordset->close();
+
+            // Now make the field 'NOT NULL'.
+            $field = new xmldb_field('firstanalysis', XMLDB_TYPE_INTEGER, '10',
+                null, XMLDB_NOTNULL, null, null, 'analysableid');
+            $dbman->change_field_notnull($table, $field);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2019042700.01);
+    }
+
     return true;
 }
index 9e2c96f..11b89fe 100644 (file)
@@ -1139,6 +1139,16 @@ abstract class moodleform {
                             $params = array_merge(array($realelementname), $params);
                             call_user_func_array(array(&$mform, 'disabledIf'), $params);
                             break;
+                        case 'hideif' :
+                            foreach ($namecloned as $num => $name){
+                                if ($params[0] == $name){
+                                    $params[0] = $params[0]."[$i]";
+                                    break;
+                                }
+                            }
+                            $params = array_merge(array($realelementname), $params);
+                            call_user_func_array(array(&$mform, 'hideIf'), $params);
+                            break;
                         case 'rule' :
                             if (is_string($params)){
                                 $params = array(null, $params, null, 'client');
index a465c7d..6d96f84 100644 (file)
@@ -380,6 +380,11 @@ function question_delete_question($questionid) {
     // Finally delete the question record itself
     $DB->delete_records('question', array('id' => $questionid));
     question_bank::notify_question_edited($questionid);
+
+    // Log the deletion of this question.
+    $event = \core\event\question_deleted::create_from_question_instance($question);
+    $event->add_record_snapshot('question', $question);
+    $event->trigger();
 }
 
 /**
@@ -673,7 +678,7 @@ function question_move_questions_to_category($questionids, $newcategoryid) {
             array('id' => $newcategoryid));
     list($questionidcondition, $params) = $DB->get_in_or_equal($questionids);
     $questions = $DB->get_records_sql("
-            SELECT q.id, q.qtype, qc.contextid, q.idnumber
+            SELECT q.id, q.qtype, qc.contextid, q.idnumber, q.category
               FROM {question} q
               JOIN {question_categories} qc ON q.category = qc.id
              WHERE  q.id $questionidcondition", $params);
@@ -703,6 +708,11 @@ function question_move_questions_to_category($questionids, $newcategoryid) {
             $q->idnumber = $question->idnumber . '_' . $unique;
             $DB->update_record('question', $q);
         }
+
+        // Log this question move.
+        $event = \core\event\question_moved::create_from_question_instance($question, context::instance_by_id($question->contextid),
+                ['oldcategoryid' => $question->category, 'newcategoryid' => $newcategoryid]);
+        $event->trigger();
     }
 
     // Move the questions themselves.
index 57929ca..b56cd13 100644 (file)
@@ -47,7 +47,8 @@ class core_exporter_testcase extends advanced_testcase {
             'context' => null,
             'aint' => 5,
             'astring' => 'valid string',
-            'abool' => false
+            'abool' => false,
+            'ints' => []
         );
         $this->invalidrelated = array(
             'simplestdClass' => 'a string',
@@ -55,7 +56,8 @@ class core_exporter_testcase extends advanced_testcase {
             'context' => null,
             'aint' => false,
             'astring' => 4,
-            'abool' => 'not a boolean'
+            'abool' => 'not a boolean',
+            'ints' => null
         );
 
         $this->validdata = array('stringA' => 'A string', 'stringAformat' => FORMAT_HTML, 'intB' => 4);
@@ -118,6 +120,23 @@ class core_exporter_testcase extends advanced_testcase {
         $result = $exporter->export($output);
     }
 
+    public function test_invalid_related_all_cases() {
+        global $PAGE;
+
+        foreach ($this->invalidrelated as $key => $value) {
+            $data = $this->validrelated;
+            $data[$key] = $value;
+
+            try {
+                $exporter = new core_testable_exporter($this->validdata, $data);
+                $output = $PAGE->get_renderer('core');
+                $result = $exporter->export($output);
+            } catch (coding_exception $e) {
+                $this->assertNotFalse(strpos($e->getMessage(), $key));
+            }
+        }
+    }
+
     public function test_valid_data_and_related() {
         global $PAGE;
         $output = $PAGE->get_renderer('core');
@@ -196,7 +215,7 @@ class core_testable_exporter extends \core\external\exporter {
     protected static function define_related() {
         // We cache the context so it does not need to be retrieved from the course.
         return array('simplestdClass' => 'stdClass', 'arrayofstdClass' => 'stdClass[]', 'context' => 'context?',
-            'astring' => 'string', 'abool' => 'bool', 'aint' => 'int');
+            'astring' => 'string', 'abool' => 'bool', 'aint' => 'int', 'ints' => 'int[]');
     }
 
     protected function get_other_values(renderer_base $output) {
index 86a9efd..6394a3e 100644 (file)
@@ -26,6 +26,7 @@
 defined('MOODLE_INTERNAL') || die();
 
 require_once(__DIR__ . '/../../analytics/tests/fixtures/test_timesplitting_seconds.php');
+require_once(__DIR__ . '/../../analytics/tests/fixtures/test_timesplitting_upcoming_seconds.php');
 require_once(__DIR__ . '/../../analytics/tests/fixtures/test_timesplitting_weekly.php');
 require_once(__DIR__ . '/../../lib/enrollib.php');
 
@@ -212,21 +213,6 @@ class core_analytics_time_splittings_testcase extends advanced_testcase {
         $range = reset($ranges);
         $this->assertEquals(51, key($ranges));
 
-        $upcomingweek = new \core\analytics\time_splitting\upcoming_week();
-        $upcomingweek->set_analysable($this->analysable);
-        $this->assertCount(1, $upcomingweek->get_distinct_ranges());
-
-        $ranges = $upcomingweek->get_all_ranges();
-        $this->assertEquals(53, count($ranges));
-        $this->assertEquals($this->course->startdate, $ranges[0]['start']);
-        $this->assertEquals($this->course->startdate, $ranges[0]['time']);
-
-        $this->assertCount(count($ranges), $upcomingweek->get_training_ranges());
-
-        $ranges = $upcomingweek->get_most_recent_prediction_range();
-        $range = reset($ranges);
-        $this->assertEquals(52, key($ranges));
-
         // We now use an ongoing course.
 
         $onemonthago = new DateTime('-30 days');
@@ -251,21 +237,35 @@ class core_analytics_time_splittings_testcase extends advanced_testcase {
         $this->assertLessThan(time(), $range['start']);
         $this->assertLessThan(time(), $range['end']);
 
+        $starttime = time();
+
         $upcomingweek = new \core\analytics\time_splitting\upcoming_week();
         $upcomingweek->set_analysable($ongoinganalysable);
         $this->assertCount(1, $upcomingweek->get_distinct_ranges());
 
         $ranges = $upcomingweek->get_all_ranges();
-        $this->assertEquals(5, count($ranges));
-        $this->assertCount(4, $upcomingweek->get_training_ranges());
+        $this->assertEquals(1, count($ranges));
+        $range = reset($ranges);
+        $this->assertLessThan(time(), $range['time']);
+        $this->assertLessThan(time(), $range['start']);
+        $this->assertGreaterThan(time(), $range['end']);
+
+        $this->assertCount(0, $upcomingweek->get_training_ranges());
 
         $ranges = $upcomingweek->get_most_recent_prediction_range();
         $range = reset($ranges);
-        $this->assertEquals(4, key($ranges));
+        $this->assertEquals(0, key($ranges));
         $this->assertLessThan(time(), $range['time']);
         $this->assertLessThan(time(), $range['start']);
+        // We substract 1 because upcoming_periodic also has that -1 so that predictions
+        // get executed once the first time range is set.
+        $this->assertGreaterThanOrEqual($starttime - 1, $range['time']);
+        $this->assertGreaterThanOrEqual($starttime - 1, $range['start']);
         $this->assertGreaterThan(time(), $range['end']);
 
+        $this->assertNotEmpty($upcomingweek->get_range_by_index(0));
+        $this->assertFalse($upcomingweek->get_range_by_index(1));
+
         // We now check how new ranges get added as time passes.
 
         $fewsecsago = new DateTime('-5 seconds');
@@ -288,6 +288,29 @@ class core_analytics_time_splittings_testcase extends advanced_testcase {
         // We wait for the next range to be added.
         usleep(1000000);
 
+        // We set the analysable again so the time ranges are recalculated.
+        $seconds->set_analysable($analysable);
+
+        $nnewranges = $seconds->get_all_ranges();
+        $nnewtrainingranges = $seconds->get_training_ranges();
+        $newmostrecentrange = $seconds->get_most_recent_prediction_range();
+        $newmostrecentrange = reset($newmostrecentrange);
+        $this->assertGreaterThan($nranges, $nnewranges);
+        $this->assertGreaterThan($ntrainingranges, $nnewtrainingranges);
+        $this->assertGreaterThan($mostrecentrange['time'], $newmostrecentrange['time']);
+
+        $seconds = new test_timesplitting_upcoming_seconds();
+        $seconds->set_analysable($analysable);
+
+        // Store the ranges we just obtained.
+        $nranges = count($seconds->get_all_ranges());
+        $ntrainingranges = count($seconds->get_training_ranges());
+        $mostrecentrange = $seconds->get_most_recent_prediction_range();
+        $mostrecentrange = reset($mostrecentrange);
+
+        // We wait for the next range to be added.
+        usleep(1000000);
+
         $seconds->set_analysable($analysable);
         $nnewranges = $seconds->get_all_ranges();
         $nnewtrainingranges = $seconds->get_training_ranges();
index 33d3486..d19f641 100644 (file)
@@ -83,6 +83,8 @@ class email_digest implements \renderable, \templatable {
      * @return \stdClass The data ready for use in a mustache template
      */
     public function export_for_template(\renderer_base $renderer) {
+        global $PAGE;
+
         // Prepare the data we are going to send to the template.
         $data = new \stdClass();
         $data->conversations = [];
@@ -97,9 +99,22 @@ class email_digest implements \renderable, \templatable {
 
             $viewallmessageslink = new \moodle_url('/message/index.php', ['convid' => $conversation->id]);
 
+            $group = new \stdClass();
+            $group->id = $conversation->groupid;
+            $group->picture = $conversation->picture;
+            $group->hidepicture = $conversation->hidepicture;
+            $group->courseid = $conversation->courseid;
+            $grouppictureurl = $renderer->image_url('g/g1')->out(false); // Default image.
+            if ($url = get_group_picture_url($group, $group->courseid, false, true)) {
+                $grouppictureurl = $url->out(false);
+            }
+
+            $coursecontext = \context_course::instance($conversation->courseid);
+
             $conversationformatted = new \stdClass();
-            $conversationformatted->groupname = $conversation->name;
-            $conversationformatted->coursename = $conversation->coursename;
+            $conversationformatted->groupname = format_string($conversation->name, true, ['context' => $coursecontext]);
+            $conversationformatted->grouppictureurl = $grouppictureurl;
+            $conversationformatted->coursename = format_string($conversation->coursename, true, ['context' => $coursecontext]);
             $conversationformatted->numberofunreadmessages = count($messages);
             $conversationformatted->messages = [];
             $conversationformatted->viewallmessageslink = \html_writer::link($viewallmessageslink,
@@ -110,8 +125,17 @@ class email_digest implements \renderable, \templatable {
             foreach ($messages as $message) {
                 $user = new \stdClass();
                 username_load_fields_from_object($user, $message);
+                $user->picture = $message->picture;
+                $user->imagealt = $message->imagealt;
+                $user->email = $message->email;
                 $user->id = $message->useridfrom;
+
+                $userpicture = new \user_picture($user);
+                $userpicture->includetoken = true;
+                $userpictureurl = $userpicture->get_url($PAGE)->out(false);
+
                 $messageformatted = new \stdClass();
+                $messageformatted->userpictureurl = $userpictureurl;
                 $messageformatted->userfullname = fullname($user);
                 $messageformatted->message = message_format_message_text($message);
 
index 306fade..08c4e68 100644 (file)
@@ -73,6 +73,8 @@ class send_email_task extends scheduled_task {
         // Keep track of which emails failed to send.
         $users = $this->get_unique_users();
         foreach ($users as $user) {
+            cron_setup_user($user);
+
             $hascontent = false;
             $renderable = new \message_email\output\email_digest($user);
             $conversations = $this->get_conversations_for_user($user->id);
@@ -97,6 +99,7 @@ class send_email_task extends scheduled_task {
                 }
             }
         }
+        cron_setup_user();
         $users->close();
     }
 
index cb15b6e..aafd671 100644 (file)
         "conversations": [
             {
                 "groupname": "Blue Students",
+                "grouppictureurl": "http://example.com/image.jpg",
                 "coursename": "Math 101",
                 "numberofunreadmessages": "2",
                 "messages": [
                     {
                         "userfullname": "Chris Cross",
+                        "userpictureurl": "http://example.com/image.jpg",
                         "message": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla neque nunc, bibendum ac vestibulum sit amet, scelerisque luctus sem. Maecenas ultricies hendrerit augue, ac venenatis odio volutpat nec",
                         "timesent": "10:12"
                     },
                     {
                         "userfullname": "Irene Ipsum",
+                        "userpictureurl": "http://example.com/image.jpg",
                         "message": "Etiam a tristique risus. Pellentesque id tellus eget elit dictum varius id sed sapien",
                         "timesent": "10:14"
                     }
@@ -92,6 +95,9 @@
     <table class="table">
         <thead>
         <tr>
+            <th>
+                <img src="{{{ grouppictureurl }}}" class="round" width="40px" height="40px"/>
+            </th>
             <th>
                 <strong>{{ groupname }}</strong><br>
                 {{ coursename }}
         <tbody>
             {{#messages}}
             <tr>
+                <td>
+                    <img src="{{{ userpictureurl }}}" class="round" width="30px" height="30px">
+                </td>
                 <td>
                     <strong>{{{ userfullname }}}</strong>
                     <p>{{{ message }}}</p>
index eb23e68..e09ca39 100644 (file)
         "conversations": [
             {
                 "groupname": "Blue Students",
+                "grouppictureurl": "http://example.com/image.jpg",
                 "coursename": "Math 101",
                 "numberofunreadmessages": "2",
                 "messages": [
                     {
                         "userfullname": "Chris Cross",
+                        "userpictureurl": "http://example.com/image.jpg",
                         "message": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla neque nunc, bibendum ac vestibulum sit amet, scelerisque luctus sem. Maecenas ultricies hendrerit augue, ac venenatis odio volutpat nec",
                         "timesent": "10:12"
                     },
                     {
                         "userfullname": "Irene Ipsum",
+                        "userpictureurl": "http://example.com/image.jpg",
                         "message": "Etiam a tristique risus. Pellentesque id tellus eget elit dictum varius id sed sapien",
                         "timesent": "10:14"
                     }
@@ -50,6 +53,8 @@
     }
 }}
 {{#conversations}}
+    {{{grouppictureurl}}}
+
     {{groupname}}
 
     {{coursename}}
@@ -57,6 +62,8 @@
     {{numberofunreadmessages}} {{#str}}emaildigestunreadmessages, message_email{{/str}}
 
     {{#messages}}
+        {{{ userpictureurl }}}
+
         {{userfullname}}
 
         {{message}}
index 587472e..9fc3741 100644 (file)
@@ -58,7 +58,7 @@ class mod_choice_mod_form extends moodleform_mod {
 
         $repeateloptions = array();
         $repeateloptions['limit']['default'] = 0;
-        $repeateloptions['limit']['disabledif'] = array('limitanswers', 'eq', 0);
+        $repeateloptions['limit']['hideif'] = array('limitanswers', 'eq', 0);
         $repeateloptions['limit']['rule'] = 'numeric';
         $repeateloptions['limit']['type'] = PARAM_INT;
 
@@ -93,7 +93,7 @@ class mod_choice_mod_form extends moodleform_mod {
         $mform->addElement('select', 'showresults', get_string("publish", "choice"), $CHOICE_SHOWRESULTS);
 
         $mform->addElement('select', 'publish', get_string("privacy", "choice"), $CHOICE_PUBLISH);
-        $mform->disabledIf('publish', 'showresults', 'eq', 0);
+        $mform->hideIf('publish', 'showresults', 'eq', 0);
 
         $mform->addElement('selectyesno', 'showunanswered', get_string("showunanswered", "choice"));
 
index be7af42..6840680 100644 (file)
@@ -35,7 +35,7 @@ class mod_data_mod_form extends moodleform_mod {
         $mform->addElement('selectyesno', 'manageapproved', get_string('manageapproved', 'data'));
         $mform->addHelpButton('manageapproved', 'manageapproved', 'data');
         $mform->setDefault('manageapproved', 1);
-        $mform->disabledIf('manageapproved', 'approval', 'eq', 0);
+        $mform->hideIf('manageapproved', 'approval', 'eq', 0);
 
         $mform->addElement('selectyesno', 'comments', get_string('allowcomments', 'data'));
 
index 2289840..b439afe 100644 (file)
@@ -61,12 +61,16 @@ class mod_feedback_completion extends mod_feedback_structure {
      * @param int $completedid id in the table feedback_completed, may be omitted if userid is specified
      *     but it is highly recommended because the same user may have multiple responses to the same feedback
      *     for different courses
-     * @param int $userid id of the user - if specified only non-anonymous replies will be returned. If not
-     *     specified only anonymous replies will be returned and the $completedid is mandatory.
+     * @param int $nonanonymouseuserid - Return only anonymous results or specified user's results.
+     *     If null only anonymous replies will be returned and the $completedid is mandatory.
+     *     If specified only non-anonymous replies of $nonanonymouseuserid will be returned.
+     * @param int $userid User id to use for all capability checks, etc. Set to 0 for current user (default).
      */
-    public function __construct($feedback, $cm, $courseid, $iscompleted = false, $completedid = null, $userid = null) {
+    public function __construct($feedback, $cm, $courseid, $iscompleted = false, $completedid = null,
+                                $nonanonymouseuserid = null, $userid = 0) {
         global $DB;
-        parent::__construct($feedback, $cm, $courseid, 0);
+
+        parent::__construct($feedback, $cm, $courseid, 0, $userid);
         // Make sure courseid is always set for site feedback.
         if ($this->feedback->course == SITEID && !$this->courseid) {
             $this->courseid = SITEID;
@@ -75,17 +79,17 @@ class mod_feedback_completion extends mod_feedback_structure {
             // Retrieve information about the completion.
             $this->iscompleted = true;
             $params = array('feedback' => $this->feedback->id);
-            if (!$userid && !$completedid) {
-                throw new coding_exception('Either $completedid or $userid must be specified for completed feedbacks');
+            if (!$nonanonymouseuserid && !$completedid) {
+                throw new coding_exception('Either $completedid or $nonanonymouseuserid must be specified for completed feedbacks');
             }
             if ($completedid) {
                 $params['id'] = $completedid;
             }
-            if ($userid) {
+            if ($nonanonymouseuserid) {
                 // We must respect the anonymousity of the reply that the user saw when they were completing the feedback,
                 // not the current state that may have been changed later by the teacher.
                 $params['anonymous_response'] = FEEDBACK_ANONYMOUS_NO;
-                $params['userid'] = $userid;
+                $params['userid'] = $nonanonymouseuserid;
             }
             $this->completed = $DB->get_record('feedback_completed', $params, '*', MUST_EXIST);
             $this->courseid = $this->completed->courseid;
@@ -126,14 +130,14 @@ class mod_feedback_completion extends mod_feedback_structure {
      * @return stdClass|false record from feedback_completedtmp or false if not found
      */
     public function get_current_completed_tmp() {
-        global $USER, $DB;
+        global $DB, $USER;
         if ($this->completedtmp === null) {
             $params = array('feedback' => $this->get_feedback()->id);
             if ($courseid = $this->get_courseid()) {
                 $params['courseid'] = $courseid;
             }
-            if (isloggedin() && !isguestuser()) {
-                $params['userid'] = $USER->id;
+            if ((isloggedin() || $USER->id != $this->userid) && !isguestuser($this->userid)) {
+                $params['userid'] = $this->userid;
             } else {
                 $params['guestid'] = sesskey();
             }
@@ -437,13 +441,13 @@ class mod_feedback_completion extends mod_feedback_structure {
      * @return stdClass record from feedback_completedtmp or false if not found
      */
     protected function create_current_completed_tmp() {
-        global $USER, $DB;
+        global $DB, $USER;
         $record = (object)['feedback' => $this->feedback->id];
         if ($this->get_courseid()) {
             $record->courseid = $this->get_courseid();
         }
-        if (isloggedin() && !isguestuser()) {
-            $record->userid = $USER->id;
+        if ((isloggedin() || $USER->id != $this->userid) && !isguestuser($this->userid)) {
+            $record->userid = $this->userid;
         } else {
             $record->guestid = sesskey();
         }
@@ -535,7 +539,7 @@ class mod_feedback_completion extends mod_feedback_structure {
      * It is also responsible for sending email notifications when applicable.
      */
     public function save_response() {
-        global $USER, $SESSION, $DB;
+        global $SESSION, $DB, $USER;
 
         $feedbackcompleted = $this->find_last_completed();
         $feedbackcompletedtmp = $this->get_current_completed_tmp();
@@ -552,7 +556,7 @@ class mod_feedback_completion extends mod_feedback_structure {
 
         // Send email.
         if ($this->feedback->anonymous == FEEDBACK_ANONYMOUS_NO) {
-            feedback_send_email($this->cm, $this->feedback, $this->cm->get_course(), $USER, $this->completed);
+            feedback_send_email($this->cm, $this->feedback, $this->cm->get_course(), $this->userid, $this->completed);
         } else {
             feedback_send_email_anonym($this->cm, $this->feedback, $this->cm->get_course());
         }
@@ -561,9 +565,9 @@ class mod_feedback_completion extends mod_feedback_structure {
 
         // Update completion state.
         $completion = new completion_info($this->cm->get_course());
-        if (isloggedin() && !isguestuser() && $completion->is_enabled($this->cm) &&
+        if ((isloggedin() || $USER->id != $this->userid) && $completion->is_enabled($this->cm) &&
                 $this->cm->completion == COMPLETION_TRACKING_AUTOMATIC && $this->feedback->completionsubmit) {
-            $completion->update_state($this->cm, COMPLETION_COMPLETE);
+            $completion->update_state($this->cm, COMPLETION_COMPLETE, $this->userid);
         }
     }
 
@@ -586,8 +590,8 @@ class mod_feedback_completion extends mod_feedback_structure {
      * @return stdClass record from feedback_completed or false if not found
      */
     public function find_last_completed() {
-        global $USER, $DB;
-        if (!isloggedin() || isguestuser()) {
+        global $DB, $USER;
+        if ((!isloggedin() && $USER->id == $this->userid) || isguestuser($this->userid)) {
             // Not possible to retrieve completed feedback for guests.
             return false;
         }
@@ -595,7 +599,10 @@ class mod_feedback_completion extends mod_feedback_structure {
             // Not possible to retrieve completed anonymous feedback.
             return false;
         }
-        $params = array('feedback' => $this->feedback->id, 'userid' => $USER->id, 'anonymous_response' => FEEDBACK_ANONYMOUS_NO);
+        $params = array('feedback' => $this->feedback->id,
+            'userid' => $this->userid,
+            'anonymous_response' => FEEDBACK_ANONYMOUS_NO
+        );
         if ($this->get_courseid()) {
             $params['courseid'] = $this->get_courseid();
         }
@@ -604,7 +611,7 @@ class mod_feedback_completion extends mod_feedback_structure {
     }
 
     /**
-     * Checks if current user has capability to submit the feedback
+     * Checks if user has capability to submit the feedback
      *
      * There is an exception for fully anonymous feedbacks when guests can complete
      * feedback without the proper capability.
@@ -616,17 +623,17 @@ class mod_feedback_completion extends mod_feedback_structure {
      * @return bool
      */
     public function can_complete() {
-        global $CFG;
+        global $CFG, $USER;
 
         $context = context_module::instance($this->cm->id);
-        if (has_capability('mod/feedback:complete', $context)) {
+        if (has_capability('mod/feedback:complete', $context, $this->userid)) {
             return true;
         }
 
         if (!empty($CFG->feedback_allowfullanonymous)
                     AND $this->feedback->course == SITEID
                     AND $this->feedback->anonymous == FEEDBACK_ANONYMOUS_YES
-                    AND (!isloggedin() OR isguestuser())) {
+                    AND ((!isloggedin() && $USER->id == $this->userid) || isguestuser($this->userid))) {
             // Guests are allowed to complete fully anonymous feedback without having 'mod/feedback:complete' capability.
             return true;
         }
@@ -670,7 +677,7 @@ class mod_feedback_completion extends mod_feedback_structure {
         require_once($CFG->libdir . '/completionlib.php');
 
         $completion = new completion_info($this->cm->get_course());
-        $completion->set_module_viewed($this->cm);
+        $completion->set_module_viewed($this->cm, $this->userid);
     }
 
     /**
index 2dedb67..8261901 100644 (file)
@@ -47,6 +47,8 @@ class mod_feedback_structure {
     protected $allitems;
     /** @var array */
     protected $allcourses;
+    /** @var int */
+    protected $userid;
 
     /**
      * Constructor
@@ -57,8 +59,11 @@ class mod_feedback_structure {
      *     (at least one of $feedback or $cm is required)
      * @param int $courseid current course (for site feedbacks only)
      * @param int $templateid template id if this class represents the template structure
+     * @param int $userid User id to use for all capability checks, etc. Set to 0 for current user (default).
      */
-    public function __construct($feedback, $cm, $courseid = 0, $templateid = null) {
+    public function __construct($feedback, $cm, $courseid = 0, $templateid = null, $userid = 0) {
+        global $USER;
+
         if ((empty($feedback->id) || empty($feedback->course)) && (empty($cm->instance) || empty($cm->course))) {
             throw new coding_exception('Either $feedback or $cm must be passed to constructor');
         }
@@ -68,6 +73,12 @@ class mod_feedback_structure {
         $this->templateid = $templateid;
         $this->courseid = ($this->feedback->course == SITEID) ? $courseid : 0;
 
+        if (empty($userid)) {
+            $this->userid = $USER->id;
+        } else {
+            $this->userid = $userid;
+        }
+
         if (!$feedback) {
             // If feedback object was not specified, populate object with fields required for the most of methods.
             // These fields were added to course module cache in feedback_get_coursemodule_info().
@@ -201,17 +212,19 @@ class mod_feedback_structure {
      * @return bool
      */
     public function can_view_analysis() {
+        global $USER;
+
         $context = context_module::instance($this->cm->id);
-        if (has_capability('mod/feedback:viewreports', $context)) {
+        if (has_capability('mod/feedback:viewreports', $context, $this->userid)) {
             return true;
         }
 
         if (intval($this->get_feedback()->publish_stats) != 1 ||
-                !has_capability('mod/feedback:viewanalysepage', $context)) {
+                !has_capability('mod/feedback:viewanalysepage', $context, $this->userid)) {
             return false;
         }
 
-        if (!isloggedin() || isguestuser()) {
+        if ((!isloggedin() && $USER->id == $this->userid) || isguestuser($this->userid)) {
             // There is no tracking for the guests, assume that they can view analysis if condition above is satisfied.
             return $this->feedback->course == SITEID;
         }
@@ -228,13 +241,13 @@ class mod_feedback_structure {
      * @return bool true if the feedback already is submitted otherwise false
      */
     public function is_already_submitted($anycourseid = false) {
-        global $USER, $DB;
+        global $DB, $USER;
 
-        if (!isloggedin() || isguestuser()) {
+        if ((!isloggedin() && $USER->id == $this->userid) || isguestuser($this->userid)) {
             return false;
         }
 
-        $params = array('userid' => $USER->id, 'feedback' => $this->feedback->id);
+        $params = array('userid' => $this->userid, 'feedback' => $this->feedback->id);
         if (!$anycourseid && $this->courseid) {
             $params['courseid'] = $this->courseid;
         }
@@ -341,7 +354,8 @@ class mod_feedback_structure {
         $this->allcourses = array();
         foreach ($list as $course) {
             context_helper::preload_from_record($course);
-            if (!$course->visible && !has_capability('moodle/course:viewhiddencourses', context_course::instance($course->id))) {
+            if (!$course->visible &&
+                !has_capability('moodle/course:viewhiddencourses', context_course::instance($course->id), $this->userid)) {
                 // Do not return courses that current user can not see.
                 continue;
             }
index 6efb668..be3db85 100644 (file)
@@ -49,7 +49,7 @@ abstract class feedback_item_form extends moodleform {
                                 'dependvalue',
                                 get_string('dependvalue', 'feedback'),
                                 array('size'=>FEEDBACK_ITEM_LABEL_TEXTBOX_SIZE, 'maxlength'=>255));
-            $mform->disabledIf('dependvalue', 'dependitem', 'eq', '0');
+            $mform->hideIf('dependvalue', 'dependitem', 'eq', '0');
         } else {
             $mform->addElement('hidden', 'dependitem', 0);
             $mform->addElement('hidden', 'dependvalue', '');
index 15af8a3..9059ca3 100644 (file)
@@ -55,12 +55,12 @@ class feedback_multichoice_form extends feedback_item_form {
                             get_string('adjustment', 'feedback').'&nbsp;',
                             array(0 => get_string('vertical', 'feedback'),
                                   1 => get_string('horizontal', 'feedback')));
-        $mform->disabledIf('horizontal', 'subtype', 'eq', 'd');
+        $mform->hideIf('horizontal', 'subtype', 'eq', 'd');
 
         $mform->addElement('selectyesno',
                            'hidenoselect',
                            get_string('hide_no_select_option', 'feedback'));
-        $mform->disabledIf('hidenoselect', 'subtype', 'ne', 'r');
+        $mform->hideIf('hidenoselect', 'subtype', 'ne', 'r');
 
         $mform->addElement('selectyesno',
                            'ignoreempty',
index 7e8b0fe..0dcd0f2 100644 (file)
@@ -54,12 +54,12 @@ class feedback_multichoicerated_form extends feedback_item_form {
                             get_string('adjustment', 'feedback').'&nbsp;',
                             array(0 => get_string('vertical', 'feedback'),
                                   1 => get_string('horizontal', 'feedback')));
-        $mform->disabledIf('horizontal', 'subtype', 'eq', 'd');
+        $mform->hideIf('horizontal', 'subtype', 'eq', 'd');
 
         $mform->addElement('selectyesno',
                            'hidenoselect',
                            get_string('hide_no_select_option', 'feedback'));
-        $mform->disabledIf('hidenoselect', 'subtype', 'eq', 'd');
+        $mform->hideIf('hidenoselect', 'subtype', 'eq', 'd');
 
         $mform->addElement('selectyesno',
                            'ignoreempty',
index c78070f..884dd7d 100644 (file)
@@ -2556,7 +2556,7 @@ function feedback_send_email($cm, $feedback, $course, $user, $completed = null)
         return;
     }
 
-    if (is_int($user)) {
+    if (!is_object($user)) {
         $user = $DB->get_record('user', array('id' => $user));
     }
 
@@ -3009,13 +3009,27 @@ function feedback_check_updates_since(cm_info $cm, $from, $filter = array()) {
  *
  * @param calendar_event $event
  * @param \core_calendar\action_factory $factory
+ * @param int $userid User id to use for all capability checks, etc. Set to 0 for current user (default).
  * @return \core_calendar\local\event\entities\action_interface|null
  */
 function mod_feedback_core_calendar_provide_event_action(calendar_event $event,
-                                                         \core_calendar\action_factory $factory) {
+                                                         \core_calendar\action_factory $factory,
+                                                         int $userid = 0) {
 
-    $cm = get_fast_modinfo($event->courseid)->instances['feedback'][$event->instance];
-    $feedbackcompletion = new mod_feedback_completion(null, $cm, 0);
+    global $USER;
+
+    if (empty($userid)) {
+        $userid = $USER->id;
+    }
+
+    $cm = get_fast_modinfo($event->courseid, $userid)->instances['feedback'][$event->instance];
+
+    if (!$cm->uservisible) {
+        // The module is not visible to the user for any reason.
+        return null;
+    }
+
+    $feedbackcompletion = new mod_feedback_completion(null, $cm, 0, false, null, null, $userid);
 
     if (!empty($cm->customdata['timeclose']) && $cm->customdata['timeclose'] < time()) {
         // Feedback is already closed, do not display it even if it was never submitted.
@@ -3030,7 +3044,7 @@ function mod_feedback_core_calendar_provide_event_action(calendar_event $event,
     // The feedback is actionable if it does not have timeopen or timeopen is in the past.
     $actionable = $feedbackcompletion->is_open();
 
-    if ($actionable && $feedbackcompletion->is_already_submitted()) {
+    if ($actionable && $feedbackcompletion->is_already_submitted(false)) {
         // There is no need to display anything if the user has already submitted the feedback.
         return null;
     }
index 0295cec..166ddd3 100644 (file)
@@ -44,7 +44,6 @@ Feature: Test creating different types of feedback questions for anonymous feedb
       | Question                       | this is a multiple choice 2        |
       | Label                          | multichoice2                       |
       | Multiple choice type           | Multiple choice - multiple answers |
-      | Hide the "Not selected" option | Yes                                |
       | Multiple choice values         | option d\noption e\noption f       |
     And I add a "Multiple choice" question to the feedback with:
       | Question                       | this is a multiple choice 3        |
index beb2ebf..2054274 100644 (file)
@@ -48,7 +48,6 @@ Feature: Test creating different types of feedback questions for non-anonymous f
       | Question                       | this is a multiple choice 2        |
       | Label                          | multichoice2                       |
       | Multiple choice type           | Multiple choice - multiple answers |
-      | Hide the "Not selected" option | Yes                                |
       | Multiple choice values         | option d\noption e\noption f       |
     And I add a "Multiple choice" question to the feedback with:
       | Question                       | this is a multiple choice 3        |
index 4276414..a890ace 100644 (file)
@@ -193,6 +193,42 @@ class mod_feedback_lib_testcase extends advanced_testcase {
         $this->assertTrue($actionevent->is_actionable());
     }
 
+    /**
+     * Test calendar event provide action open, viewed by a different user.
+     */
+    public function test_feedback_core_calendar_provide_event_action_open_for_user() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $now = time();
+        $user = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+        $course = $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $studentrole->id, 'manual');
+
+        $feedback = $this->getDataGenerator()->create_module('feedback', ['course' => $course->id,
+            'timeopen' => $now - DAYSECS, 'timeclose' => $now + DAYSECS]);
+        $event = $this->create_action_event($course->id, $feedback->id, FEEDBACK_EVENT_TYPE_OPEN);
+        $factory = new \core_calendar\action_factory();
+
+        $this->setUser($user2);
+
+        // User2 checking their events.
+        $actionevent = mod_feedback_core_calendar_provide_event_action($event, $factory, $user2->id);
+        $this->assertNull($actionevent);
+
+        // User2 checking $user's events.
+        $actionevent = mod_feedback_core_calendar_provide_event_action($event, $factory, $user->id);
+        $this->assertInstanceOf('\core_calendar\local\event\value_objects\action', $actionevent);
+        $this->assertEquals(get_string('answerquestions', 'feedback'), $actionevent->get_name());
+        $this->assertInstanceOf('moodle_url', $actionevent->get_url());
+        $this->assertEquals(1, $actionevent->get_item_count());
+        $this->assertTrue($actionevent->is_actionable());
+    }
+
     /**
      * Test calendar event provide action closed.
      */
@@ -212,6 +248,38 @@ class mod_feedback_lib_testcase extends advanced_testcase {
         $this->assertNull($actionevent);
     }
 
+    /**
+     * Test calendar event provide action closed, viewed by a different user.
+     */
+    public function test_feedback_core_calendar_provide_event_action_closed_for_user() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $user = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $studentrole->id, 'manual');
+
+        $feedback = $this->getDataGenerator()->create_module('feedback', array('course' => $course->id,
+            'timeclose' => time() - DAYSECS));
+        $event = $this->create_action_event($course->id, $feedback->id, FEEDBACK_EVENT_TYPE_OPEN);
+        $factory = new \core_calendar\action_factory();
+        $this->setUser($user2);
+
+        // User2 checking their events.
+        $actionevent = mod_feedback_core_calendar_provide_event_action($event, $factory, $user2->id);
+        $this->assertNull($actionevent);
+
+        // User2 checking $user's events.
+        $actionevent = mod_feedback_core_calendar_provide_event_action($event, $factory, $user->id);
+
+        // No event on the dashboard if feedback is closed.
+        $this->assertNull($actionevent);
+    }
+
     /**
      * Test calendar event action open in future.
      *
@@ -236,6 +304,44 @@ class mod_feedback_lib_testcase extends advanced_testcase {
         $this->assertFalse($actionevent->is_actionable());
     }
 
+    /**
+     * Test calendar event action open in future, viewed by a different user.
+     *
+     * @throws coding_exception
+     */
+    public function test_feedback_core_calendar_provide_event_action_open_in_future_for_user() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $user = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $studentrole->id, 'manual');
+
+        $feedback = $this->getDataGenerator()->create_module('feedback', ['course' => $course->id,
+            'timeopen' => time() + DAYSECS]);
+        $event = $this->create_action_event($course->id, $feedback->id, FEEDBACK_EVENT_TYPE_OPEN);
+
+        $factory = new \core_calendar\action_factory();
+        $this->setUser($user2);
+
+        // User2 checking their events.
+        $actionevent = mod_feedback_core_calendar_provide_event_action($event, $factory, $user2->id);
+        $this->assertNull($actionevent);
+
+        // User2 checking $user's events.
+        $actionevent = mod_feedback_core_calendar_provide_event_action($event, $factory, $user->id);
+
+        $this->assertInstanceOf('\core_calendar\local\event\value_objects\action', $actionevent);
+        $this->assertEquals(get_string('answerquestions', 'feedback'), $actionevent->get_name());
+        $this->assertInstanceOf('moodle_url', $actionevent->get_url());
+        $this->assertEquals(1, $actionevent->get_item_count());
+        $this->assertFalse($actionevent->is_actionable());
+    }
+
     /**
      * Test calendar event with no time specified.
      *
@@ -259,6 +365,43 @@ class mod_feedback_lib_testcase extends advanced_testcase {
         $this->assertTrue($actionevent->is_actionable());
     }
 
+    /**
+     * Test calendar event with no time specified, viewed by a different user.
+     *
+     * @throws coding_exception
+     */
+    public function test_feedback_core_calendar_provide_event_action_no_time_specified_for_user() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $user = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $studentrole->id, 'manual');
+
+        $feedback = $this->getDataGenerator()->create_module('feedback', ['course' => $course->id]);
+        $event = $this->create_action_event($course->id, $feedback->id, FEEDBACK_EVENT_TYPE_OPEN);
+
+        $factory = new \core_calendar\action_factory();
+        $this->setUser($user2);
+
+        // User2 checking their events.
+        $actionevent = mod_feedback_core_calendar_provide_event_action($event, $factory, $user2->id);
+        $this->assertNull($actionevent);
+
+        // User2 checking $user's events.
+        $actionevent = mod_feedback_core_calendar_provide_event_action($event, $factory, $user->id);
+
+        $this->assertInstanceOf('\core_calendar\local\event\value_objects\action', $actionevent);
+        $this->assertEquals(get_string('answerquestions', 'feedback'), $actionevent->get_name());
+        $this->assertInstanceOf('moodle_url', $actionevent->get_url());
+        $this->assertEquals(1, $actionevent->get_item_count());
+        $this->assertTrue($actionevent->is_actionable());
+    }
+
     /**
      * A user that can not submit feedback should not have an action.
      */
@@ -286,6 +429,40 @@ class mod_feedback_lib_testcase extends advanced_testcase {
         $this->assertNull($action);
     }
 
+    /**
+     * A user that can not submit feedback should not have an action, viewed by a different user.
+     */
+    public function test_feedback_core_calendar_provide_event_action_can_not_submit_for_user() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $user = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+        $course = $this->getDataGenerator()->create_course();
+        $feedback = $this->getDataGenerator()->create_module('feedback', ['course' => $course->id]);
+        $event = $this->create_action_event($course->id, $feedback->id, FEEDBACK_EVENT_TYPE_OPEN);
+        $cm = get_coursemodule_from_instance('feedback', $feedback->id);
+        $context = context_module::instance($cm->id);
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $studentrole->id, 'manual');
+
+        assign_capability('mod/feedback:complete', CAP_PROHIBIT, $studentrole->id, $context);
+        $factory = new \core_calendar\action_factory();
+        $this->setUser($user2);
+
+        // User2 checking their events.
+
+        $actionevent = mod_feedback_core_calendar_provide_event_action($event, $factory, $user2->id);
+        $this->assertNull($actionevent);
+
+        // User2 checking $user's events.
+        $actionevent = mod_feedback_core_calendar_provide_event_action($event, $factory, $user->id);
+
+        $this->assertNull($actionevent);
+    }
+
     /**
      * A user that has already submitted feedback should not have an action.
      */
@@ -321,6 +498,49 @@ class mod_feedback_lib_testcase extends advanced_testcase {
         $this->assertNull($action);
     }
 
+    /**
+     * A user that has already submitted feedback should not have an action, viewed by a different user.
+     */
+    public function test_feedback_core_calendar_provide_event_action_already_submitted_for_user() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $user = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+        $course = $this->getDataGenerator()->create_course();
+        $feedback = $this->getDataGenerator()->create_module('feedback', ['course' => $course->id]);
+        $event = $this->create_action_event($course->id, $feedback->id, FEEDBACK_EVENT_TYPE_OPEN);
+        $cm = get_coursemodule_from_instance('feedback', $feedback->id);
+        $context = context_module::instance($cm->id);
+
+        $this->setUser($user);
+
+        $record = [
+            'feedback' => $feedback->id,
+            'userid' => $user->id,
+            'timemodified' => time(),
+            'random_response' => 0,
+            'anonymous_response' => FEEDBACK_ANONYMOUS_NO,
+            'courseid' => 0,
+        ];
+        $DB->insert_record('feedback_completed', (object) $record);
+
+        $factory = new \core_calendar\action_factory();
+        $this->setUser($user2);
+
+        // User2 checking their events.
+        $actionevent = mod_feedback_core_calendar_provide_event_action($event, $factory, $user2->id);
+        $this->assertNull($actionevent);
+
+        // User2 checking $user's events.
+        $actionevent = mod_feedback_core_calendar_provide_event_action($event, $factory, $user->id);
+
+        $this->assertNull($actionevent);
+    }
+
     /**
      * Creates an action event.
      *
index f983481..248eef8 100644 (file)
@@ -524,22 +524,18 @@ class mod_feedback_privacy_testcase extends provider_testcase {
      * @return void
      */
     protected function create_submission_with_answers($feedback, $user, $answers, $submissioncount = 1) {
-        global $DB, $USER;
-        $origuser = $USER;
-        $this->setUser($user);
+        global $DB;
 
         $modinfo = get_fast_modinfo($feedback->course);
         $cm = $modinfo->get_cm($feedback->cmid);
 
-        $feedbackcompletion = new mod_feedback_completion($feedback, $cm, $feedback->course);
+        $feedbackcompletion = new mod_feedback_completion($feedback, $cm, $feedback->course, false, null, null, $user->id);
         $feedbackcompletion->save_response_tmp((object) $answers);
         $feedbackcompletion->save_response();
         $this->assertEquals($submissioncount, $DB->count_records('feedback_completed', ['feedback' => $feedback->id,
             'userid' => $user->id]));
         $this->assertEquals(count($answers), $DB->count_records('feedback_value', [
             'completed' => $feedbackcompletion->get_completed()->id]));
-
-        $this->setUser($origuser);
     }
 
     /**
@@ -551,19 +547,15 @@ class mod_feedback_privacy_testcase extends provider_testcase {
      * @return void
      */
     protected function create_tmp_submission_with_answers($feedback, $user, $answers) {
-        global $DB, $USER;
-        $origuser = $USER;
-        $this->setUser($user);
+        global $DB;
 
         $modinfo = get_fast_modinfo($feedback->course);
         $cm = $modinfo->get_cm($feedback->cmid);
 
-        $feedbackcompletion = new mod_feedback_completion($feedback, $cm, $feedback->course);
+        $feedbackcompletion = new mod_feedback_completion($feedback, $cm, $feedback->course, false, null, null, $user->id);
         $feedbackcompletion->save_response_tmp((object) $answers);
         $this->assertEquals(1, $DB->count_records('feedback_completedtmp', ['feedback' => $feedback->id, 'userid' => $user->id]));
         $this->assertEquals(2, $DB->count_records('feedback_valuetmp', [
             'completed' => $feedbackcompletion->get_current_completed_tmp()->id]));
-
-        $this->setUser($origuser);
     }
 }
index 0f7bc8b..dc8b9ff 100644 (file)
Binary files a/mod/forum/amd/build/discussion_list.min.js and b/mod/forum/amd/build/discussion_list.min.js differ
diff --git a/mod/forum/amd/build/favourite_toggle.min.js b/mod/forum/amd/build/favourite_toggle.min.js
new file mode 100644 (file)
index 0000000..4d6584e
Binary files /dev/null and b/mod/forum/amd/build/favourite_toggle.min.js differ
diff --git a/mod/forum/amd/build/pin_toggle.min.js b/mod/forum/amd/build/pin_toggle.min.js
new file mode 100644 (file)
index 0000000..f625c53
Binary files /dev/null and b/mod/forum/amd/build/pin_toggle.min.js differ
index f809871..b20b532 100644 (file)
Binary files a/mod/forum/amd/build/repository.min.js and b/mod/forum/amd/build/repository.min.js differ
index d3dda42..6cdaf85 100644 (file)
Binary files a/mod/forum/amd/build/selectors.min.js and b/mod/forum/amd/build/selectors.min.js differ
index f2bfab9..06ea7dc 100644 (file)
  * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['mod_forum/subscription_toggle'], function(SubscriptionToggle) {
+define([
+    'jquery',
+    'core/templates',
+    'core/str',
+    'core/notification',
+    'mod_forum/subscription_toggle',
+    'mod_forum/selectors',
+    'mod_forum/repository',
+], function(
+    $,
+    Templates,
+    String,
+    Notification,
+    SubscriptionToggle,
+    Selectors,
+    Repository
+) {
+    var registerEventListeners = function(root) {
+        root.on('click', Selectors.favourite.toggle, function() {
+            var toggleElement = $(this);
+            var forumId = toggleElement.data('forumid');
+            var discussionId = toggleElement.data('discussionid');
+            var subscriptionState = toggleElement.data('targetstate');
+            Repository.setFavouriteDiscussionState(forumId, discussionId, subscriptionState)
+                .then(function() {
+                    return location.reload();
+                })
+                .catch(Notification.exception);
+        });
+
+        root.on('click', Selectors.pin.toggle, function(e) {
+            e.preventDefault();
+            var toggleElement = $(this);
+            var forumId = toggleElement.data('forumid');
+            var discussionId = toggleElement.data('discussionid');
+            var state = toggleElement.data('targetstate');
+            Repository.setPinDiscussionState(forumId, discussionId, state)
+                .then(function() {
+                    return location.reload();
+                })
+                .catch(Notification.exception);
+        });
+
+        root.on('click', Selectors.lock.toggle, function(e) {
+            var toggleElement = $(this);
+            var forumId = toggleElement.data('forumid');
+            var discussionId = toggleElement.data('discussionid');
+            var state = toggleElement.data('state');
+
+            Repository.setDiscussionLockState(forumId, discussionId, state)
+                .then(function(context) {
+                    context.forumid = forumId;
+                    return Templates.render('mod_forum/discussion_lock_toggle', context);
+                })
+                .then(function(html, js) {
+                    return Templates.replaceNode(toggleElement, html, js);
+                })
+                .then(function() {
+                    return String.get_string('lockupdated', 'forum')
+                        .done(function(s) {
+                            return Notification.addNotification({
+                                message: s,
+                                type: "info"
+                            });
+                        });
+                })
+                .catch(Notification.exception);
+
+            e.preventDefault();
+        });
+    };
+
     return {
         init: function(root) {
             SubscriptionToggle.init(root);
+            registerEventListeners(root);
         }
     };
 });
diff --git a/mod/forum/amd/src/favourite_toggle.js b/mod/forum/amd/src/favourite_toggle.js
new file mode 100644 (file)
index 0000000..346921b
--- /dev/null
@@ -0,0 +1,80 @@
+// 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/>.
+
+/**
+ * Handle discussion subscription toggling on a discussion list in
+ * the forum view.
+ *
+ * @module     mod_forum/favourite_toggle
+ * @package    mod_forum
+ * @copyright  2019 Peter Dias <peter@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([
+        'jquery',
+        'core/templates',
+        'core/notification',
+        'mod_forum/repository',
+        'mod_forum/selectors',
+        'core/str',
+    ], function(
+        $,
+        Templates,
+        Notification,
+        Repository,
+        Selectors,
+        String
+    ) {
+
+    /**
+     * Register event listeners for the subscription toggle.
+     *
+     * @param {object} root The discussion list root element
+     */
+    var registerEventListeners = function(root) {
+        root.on('click', Selectors.favourite.toggle, function(e) {
+            var toggleElement = $(this);
+            var forumId = toggleElement.data('forumid');
+            var discussionId = toggleElement.data('discussionid');
+            var subscriptionState = toggleElement.data('targetstate');
+
+            Repository.setFavouriteDiscussionState(forumId, discussionId, subscriptionState)
+                .then(function(context) {
+                    return Templates.render('mod_forum/discussion_favourite_toggle', context);
+                })
+                .then(function(html, js) {
+                    return Templates.replaceNode(toggleElement, html, js);
+                })
+                .then(function() {
+                    return String.get_string("favouriteupdated", "forum")
+                        .done(function(s) {
+                            return Notification.addNotification({
+                                message: s,
+                                type: "info"
+                            });
+                        });
+                })
+                .catch(Notification.exception);
+
+            e.preventDefault();
+        });
+    };
+
+    return {
+        init: function(root) {
+            registerEventListeners(root);
+        }
+    };
+});
diff --git a/mod/forum/amd/src/pin_toggle.js b/mod/forum/amd/src/pin_toggle.js
new file mode 100644 (file)
index 0000000..7884128
--- /dev/null
@@ -0,0 +1,86 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This module is the highest level module for the calendar. It is
+ * responsible for initialising all of the components required for
+ * the calendar to run. It also coordinates the interaction between
+ * components by listening for and responding to different events
+ * triggered within the calendar UI.
+ *
+ * @module     mod_forum/pin_toggle
+ * @package    mod_forum
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([
+    'jquery',
+    'core/ajax',
+    'core/str',
+    'core/templates',
+    'core/notification',
+    'mod_forum/repository',
+    'mod_forum/selectors',
+    'core/str',
+], function(
+    $,
+    Ajax,
+    Str,
+    Templates,
+    Notification,
+    Repository,
+    Selectors,
+    String
+) {
+
+    /**
+     * Registery event listeners for the pin toggle.
+     *
+     * @param {object} root The calendar root element
+     */
+    var registerEventListeners = function(root) {
+        root.on('click', Selectors.pin.toggle, function(e) {
+            var toggleElement = $(this);
+            var forumid = toggleElement.data('forumid');
+            var discussionid = toggleElement.data('discussionid');
+            var pinstate = toggleElement.data('targetstate');
+            Repository.setPinDiscussionState(forumid, discussionid, pinstate)
+                .then(function(context) {
+                    return Templates.render('mod_forum/discussion_pin_toggle', context);
+                })
+                .then(function(html, js) {
+                    return Templates.replaceNode(toggleElement, html, js);
+                })
+                .then(function() {
+                    return String.get_string("pinupdated", "forum")
+                        .done(function(s) {
+                            return Notification.addNotification({
+                                message: s,
+                                type: "info"
+                            });
+                        });
+                })
+                .fail(Notification.exception);
+
+            e.preventDefault();
+        });
+    };
+
+    return {
+        init: function(root) {
+            registerEventListeners(root);
+        }
+    };
+});
\ No newline at end of file
index fcd10af..e7d32af 100644 (file)
@@ -52,7 +52,25 @@ define(['core/ajax'], function(Ajax) {
                 subject: subject
             }
         };
+        return Ajax.call([request])[0];
+    };
 
+    /**
+     * Set the favourite state for a discussion in a forum.
+     *
+     * @param {number} forumId ID of the forum the discussion belongs to
+     * @param {number} discussionId ID of the discussion with the subscription state
+     * @param {null|date} targetState Set the favourite state. True == favourited; false == unfavourited.
+     * @return {object} jQuery promise
+     */
+    var setFavouriteDiscussionState = function(forumId, discussionId, targetState) {
+        var request = {
+            methodname: 'mod_forum_toggle_favourite_state',
+            args: {
+                discussionid: discussionId,
+                targetstate: targetState
+            }
+        };
         return Ajax.call([request])[0];
     };
 
@@ -62,7 +80,25 @@ define(['core/ajax'], function(Ajax) {
             args: {
                 forumid: forumId,
                 discussionid: discussionId,
-                targetstate: targetState
+                targetstate: targetState}
+        };
+        return Ajax.call([request])[0];
+    };
+
+    /**
+     * Set the pinned state for the discussion provided.
+     *
+     * @param {number} forumid
+     * @param {number} discussionid
+     * @param {boolean} targetstate
+     * @return {*|Promise}
+     */
+    var setPinDiscussionState = function(forumid, discussionid, targetstate) {
+        var request = {
+            methodname: 'mod_forum_set_pin_state',
+            args: {
+                discussionid: discussionid,
+                targetstate: targetstate
             }
         };
         return Ajax.call([request])[0];
@@ -71,6 +107,8 @@ define(['core/ajax'], function(Ajax) {
     return {
         setDiscussionSubscriptionState: setDiscussionSubscriptionState,
         addDiscussionPost: addDiscussionPost,
-        setDiscussionLockState: setDiscussionLockState
+        setDiscussionLockState: setDiscussionLockState,
+        setFavouriteDiscussionState: setFavouriteDiscussionState,
+        setPinDiscussionState: setPinDiscussionState
     };
 });
index 605c138..6550961 100644 (file)
@@ -26,9 +26,6 @@ define([], function() {
         subscription: {
             toggle: "[data-type='subscription-toggle'][data-action='toggle']",
         },
-        pin: {
-            toggle: ".pindiscussion [data-action='toggle']",
-        },
         post: {
             post: '[data-region="post"]',
             action: '[data-region="post-action"]',
@@ -45,6 +42,12 @@ define([], function() {
         },
         lock: {
             toggle: "[data-action='toggle'][data-type='lock-toggle']",
-        }
+        },
+        favourite: {
+            toggle: "[data-type='favorite-toggle'][data-action='toggle']",
+        },
+        pin: {
+            toggle: "[data-type='pin-toggle'][data-action='toggle']",
+        },
     };
 });
diff --git a/mod/forum/classes/local/builders/exported_discussion.php b/mod/forum/classes/local/builders/exported_discussion.php
new file mode 100644 (file)
index 0000000..d0e98f5
--- /dev/null
@@ -0,0 +1,155 @@
+<?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/>.
+
+/**
+ * Exported discussion builder class.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Peter Dias<peter@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\builders;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\entities\discussion as discussion_entity;
+use mod_forum\local\entities\forum as forum_entity;
+use mod_forum\local\factories\legacy_data_mapper as legacy_data_mapper_factory;
+use mod_forum\local\factories\exporter as exporter_factory;
+use mod_forum\local\factories\vault as vault_factory;
+use rating_manager;
+use renderer_base;
+use stdClass;
+
+/**
+ * Exported discussion builder class
+ *
+ * This class is an implementation of the builder pattern (loosely). It is responsible
+ * for taking a set of related forums, discussions, and posts and generate the exported
+ * version of the discussion.
+ *
+ * It encapsulates the complexity involved with exporting discussions. All of the relevant
+ * additional resources will be loaded by this class in order to ensure the exporting
+ * process can happen.
+ *
+ * See this doc for more information on the builder pattern:
+ * https://designpatternsphp.readthedocs.io/en/latest/Creational/Builder/README.html
+ *
+ * @copyright  2019 Peter Dias<peter@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class exported_discussion {
+    /** @var renderer_base $renderer Core renderer */
+    private $renderer;
+
+    /** @var legacy_data_mapper_factory $legacydatamapperfactory Data mapper factory */
+    private $legacydatamapperfactory;
+
+    /** @var exporter_factory $exporterfactory Exporter factory */
+    private $exporterfactory;
+
+    /** @var vault_factory $vaultfactory Vault factory */
+    private $vaultfactory;
+
+    /** @var rating_manager $ratingmanager Rating manager */
+    private $ratingmanager;
+
+    /**
+     * Constructor.
+     *
+     * @param renderer_base $renderer Core renderer
+     * @param legacy_data_mapper_factory $legacydatamapperfactory Legacy data mapper factory
+     * @param exporter_factory $exporterfactory Exporter factory
+     * @param vault_factory $vaultfactory Vault factory
+     * @param rating_manager $ratingmanager Rating manager
+     */
+    public function __construct(
+        renderer_base $renderer,
+        legacy_data_mapper_factory $legacydatamapperfactory,
+        exporter_factory $exporterfactory,
+        vault_factory $vaultfactory,
+        rating_manager $ratingmanager
+    ) {
+        $this->renderer = $renderer;
+        $this->legacydatamapperfactory = $legacydatamapperfactory;
+        $this->exporterfactory = $exporterfactory;
+        $this->vaultfactory = $vaultfactory;
+        $this->ratingmanager = $ratingmanager;
+    }
+
+    /**
+     * Build any additional variables for the exported discussion for a given set of discussions.
+     *
+     * This will typically be used for a list of discussions in the same forum.
+     *
+     * @param stdClass $user The user to export the posts for.
+     * @param forum_entity $forum The forum that each of the $discussions belong to
+     * @param discussion_entity $discussion A list of all discussions that each of the $posts belong to
+     * @return stdClass[] List of exported posts in the same order as the $posts array.
+     */
+    public function build(
+        stdClass $user,
+        forum_entity $forum,
+        discussion_entity $discussion
+    ) : array {
+
+        $favouriteids = [];
+        if ($this->is_favourited($discussion, $forum->get_context(), $user)) {
+            $favouriteids[] = $discussion->get_id();
+        }
+
+        $groupsbyid = $this->get_groups_available_in_forum($forum);
+        $discussionexporter = $this->exporterfactory->get_discussion_exporter(
+            $user, $forum, $discussion, $groupsbyid, $favouriteids
+        );
+
+        return (array) $discussionexporter->export($this->renderer);
+    }
+
+    /**
+     * Get the groups details for all groups available to the forum.
+     * @param forum_entity $forum The forum entity
+     * @return stdClass[]
+     */
+    private function get_groups_available_in_forum($forum) : array {
+        $course = $forum->get_course_record();
+        $coursemodule = $forum->get_course_module_record();
+
+        return groups_get_all_groups($course->id, 0, $coursemodule->groupingid);
+    }
+
+    /**
+     * Check whether the provided discussion has been favourited by the user.
+     *
+     * @param discussion_entity $discussion The discussion record
+     * @param \context_module $forumcontext Forum context
+     * @param \stdClass $user The user to check the favourite against
+     *
+     * @return bool Whether or not the user has favourited the discussion
+     */
+    public function is_favourited(discussion_entity $discussion, \context_module $forumcontext, \stdClass $user) {
+        if (!isloggedin()) {
+            return false;
+        }
+
+        $usercontext = \context_user::instance($user->id);
+        $ufservice = \core_favourites\service_factory::get_service_for_user_context($usercontext);
+        return $ufservice->favourite_exists('mod_forum', 'discussions', $discussion->get_id(), $forumcontext);
+    }
+
+
+}
index ba1e0aa..7859c47 100644 (file)
@@ -127,7 +127,7 @@ class exported_discussion_summaries {
         $latestposts = $postvault->get_latest_post_id_for_discussion_ids($user, $discussionids, $canseeanyprivatereply);
 
         $unreadcounts = [];
-
+        $favourites = $this->get_favourites($user);
         $forumdatamapper = $this->legacydatamapperfactory->get_forum_data_mapper();
         $forumrecord = $forumdatamapper->to_legacy_object($forum);
 
@@ -143,12 +143,34 @@ class exported_discussion_summaries {
             $groupsbyauthorid,
             $replycounts,
             $unreadcounts,
-            $latestposts
+            $latestposts,
+            $favourites
         );
 
         return (array) $summaryexporter->export($this->renderer);
     }
 
+    /**
+     * Get a list of all favourited discussions.
+     *
+     * @param stdClass $user The user we are getting favourites for
+     * @return int[] A list of favourited itemids
+     */
+    private function get_favourites(stdClass $user) : array {
+        $ids = [];
+
+        if (isloggedin()) {
+            $usercontext = \context_user::instance($user->id);
+            $ufservice = \core_favourites\service_factory::get_service_for_user_context($usercontext);
+            $favourites = $ufservice->find_favourites_by_type('mod_forum', 'discussions');
+            foreach ($favourites as $favourite) {
+                $ids[] = $favourite->itemid;
+            }
+        }
+
+        return $ids;
+    }
+
     /**
      * Get the groups details for all groups available to the forum.
      * @param forum_entity $forum The forum entity
index b433401..13daac3 100644 (file)
@@ -300,6 +300,18 @@ class discussion {
         return $this->get_group_id() > 0;
     }
 
+    /**
+     * Set the pinned value for this entity
+     *
+     * @param int $targetstate The state to change the pin to
+     * @return bool
+     */
+    public function set_pinned(int $targetstate): void {
+        if ($targetstate != $this->pinned) {
+            $this->pinned = $targetstate;
+        }
+    }
+
     /**
      * Check if the discussion is timed.
      *
index e7d2e4e..5f799d0 100644 (file)
@@ -96,6 +96,7 @@ class discussion extends exporter {
             'userstate' => [
                 'type' => [
                     'subscribed' => ['type' => PARAM_BOOL],
+                    'favourited' => ['type' => PARAM_BOOL],
                 ],
             ],
             'capabilities' => [
@@ -105,6 +106,7 @@ class discussion extends exporter {
                     'pin' => ['type' => PARAM_BOOL],
                     'post' => ['type' => PARAM_BOOL],
                     'manage' => ['type' => PARAM_BOOL],
+                    'favourite' => ['type' => PARAM_BOOL]
                 ]
             ],
             'urls' => [
@@ -119,7 +121,11 @@ class discussion extends exporter {
                         'type' => PARAM_URL,
                     ],
                     'markasread' => ['type' => PARAM_URL],
-                    'subscribe' => ['type' => PARAM_URL]
+                    'subscribe' => ['type' => PARAM_URL],
+                    'pin' => [
+                        'optional' => true,
+                        'type' => PARAM_URL,
+                    ],
                 ],
             ],
             'timed' => [
@@ -151,6 +157,7 @@ class discussion extends exporter {
 
         $capabilitymanager = $this->related['capabilitymanager'];
         $urlfactory = $this->related['urlfactory'];
+        $favouriteids = isset($this->related['favouriteids']) ? $this->related['favouriteids'] : [];
 
         $forum = $this->related['forum'];
         $forumrecord = $this->get_forum_record();
@@ -195,14 +202,16 @@ class discussion extends exporter {
                 'locked' => $discussion->get_locked()
             ],
             'userstate' => [
-                'subscribed' => \mod_forum\subscriptions::is_subscribed($user->id, $forumrecord, $discussion->get_id())
+                'subscribed' => \mod_forum\subscriptions::is_subscribed($user->id, $forumrecord, $discussion->get_id()),
+                'favourited' => in_array($discussion->get_id(), $favouriteids) ? true : false,
             ],
             'capabilities' => [
                 'subscribe' => $capabilitymanager->can_subscribe_to_discussion($user, $discussion),
                 'move' => $capabilitymanager->can_move_discussion($user, $discussion),
                 'pin' => $capabilitymanager->can_pin_discussion($user, $discussion),
                 'post' => $capabilitymanager->can_post_in_discussion($user, $discussion),
-                'manage' => $capabilitymanager->can_manage_forum($user)
+                'manage' => $capabilitymanager->can_manage_forum($user),
+                'favourite' => $capabilitymanager->can_favourite_discussion($user, $discussion) // Defaulting to true until we get capabilities sorted
             ],
             'urls' => [
                 'view' => $urlfactory->get_discussion_view_url_from_discussion($discussion)->out(false),
@@ -219,6 +228,10 @@ class discussion extends exporter {
                 )->out(false);
         }
 
+        if ($capabilitymanager->can_pin_discussions($user)) {
+            $data['urls']['pin'] = $urlfactory->get_pin_discussion_url_from_discussion($discussion)->out(false);
+        }
+
         if ($groupdata) {
             $data['group'] = $groupdata;
         }
@@ -255,7 +268,8 @@ class discussion extends exporter {
             'urlfactory' => 'mod_forum\local\factories\url',
             'user' => 'stdClass',
             'groupsbyid' => 'stdClass[]',
-            'latestpostid' => 'int?'
+            'latestpostid' => 'int?',
+            'favouriteids' => 'int[]?'
         ];
     }
 }
index ded471d..13eac7e 100644 (file)
@@ -152,6 +152,7 @@ class discussion_summaries extends exporter {
             'capabilitymanager' => 'mod_forum\local\managers\capability',
             'urlfactory' => 'mod_forum\local\factories\url',
             'user' => 'stdClass',
+            'favouriteids' => 'int[]?'
         ];
     }
 }
index 1a7ac8e..6ba2bcb 100644 (file)
@@ -175,6 +175,7 @@ class discussion_summary extends exporter {
             'capabilitymanager' => 'mod_forum\local\managers\capability',
             'urlfactory' => 'mod_forum\local\factories\url',
             'user' => 'stdClass',
+            'favouriteids' => 'int[]?'
         ];
     }
 }
index 2a71d5f..a648d1e 100644 (file)
@@ -28,6 +28,7 @@ defined('MOODLE_INTERNAL') || die();
 
 use mod_forum\local\builders\exported_posts as exported_posts_builder;
 use mod_forum\local\builders\exported_discussion_summaries as exported_discussion_summaries_builder;
+use mod_forum\local\builders\exported_discussion as exported_discussion_builder;
 use mod_forum\local\factories\vault as vault_factory;
 use mod_forum\local\factories\legacy_data_mapper as legacy_data_mapper_factory;
 use mod_forum\local\factories\exporter as exporter_factory;
@@ -108,4 +109,19 @@ class builder {
             $this->managerfactory
         );
     }
+
+    /**
+     * Get an instance of the exported discussion builder.
+     *
+     * @return exported_discussion_summaries_builder
+     */
+    public function get_exported_discussion_builder() : exported_discussion_builder {
+        return new exported_discussion_builder(
+            $this->rendererbase,
+            $this->legacydatamapperfactory,
+            $this->exporterfactory,
+            $this->vaultfactory,
+            $this->managerfactory->get_rating_manager()
+        );
+    }
 }
index 3d071bb..8aaea94 100644 (file)
@@ -122,7 +122,8 @@ class exporter {
         stdClass $user,
         forum_entity $forum,
         discussion_entity $discussion,
-        array $groupsbyid = []
+        array $groupsbyid = [],
+        array $favouriteids = []
     ) : discussion_exporter {
         return new discussion_exporter($discussion, [
             'context' => $forum->get_context(),
@@ -132,7 +133,8 @@ class exporter {
             'user' => $user,
             'legacydatamapperfactory' => $this->legacydatamapperfactory,
             'latestpostid' => null,
-            'groupsbyid' => $groupsbyid
+            'groupsbyid' => $groupsbyid,
+            'favouriteids' => $favouriteids
         ]);
     }
 
@@ -166,7 +168,8 @@ class exporter {
         array $groupsbyauthorid = [],
         array $discussionreplycount = [],
         array $discussionunreadcount = [],
-        array $latestpostid = []
+        array $latestpostid = [],
+        array $favourites = []
     ) : discussion_summaries_exporter {
         return new discussion_summaries_exporter(
             $discussions,
@@ -182,6 +185,7 @@ class exporter {
                 'capabilitymanager' => $this->managerfactory->get_capability_manager($forum),
                 'urlfactory' => $this->urlfactory,
                 'user' => $user,
+                'favouriteids' => $favourites
             ]
         );
     }
index 75c39a3..a8a7b0d 100644 (file)
@@ -138,7 +138,15 @@ class renderer {
             $ratingmanager,
             $this->entityfactory->get_exported_posts_sorter(),
             $baseurl,
-            $notifications
+            $notifications,
+            function($discussion, $user, $forum) {
+                $exportbuilder = $this->builderfactory->get_exported_discussion_builder();
+                return $exportedposts = $exportbuilder->build(
+                    $user,
+                    $forum,
+                    $discussion
+                );
+            }
         );
     }
 
index 5268b9c..2d9f7a7 100644 (file)
@@ -457,4 +457,19 @@ class url {
             'd' => $discussion->get_id()
         ]);
     }
+
+    /**
+     * Generate the pinned discussion link
+     *
+     * @param discussion_entity $discussion
+     * @return moodle_url
+     * @throws \moodle_exception
+     */
+    public function get_pin_discussion_url_from_discussion(discussion_entity $discussion) : moodle_url {
+        return new moodle_url('discuss.php', [
+            'sesskey' => sesskey(),
+            'd' => $discussion->get_id(),
+            'pin' => $discussion->is_pinned() ? FORUM_DISCUSSION_UNPINNED : FORUM_DISCUSSION_PINNED
+        ]);
+    }
 }
index 8bc2046..762ba2f 100644 (file)
@@ -321,6 +321,18 @@ class capability {
         return forum_user_can_post($forumrecord, $discussionrecord, $user, $coursemodule, $course, $context);
     }
 
+    /**
+     * Can the user favourite the discussion
+     *
+     * @param stdClass $user The user to check
+     * @param discussion_entity $discussion The discussion to check
+     * @return bool
+     */
+    public function can_favourite_discussion(stdClass $user, discussion_entity $discussion) : bool {
+        $context = $this->get_context();
+        return has_capability('mod/forum:cantogglefavourite', $context, $user);
+    }
+
     /**
      * Can the user view the content of the post in this discussion?
      *
index 500bc75..8b7e624 100644 (file)
@@ -90,6 +90,8 @@ class discussion {
     private $notifications;
     /** @var sorter_entity $exportedpostsorter Sorter for the exported posts */
     private $exportedpostsorter;
+    /** @var callable $postprocessfortemplate Function to process exported posts before template rendering */
+    private $postprocessfortemplate;
 
     /**
      * Constructor.
@@ -123,7 +125,8 @@ class discussion {
         rating_manager $ratingmanager,
         sorter_entity $exportedpostsorter,
         moodle_url $baseurl,
-        array $notifications = []
+        array $notifications = [],
+        callable $postprocessfortemplate = null
     ) {
         $this->forum = $forum;
         $this->discussion = $discussion;
@@ -140,6 +143,7 @@ class discussion {
         $this->notifications = $notifications;
 
         $this->exportedpostsorter = $exportedpostsorter;
+        $this->postprocessfortemplate = $postprocessfortemplate;
 
         $forumdatamapper = $this->legacydatamapperfactory->get_forum_data_mapper();
         $this->forumrecord = $forumdatamapper->to_legacy_object($forum);
@@ -173,7 +177,12 @@ class discussion {
 
         $posts = array_merge([$firstpost], array_values($replies));
 
-        $exporteddiscussion = $this->get_exported_discussion($user);
+        if ($this->postprocessfortemplate !== null) {
+            $exporteddiscussion = ($this->postprocessfortemplate) ($this->discussion, $user, $this->forum);
+        } else {
+            $exporteddiscussion = $this->get_exported_discussion($user);
+        }
+
         $exporteddiscussion = array_merge($exporteddiscussion, [
             'notifications' => $this->get_notifications($user),
             'html' => [
@@ -197,10 +206,6 @@ class discussion {
             $exporteddiscussion['html']['movediscussion'] = $this->get_move_discussion_html();
         }
 
-        if ($capabilities['pin']) {
-            $exporteddiscussion['html']['pindiscussion'] = $this->get_pin_discussion_html();
-        }
-
         return $this->renderer->render_from_template('mod_forum/forum_discussion', $exporteddiscussion);
     }
 
@@ -325,30 +330,6 @@ class discussion {
         return null;
     }
 
-    /**
-     * Get the HTML to render the pin discussion button.
-     *
-     * @return string
-     */
-    private function get_pin_discussion_html() : string {
-        $discussion = $this->discussion;
-
-        if ($discussion->is_pinned()) {
-            $pinlink = FORUM_DISCUSSION_UNPINNED;
-            $pintext = get_string('discussionunpin', 'forum');
-        } else {
-            $pinlink = FORUM_DISCUSSION_PINNED;
-            $pintext = get_string('discussionpin', 'forum');
-        }
-
-        $button = new single_button(
-            new moodle_url('discuss.php', ['pin' => $pinlink, 'd' => $discussion->get_id()]),
-            $pintext,
-            'post'
-        );
-        return $this->renderer->render($button);
-    }
-
     /**
      * Get the HTML to render the export discussion button.
      *
index 47e3027..8c39aa7 100644 (file)
@@ -171,6 +171,10 @@ class discussion_list {
             ),
             'hasmore' => ($alldiscussionscount > $pagesize),
             'notifications' => $this->get_notifications($user, $groupid),
+            'settings' => [
+                'excludetext' => true,
+                'togglemoreicon' => true
+            ]
         ];
 
         if (!$discussions) {
@@ -310,7 +314,8 @@ class discussion_list {
                 $user->id,
                 $sortorder,
                 $this->get_page_size($pagesize),
-                $this->get_page_number($pageno) * $this->get_page_size($pagesize));
+                $this->get_page_number($pageno) * $this->get_page_size($pagesize),
+                $user);
         } else {
             return $discussions = $discussionvault->get_from_forum_id_and_group_id(
                 $forum->get_id(),
@@ -319,7 +324,8 @@ class discussion_list {
                 $user->id,
                 $sortorder,
                 $this->get_page_size($pagesize),
-                $this->get_page_number($pageno) * $this->get_page_size($pagesize));
+                $this->get_page_number($pageno) * $this->get_page_size($pagesize),
+                $user);
         }
     }
 
index 148bbac..bd0f78b 100644 (file)
@@ -55,9 +55,10 @@ class author extends db_table_vault {
      *
      * @param string|null $wheresql Where conditions for the SQL
      * @param string|null $sortsql Order by conditions for the SQL
+     * @param \stdClass|null $user The user object
      * @return string
      */
-    protected function generate_get_records_sql(string $wheresql = null, string $sortsql = null) : string {
+    protected function generate_get_records_sql(string $wheresql = null, string $sortsql = null, \stdClass $user = null) : string {
         $selectsql = 'SELECT * FROM {' . self::TABLE . '} ' . $this->get_table_alias();
         $selectsql .= $wheresql ? ' WHERE ' . $wheresql : '';
         $selectsql .= $sortsql ? ' ORDER BY ' . $sortsql : '';
index b78daf8..862ad36 100644 (file)
@@ -72,9 +72,11 @@ abstract class db_table_vault {
      *
      * @param string|null $wheresql Where conditions for the SQL
      * @param string|null $sortsql Order by conditions for the SQL
+     * @param object|null $user The user object
      * @return string
      */
-    abstract protected function generate_get_records_sql(string $wheresql = null, string $sortsql = null) : string;
+    abstract protected function generate_get_records_sql(string $wheresql = null, string $sortsql = null,
+        \stdClass $user = null) : string;
 
     /**
      * Convert the DB records into entities. The list of records will have been
index 00d4e97..d6aa049 100644 (file)
@@ -58,9 +58,10 @@ class discussion extends db_table_vault {
      *
      * @param string|null $wheresql Where conditions for the SQL
      * @param string|null $sortsql Order by conditions for the SQL
+     * @param \stdClass|null $user The user object
      * @return string
      */
-    protected function generate_get_records_sql(string $wheresql = null, string $sortsql = null) : string {
+    protected function generate_get_records_sql(string $wheresql = null, string $sortsql = null, \stdClass $user = null) : string {
         $selectsql = 'SELECT * FROM {' . self::TABLE . '} ' . $this->get_table_alias();
         $selectsql .= $wheresql ? ' WHERE ' . $wheresql : '';
         $selectsql .= $sortsql ? ' ORDER BY ' . $sortsql : '';
index 4047f41..8506bee 100644 (file)
@@ -74,17 +74,34 @@ class discussion_list extends db_table_vault {
         return 'd';
     }
 
+    /**
+     * Get the favourite table alias
+     *
+     * @return string
+     */
+    protected function get_favourite_alias() : string {
+        return 'favalias';
+    }
+
     /**
      * Build the SQL to be used in get_records_sql.
      *
      * @param string|null $wheresql Where conditions for the SQL
      * @param string|null $sortsql Order by conditions for the SQL
+     * @param string|null $joinsql Additional join conditions for the sql
+     * @param stdClass|null $user User we are performing this query for
+     *
      * @return string
      */
-    protected function generate_get_records_sql(string $wheresql = null, ?string $sortsql = null) : string {
+    protected function generate_get_records_sql(string $wheresql = null, ?string $sortsql = null, stdClass $user = null) : string {
         $alias = $this->get_table_alias();
         $db = $this->get_db();
 
+        list($favsql, $favparams) = $this->get_favourite_sql($user);
+        foreach ($favparams as $key => $param) {
+            $favsql = str_replace(":$key", "'$param'", $favsql);
+        }
+
         // Fetch:
         // - Discussion
         // - First post
@@ -106,6 +123,7 @@ class discussion_list extends db_table_vault {
         $tables .= ' JOIN {user} fa ON fa.id = ' . $alias . '.userid';
         $tables .= ' JOIN {user} la ON la.id = ' . $alias . '.usermodified';
         $tables .= ' JOIN ' . $posttable->get_from_sql() . ' ON fp.id = ' . $alias . '.firstpost';
+        $tables .= isset($favsql) ? $favsql : '';
 
         $selectsql = 'SELECT ' . $fields . ' FROM ' . $tables;
         $selectsql .= $wheresql ? ' WHERE ' . $wheresql : '';
@@ -178,7 +196,7 @@ class discussion_list extends db_table_vault {
      *
      * @param int|null $sortmethod
      */
-    public function get_sort_order(?int $sortmethod) : string {
+    public function get_sort_order(?int $sortmethod, $includefavourites = true) : string {
         global $CFG;
 
         $alias = $this->get_table_alias();
@@ -187,7 +205,6 @@ class discussion_list extends db_table_vault {
             $keyfield = "fp.created";
             $direction = "DESC";
         } else {
-            // TODO consider user favourites...
             $keyfield = "{$alias}.timemodified";
             $direction = "DESC";
 
@@ -200,7 +217,21 @@ class discussion_list extends db_table_vault {
             }
         }
 
-        return "{$alias}.pinned DESC, {$keyfield} {$direction}, {$alias}.id DESC";
+        $favouritesort = '';
+        if ($includefavourites) {
+            $favalias = $this->get_favourite_alias();
+            // Since we're joining on the favourite table any discussion that isn't favourited will have
+            // null in the favourite columns. Nulls behave differently in the sorting for different databases.
+            // We can ensure consistency between databases by explicitly deprioritising any null favourite field
+            // using a case statement.
+            $favouritesort = ", CASE WHEN {$favalias}.id IS NULL THEN 0 ELSE 1 END DESC";
+            // After the null favourite fields are deprioritised and appear below the favourited discussions we
+            // need to order the favourited discussions by id so that the most recently favourited discussions
+            // appear at the top of the list.
+            $favouritesort .= ", {$favalias}.id DESC";
+        }
+
+        return "{$alias}.pinned DESC $favouritesort , {$keyfield} {$direction}";
     }
 
     /**
@@ -249,7 +280,8 @@ class discussion_list extends db_table_vault {
         ?int $includepostsforuser,
         ?int $sortorder,
         int $limit,
-        int $offset
+        int $offset,
+        stdClass $user
     ) {
         $alias = $this->get_table_alias();
         $wheresql = "{$alias}.forum = :forumid";
@@ -263,7 +295,7 @@ class discussion_list extends db_table_vault {
             'forumid' => $forumid,
         ]);
 
-        $sql = $this->generate_get_records_sql($wheresql, $this->get_sort_order($sortorder));
+        $sql = $this->generate_get_records_sql($wheresql, $this->get_sort_order($sortorder, isloggedin()), $user);
         $records = $this->get_db()->get_records_sql($sql, $params, $offset, $limit);
 
         return $this->transform_db_records_to_entities($records);
@@ -289,7 +321,8 @@ class discussion_list extends db_table_vault {
         ?int $includepostsforuser,
         ?int $sortorder,
         int $limit,
-        int $offset
+        int $offset,
+        stdClass $user
     ) {
         $alias = $this->get_table_alias();
 
@@ -313,7 +346,7 @@ class discussion_list extends db_table_vault {
             'allgroupsid' => -1,
         ]);
 
-        $sql = $this->generate_get_records_sql($wheresql, $this->get_sort_order($sortorder));
+        $sql = $this->generate_get_records_sql($wheresql, $this->get_sort_order($sortorder, isloggedin()), $user);
         $records = $this->get_db()->get_records_sql($sql, $params, $offset, $limit);
 
         return $this->transform_db_records_to_entities($records);
@@ -390,4 +423,25 @@ class discussion_list extends db_table_vault {
 
         return $this->get_db()->count_records_sql($this->generate_count_records_sql($wheresql), $params);
     }
+
+    /**
+     * Get the standard favouriting sql.
+     *
+     * @param stdClass $user The user we are getting the sql for
+     * @return [$sql, $params] An array comprising of the sql and any associated params
+     */
+    private function get_favourite_sql(?stdClass $user): array {
+        $favsql = "";
+        $favparams = [];
+
+        if ($user && isloggedin()) {
+            $usercontext = \context_user::instance($user->id);
+            $alias = $this->get_table_alias();
+            $ufservice = \core_favourites\service_factory::get_service_for_user_context($usercontext);
+            list($favsql, $favparams) = $ufservice->get_join_sql_by_type('mod_forum', 'discussions',
+                $this->get_favourite_alias(), "$alias.id");
+        }
+
+        return [$favsql, $favparams];
+    }
 }
index 853e87d..1fb21f7 100644 (file)
@@ -61,9 +61,10 @@ class forum extends db_table_vault {
      *
      * @param string|null $wheresql Where conditions for the SQL
      * @param string|null $sortsql Order by conditions for the SQL
+     * @param \stdClass|null $user The user object
      * @return string
      */
-    protected function generate_get_records_sql(string $wheresql = null, string $sortsql = null) : string {
+    protected function generate_get_records_sql(string $wheresql = null, string $sortsql = null, \stdClass $user = null) : string {
         $db = $this->get_db();
         $alias = $this->get_table_alias();
 
index d92544f..a278001 100644 (file)
@@ -63,9 +63,10 @@ class post extends db_table_vault {
      *
      * @param string|null $wheresql Where conditions for the SQL
      * @param string|null $sortsql Order by conditions for the SQL
+     * @param \stdClass|null $user The user object
      * @return string
      */
-    protected function generate_get_records_sql(string $wheresql = null, string $sortsql = null) : string {
+    protected function generate_get_records_sql(string $wheresql = null, string $sortsql = null, stdClass $user = null) : string {
         $table = self::TABLE;
         $alias = $this->get_table_alias();
         $fields = $alias . '.*';
index f9e550b..28b1db8 100644 (file)
@@ -57,9 +57,10 @@ class post_read_receipt_collection extends db_table_vault {
      *
      * @param string|null $wheresql Where conditions for the SQL
      * @param string|null $sortsql Order by conditions for the SQL
+     * @param \stdClass|null $user The user object
      * @return string
      */
-    protected function generate_get_records_sql(string $wheresql = null, string $sortsql = null) : string {
+    protected function generate_get_records_sql(string $wheresql = null, string $sortsql = null, \stdClass $user = null) : string {
         $selectsql = 'SELECT * FROM {' . self::TABLE . '} ' . $this->get_table_alias();
         $selectsql .= $wheresql ? ' WHERE ' . $wheresql : '';
         $selectsql .= $sortsql ? ' ORDER BY ' . $sortsql : '';
index b9d2015..3a5733c 100644 (file)
@@ -405,5 +405,12 @@ $capabilities = array(
         ),
         'clonepermissionsfrom' => 'mod/forum:canoverridediscussionlock'
     ),
+    'mod/forum:cantogglefavourite' => array(
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_MODULE,
+        'archetypes' => array(
+            'user' => CAP_ALLOW
+        )
+    )
 );
 
index 3506d12..12fc9f2 100644 (file)
@@ -145,4 +145,23 @@ $functions = array(
         'capabilities' => 'moodle/course:manageactivities',
         'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
+
+    'mod_forum_toggle_favourite_state' => array(
+        'classname' => 'mod_forum_external',
+        'methodname' => 'toggle_favourite_state',
+        'classpath' => 'mod/forum/externallib.php',
+        'description' => 'Toggle the favourite state',
+        'type' => 'write',
+        'ajax' => true,
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+    ),
+    'mod_forum_set_pin_state' => array(
+        'classname' => 'mod_forum_external',
+        'methodname' => 'set_pin_state',
+        'classpath' => 'mod/forum/externallib.php',
+        'description' => 'Set the pin state',
+        'type' => 'write',
+        'ajax' => true,
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+    ),
 );
index c7175d4..47cf764 100644 (file)
@@ -28,6 +28,7 @@ defined('MOODLE_INTERNAL') || die;
 require_once("$CFG->libdir/externallib.php");
 
 use mod_forum\local\exporters\post as post_exporter;
+use mod_forum\local\exporters\discussion as discussion_exporter;
 
 class mod_forum_external extends external_api {
 
@@ -591,6 +592,11 @@ class mod_forum_external extends external_api {
             $canlock = has_capability('moodle/course:manageactivities', $modcontext, $USER);
             $replies = forum_count_discussion_replies($forumid, $sort, -1, $page, $perpage, $canseeprivatereplies);
 
+            if (isloggedin()) {
+                $usercontext = context_user::instance($USER->id);
+                $ufservice = core_favourites\service_factory::get_service_for_user_context($usercontext);
+            }
+            $canfavourite = has_capability('mod/forum:cantogglefavourite', $modcontext, $USER);
             foreach ($alldiscussions as $discussion) {
 
                 // This function checks for qanda forums.
@@ -639,7 +645,10 @@ class mod_forum_external extends external_api {
 
                 $discussion->locked = forum_discussion_is_locked($forum, $discussion);
                 $discussion->canlock = $canlock;
+                $discussion->starred = !empty($ufservice) ? $ufservice->favourite_exists('mod_forum', 'discussions',
+                    $discussionrec->id, $modcontext) : false;
                 $discussion->canreply = forum_user_can_post($forum, $discussion, $USER, $cm, $course, $modcontext);
+                $discussion->canfavourite = $canfavourite;
 
                 if (forum_is_author_hidden($discussion, $forum)) {
                     $discussion->userid = null;
@@ -729,8 +738,10 @@ class mod_forum_external extends external_api {
                                 'numunread' => new external_value(PARAM_INT, 'The number of unread discussions.'),
                                 'pinned' => new external_value(PARAM_BOOL, 'Is the discussion pinned'),
                                 'locked' => new external_value(PARAM_BOOL, 'Is the discussion locked'),
+                                'starred' => new external_value(PARAM_BOOL, 'Is the discussion starred'),
                                 'canreply' => new external_value(PARAM_BOOL, 'Can the user reply to the discussion'),
                                 'canlock' => new external_value(PARAM_BOOL, 'Can the user lock the discussion'),
+                                'canfavourite' => new external_value(PARAM_BOOL, 'Can the user star the discussion'),
                             ), 'post'
                         )
                     ),
@@ -1096,6 +1107,78 @@ class mod_forum_external extends external_api {
         );
     }
 
+    /**
+     * Toggle the favouriting value for the discussion provided
+     *
+     * @param int $discussionid The discussion we need to favourite
+     * @param bool $targetstate The state of the favourite value
+     * @return array The exported discussion
+     */
+    public static function toggle_favourite_state($discussionid, $targetstate) {
+        global $DB, $PAGE, $USER;
+
+        $params = self::validate_parameters(self::toggle_favourite_state_parameters(), [
+            'discussionid' => $discussionid,
+            'targetstate' => $targetstate
+        ]);
+
+        $vaultfactory = mod_forum\local\container::get_vault_factory();
+        // Get the discussion vault and the corresponding discussion entity.
+        $discussionvault = $vaultfactory->get_discussion_vault();
+        $discussion = $discussionvault->get_from_id($params['discussionid']);
+
+        $forumvault = $vaultfactory->get_forum_vault();
+        $forum = $forumvault->get_from_id($discussion->get_forum_id());
+        $forumcontext = $forum->get_context();
+        self::validate_context($forumcontext);
+
+        $managerfactory = mod_forum\local\container::get_manager_factory();
+        $capabilitymanager = $managerfactory->get_capability_manager($forum);
+
+        // Does the user have the ability to favourite the discussion?
+        if (!$capabilitymanager->can_favourite_discussion($USER, $discussion)) {
+            throw new moodle_exception('cannotfavourite', 'forum');
+        }
+        $usercontext = context_user::instance($USER->id);
+        $ufservice = \core_favourites\service_factory::get_service_for_user_context($usercontext);
+        $isfavourited = $ufservice->favourite_exists('mod_forum', 'discussions', $discussion->get_id(), $forumcontext);
+
+        $favouritefunction = $targetstate ? 'create_favourite' : 'delete_favourite';
+        if ($isfavourited != (bool) $params['targetstate']) {
+            $ufservice->{$favouritefunction}('mod_forum', 'discussions', $discussion->get_id(), $forumcontext);
+        }
+
+        $exporterfactory = mod_forum\local\container::get_exporter_factory();
+        $builder = mod_forum\local\container::get_builder_factory()->get_exported_discussion_builder();
+        $favourited = ($builder->is_favourited($discussion, $forumcontext, $USER) ? [$discussion->get_id()] : []);
+        $exporter = $exporterfactory->get_discussion_exporter($USER, $forum, $discussion, [], $favourited);
+        return $exporter->export($PAGE->get_renderer('mod_forum'));
+    }
+
+    /**
+     * Returns description of method result value
+     *
+     * @return external_description
+     * @since Moodle 3.0
+     */
+    public static function toggle_favourite_state_returns() {
+        return discussion_exporter::get_read_structure();
+    }
+
+    /**
+     * Defines the parameters for the toggle_favourite_state method
+     *
+     * @return external_function_parameters
+     */
+    public static function toggle_favourite_state_parameters() {
+        return new external_function_parameters(
+            [
+                'discussionid' => new external_value(PARAM_INT, 'The discussion to subscribe or unsubscribe'),
+                'targetstate' => new external_value(PARAM_BOOL, 'The target state')
+            ]
+        );
+    }
+
     /**
      * Returns description of method parameters
      *
@@ -1512,7 +1595,7 @@ class mod_forum_external extends external_api {
      * @return external_description
      */
     public static function set_subscription_state_returns() {
-        return \mod_forum\local\exporters\discussion::get_read_structure();
+        return discussion_exporter::get_read_structure();
     }
 
     /**
@@ -1554,7 +1637,6 @@ class mod_forum_external extends external_api {
         $discussion->toggle_locked_state($lockedvalue);
         $response = $discussionvault->update_discussion($discussion);
         $discussion = !$response ? $response : $discussion;
-
         $exporterfactory = mod_forum\local\container::get_exporter_factory();
         $exporter = $exporterfactory->get_discussion_exporter($USER, $forum, $discussion);
         return $exporter->export($PAGE->get_renderer('mod_forum'));
@@ -1582,11 +1664,73 @@ class mod_forum_external extends external_api {
      */
&n