Merge branch 'master' of https://github.com/kristian-94/moodle
authorSara Arjona <sara@moodle.com>
Wed, 30 Jan 2019 18:08:59 +0000 (19:08 +0100)
committerSara Arjona <sara@moodle.com>
Wed, 30 Jan 2019 18:08:59 +0000 (19:08 +0100)
68 files changed:
admin/tool/dataprivacy/classes/expired_contexts_manager.php
admin/tool/dataprivacy/tests/expired_contexts_test.php
blocks/lp/db/access.php
blocks/lp/lang/en/block_lp.php
blocks/lp/upgrade.txt [new file with mode: 0644]
blocks/lp/version.php
completion/classes/external.php
completion/tests/externallib_test.php
completion/upgrade.txt
course/externallib.php
course/tests/externallib_test.php
course/upgrade.txt
lib/adminlib.php
lib/classes/task/logging_trait.php [new file with mode: 0644]
lib/classes/task/manager.php
lib/evalmath/evalmath.class.php
lib/evalmath/readme_moodle.txt
lib/templates/popover_region.mustache
lib/templates/url_select.mustache
lib/tests/adhoc_task_test.php
lib/tests/mathslib_test.php
lib/upgrade.txt
message/classes/task/migrate_message_data.php
message/lib.php
message/templates/message_drawer_contacts_list.mustache
message/templates/message_drawer_messages_list.mustache
message/templates/message_drawer_non_contacts_list.mustache
message/tests/migrate_message_data_task_test.php
mod/assign/locallib.php
mod/assign/tests/behat/group_submission.feature
mod/folder/lib.php
mod/forum/classes/task/cron_task.php
mod/forum/classes/task/send_user_digests.php [new file with mode: 0644]
mod/forum/classes/task/send_user_notifications.php [new file with mode: 0644]
mod/forum/deprecatedlib.php
mod/forum/lib.php
mod/forum/templates/forum_post_emaildigestfull_textemail.mustache
mod/forum/tests/cron_trait.php [new file with mode: 0644]
mod/forum/tests/generator_trait.php [new file with mode: 0644]
mod/forum/tests/lib_test.php
mod/forum/tests/mail_group_test.php [new file with mode: 0644]
mod/forum/tests/mail_test.php
mod/forum/tests/maildigest_test.php
mod/forum/tests/qanda_test.php [new file with mode: 0644]
mod/lesson/locallib.php
mod/lesson/pagetypes/branchtable.php
mod/lesson/pagetypes/cluster.php
mod/lesson/pagetypes/endofbranch.php
mod/lesson/pagetypes/endofcluster.php
mod/quiz/settings.php
mod/workshop/classes/external.php
mod/workshop/tests/external_test.php
question/type/ddimageortext/edit_ddimageortext_form.php
question/type/ddimageortext/lang/en/qtype_ddimageortext.php
question/type/ddimageortext/tests/edit_form_test.php [new file with mode: 0644]
question/type/ddimageortext/tests/helper.php
question/type/ddimageortext/tests/walkthrough_test.php
question/type/ddmarker/edit_ddmarker_form.php
question/type/ddmarker/tests/edit_form_test.php [new file with mode: 0644]
question/type/ddwtos/tests/edit_form_test.php
question/type/gapselect/edit_form_base.php
question/type/gapselect/tests/edit_form_test.php
report/progress/index.php
search/engine/solr/settings.php
theme/boost/scss/moodle/core.scss
theme/boost/style/moodle.css
theme/bootstrapbase/less/moodle/bs4-compat.less
theme/bootstrapbase/style/moodle.css

index 90e7b2a..7672da3 100644 (file)
@@ -920,9 +920,17 @@ class expired_contexts_manager {
      * @return  bool
      */
     protected static function is_course_context_expired_or_unprotected_for_user(\context $context, \stdClass $user) {
-        $expiryrecords = self::get_nested_expiry_info_for_courses($context->path);
 
-        $info = $expiryrecords[$context->path]->info;
+        if ($context->get_course_context()->instanceid == SITEID) {
+            // The is an activity in the site course (front page).
+            $purpose = data_registry::get_effective_contextlevel_value(CONTEXT_SYSTEM, 'purpose');
+            $info = static::get_expiry_info($purpose);
+
+        } else {
+            $expiryrecords = self::get_nested_expiry_info_for_courses($context->path);
+            $info = $expiryrecords[$context->path]->info;
+        }
+
         if ($info->is_fully_expired()) {
             // This context is fully expired.
             return true;
index 08ffb80..6deb919 100644 (file)
@@ -2222,6 +2222,40 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase {
         $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($blockcontext, $user));
     }
 
+    /**
+     * Test the is_context_expired functions when supplied with the front page course.
+     */
+    public function test_is_context_expired_frontpage() {
+        $this->resetAfterTest();
+
+        $purposes = $this->setup_basics('PT1H', 'PT1H', 'P1D');
+
+        $frontcourse = get_site();
+        $frontcoursecontext = \context_course::instance($frontcourse->id);
+
+        $sitenews = $this->getDataGenerator()->create_module('forum', ['course' => $frontcourse->id]);
+        $cm = get_coursemodule_from_instance('forum', $sitenews->id);
+        $sitenewscontext = \context_module::instance($cm->id);
+
+        $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]);
+
+        $this->assertFalse(expired_contexts_manager::is_context_expired($frontcoursecontext));
+        $this->assertFalse(expired_contexts_manager::is_context_expired($sitenewscontext));
+
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($frontcoursecontext, $user));
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($sitenewscontext, $user));
+
+        // Protecting the course contextlevel does not impact the front page.
+        $purposes->course->set('protected', 1)->save();
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($frontcoursecontext, $user));
+        $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($sitenewscontext, $user));
+
+        // Protecting the system contextlevel affects the front page, too.
+        $purposes->system->set('protected', 1)->save();
+        $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($frontcoursecontext, $user));
+        $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($sitenewscontext, $user));
+    }
+
     /**
      * Test the is_context_expired functions when supplied with an expired course.
      */
index 73da9c9..056ac20 100644 (file)
@@ -44,14 +44,4 @@ $capabilities = array(
             'user' => CAP_ALLOW
         )
     ),
-
-    // Whether or not a user can see the block.
-    'block/lp:view' => array(
-        'captype' => 'read',
-        'contextlevel' => CONTEXT_SYSTEM,
-        'archetypes' => array(
-            'user' => CAP_ALLOW
-        ),
-    ),
-
 );
index 520f710..6207e9d 100644 (file)
@@ -27,7 +27,6 @@ defined('MOODLE_INTERNAL') || die();
 $string['competenciestoreview'] = 'Competencies to review';
 $string['lp:addinstance'] = 'Add a new learning plans block';
 $string['lp:myaddinstance'] = 'Add a new learning plans block to Dashboard';
-$string['lp:view'] = 'View learning plans block';
 $string['myplans'] = 'My plans';
 $string['noactiveplans'] = 'No active plans at the moment.';
 $string['planstoreview'] = 'Plans to review';
diff --git a/blocks/lp/upgrade.txt b/blocks/lp/upgrade.txt
new file mode 100644 (file)
index 0000000..4d24c96
--- /dev/null
@@ -0,0 +1,5 @@
+This file describes API changes in the lp block code.
+
+=== 3.7 ===
+
+* The 'block/lp:view' capability has been removed. It has never been used in code.
\ No newline at end of file
index 26e7063..8b829a0 100644 (file)
@@ -24,7 +24,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2018120300;
+$plugin->version   = 2018121900;
 $plugin->requires  = 2018112800;
 $plugin->component = 'block_lp';
 $plugin->dependencies = array(
index d92fdaf..9257232 100644 (file)
@@ -268,7 +268,8 @@ class core_completion_external extends external_api {
                        'state'         => $activitycompletiondata->completionstate,
                        'timecompleted' => $activitycompletiondata->timemodified,
                        'tracking'      => $activity->completion,
-                       'overrideby'    => $activitycompletiondata->overrideby
+                       'overrideby'    => $activitycompletiondata->overrideby,
+                       'valueused'     => core_availability\info::completion_value_used($course, $activity->id)
             );
         }
 
@@ -302,6 +303,8 @@ class core_completion_external extends external_api {
                                                                     0 means none, 1 manual, 2 automatic'),
                             'overrideby' => new external_value(PARAM_INT, 'The user id who has overriden the status, or null',
                                 VALUE_OPTIONAL),
+                            'valueused' => new external_value(PARAM_BOOL, 'Whether the completion status affects the availability
+                                    of another activity.', VALUE_OPTIONAL),
                         ), 'Activity'
                     ), 'List of activities status'
                 ),
index ae06d97..dc0d76e 100644 (file)
@@ -103,12 +103,14 @@ class core_completion_externallib_testcase extends externallib_advanced_testcase
         $course = $this->getDataGenerator()->create_course(array('enablecompletion' => 1,
                                                                     'groupmode' => SEPARATEGROUPS,
                                                                     'groupmodeforce' => 1));
+        availability_completion\condition::wipe_static_cache();
 
         $data = $this->getDataGenerator()->create_module('data', array('course' => $course->id),
                                                              array('completion' => 1));
         $forum = $this->getDataGenerator()->create_module('forum',  array('course' => $course->id),
                                                              array('completion' => 1));
-        $assign = $this->getDataGenerator()->create_module('assign',  array('course' => $course->id));
+        $availability = '{"op":"&","c":[{"type":"completion","cm":' . $forum->cmid .',"e":1}],"showc":[true]}';
+        $assign = $this->getDataGenerator()->create_module('assign', ['course' => $course->id], ['availability' => $availability]);
         $page = $this->getDataGenerator()->create_module('page',  array('course' => $course->id),
                                                             array('completion' => 1, 'visible' => 0));
 
@@ -146,10 +148,12 @@ class core_completion_externallib_testcase extends externallib_advanced_testcase
                 $activitiesfound++;
                 $this->assertEquals(COMPLETION_COMPLETE, $status['state']);
                 $this->assertEquals(COMPLETION_TRACKING_MANUAL, $status['tracking']);
+                $this->assertTrue($status['valueused']);
             } else if ($status['cmid'] == $data->cmid and $status['modname'] == 'data' and $status['instance'] == $data->id) {
                 $activitiesfound++;
                 $this->assertEquals(COMPLETION_INCOMPLETE, $status['state']);
                 $this->assertEquals(COMPLETION_TRACKING_MANUAL, $status['tracking']);
+                $this->assertFalse($status['valueused']);
             }
         }
         $this->assertEquals(2, $activitiesfound);
index 8ade6e1..db2324d 100644 (file)
@@ -1,6 +1,10 @@
 This files describes API changes in /completion/* - completion,
 information provided here is intended especially for developers.
 
+=== 3.7 ===
+ * External function core_completion_external::get_activities_completion_status new returns the following additional field:
+   - valueused (indicates whether the completion state affects the availability of other content)
+
 === 2.9 ===
 
 * A completed and failed activity counts as a completed activity for
index f30b41a..27d2629 100644 (file)
@@ -277,7 +277,8 @@ class core_course_external extends external_api {
                             $module['completiondata'] = array(
                                 'state'         => $completiondata->completionstate,
                                 'timecompleted' => $completiondata->timemodified,
-                                'overrideby'    => $completiondata->overrideby
+                                'overrideby'    => $completiondata->overrideby,
+                                'valueused'     => core_availability\info::completion_value_used($course, $cm->id)
                             );
                         }
 
@@ -440,6 +441,8 @@ class core_course_external extends external_api {
                                             'timecompleted' => new external_value(PARAM_INT, 'Timestamp for completion status.'),
                                             'overrideby' => new external_value(PARAM_INT, 'The user id who has overriden the
                                                 status.'),
+                                            'valueused' => new external_value(PARAM_BOOL, 'Whether the completion status affects
+                                                the availability of another activity.', VALUE_OPTIONAL),
                                         ), 'Module completion data.', VALUE_OPTIONAL
                                     ),
                                     'contents' => new external_multiple_structure(
index 627182e..7520c64 100644 (file)
@@ -870,14 +870,16 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $labeldescription = 'This is a very long label to test if more than 50 characters are returned.
                 So bla bla bla bla <b>bold bold bold</b> bla bla bla bla.';
         $label = $this->getDataGenerator()->create_module('label', array('course' => $course->id,
-            'intro' => $labeldescription));
+            'intro' => $labeldescription, 'completion' => COMPLETION_TRACKING_MANUAL));
         $labelcm = get_coursemodule_from_instance('label', $label->id);
         $tomorrow = time() + DAYSECS;
         // Module with availability restrictions not met.
+        $availability = '{"op":"&","c":[{"type":"date","d":">=","t":' . $tomorrow . '},'
+                .'{"type":"completion","cm":' . $label->cmid .',"e":1}],"showc":[true,true]}';
         $url = $this->getDataGenerator()->create_module('url',
             array('course' => $course->id, 'name' => 'URL: % & $ ../', 'section' => 2, 'display' => RESOURCELIB_DISPLAY_POPUP,
                 'popupwidth' => 100, 'popupheight' => 100),
-            array('availability' => '{"op":"&","c":[{"type":"date","d":">=","t":' . $tomorrow . '}],"showc":[true]}'));
+            array('availability' => $availability));
         $urlcm = get_coursemodule_from_instance('url', $url->id);
         // Module for the last section.
         $this->getDataGenerator()->create_module('url',
@@ -1189,6 +1191,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $this->resetAfterTest(true);
 
         list($course, $forumcm, $datacm, $pagecm, $labelcm, $urlcm) = $this->prepare_get_course_contents_test();
+        availability_completion\condition::wipe_static_cache();
 
         // Test activity not completed yet.
         $result = core_course_external::get_course_contents($course->id, array(
@@ -1202,6 +1205,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $this->assertEquals(0, $result[0]['modules'][0]["completiondata"]['state']);
         $this->assertEquals(0, $result[0]['modules'][0]["completiondata"]['timecompleted']);
         $this->assertEmpty($result[0]['modules'][0]["completiondata"]['overrideby']);
+        $this->assertFalse($result[0]['modules'][0]["completiondata"]['valueused']);
 
         // Set activity completed.
         core_completion_external::update_activity_completion_status_manually($forumcm->id, true);
@@ -1215,6 +1219,20 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $this->assertNotEmpty($result[0]['modules'][0]["completiondata"]['timecompleted']);
         $this->assertEmpty($result[0]['modules'][0]["completiondata"]['overrideby']);
 
+        // Test activity with completion value that is used in an availability condition.
+        $result = core_course_external::get_course_contents($course->id, array(
+                array("name" => "modname", "value" => "label"), array("name" => "modid", "value" => $labelcm->instance)));
+        // We need to execute the return values cleaning process to simulate the web service server.
+        $result = external_api::clean_returnvalue(core_course_external::get_course_contents_returns(), $result);
+
+        $this->assertCount(1, $result[0]['modules']);
+        $this->assertEquals("label", $result[0]['modules'][0]["modname"]);
+        $this->assertEquals(COMPLETION_TRACKING_MANUAL, $result[0]['modules'][0]["completion"]);
+        $this->assertEquals(0, $result[0]['modules'][0]["completiondata"]['state']);
+        $this->assertEquals(0, $result[0]['modules'][0]["completiondata"]['timecompleted']);
+        $this->assertEmpty($result[0]['modules'][0]["completiondata"]['overrideby']);
+        $this->assertTrue($result[0]['modules'][0]["completiondata"]['valueused']);
+
         // Disable completion.
         $CFG->enablecompletion = 0;
         $result = core_course_external::get_course_contents($course->id, array(
@@ -2922,4 +2940,4 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $this->assertCount(1, $result);
         $this->assertEquals($courses[0]->id, array_shift($result)->id);
     }
-}
\ No newline at end of file
+}
index 0013e74..f23582e 100644 (file)
@@ -1,6 +1,10 @@
 This files describes API changes in /course/*,
 information provided here is intended especially for developers.
 
+=== 3.7 ===
+ * External function core_course_external::get_course_contents new returns the following additional completiondata field:
+   - valueused (indicates whether the completion state affects the availability of other content)
+
 === 3.6 ===
 
  * External function core_course_external::get_course_public_information now returns the roles and the primary role of course
index 00940fe..8772235 100644 (file)
@@ -2489,7 +2489,28 @@ class admin_setting_configpasswordunmask extends admin_setting_configtext {
         $element = $OUTPUT->render_from_template('core_admin/setting_configpasswordunmask', $context);
         return format_admin_setting($this, $this->visiblename, $element, $this->description, true, '', null, $query);
     }
+}
 
+/**
+ * Password field, allows unmasking of password, with an advanced checkbox that controls an additional $name.'_adv' setting.
+ *
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @copyright 2018 Paul Holden (pholden@greenhead.ac.uk)
+ */
+class admin_setting_configpasswordunmask_with_advanced extends admin_setting_configpasswordunmask {
+
+    /**
+     * Constructor
+     *
+     * @param string $name unique ascii name, either 'mysetting' for settings that in config, or 'myplugin/mysetting' for ones in config_plugins.
+     * @param string $visiblename localised
+     * @param string $description long localised info
+     * @param array $defaultsetting ('value'=>string, 'adv'=>bool)
+     */
+    public function __construct($name, $visiblename, $description, $defaultsetting) {
+        parent::__construct($name, $visiblename, $description, $defaultsetting['value']);
+        $this->set_advanced_flag_options(admin_setting_flag::ENABLED, !empty($defaultsetting['adv']));
+    }
 }
 
 /**
diff --git a/lib/classes/task/logging_trait.php b/lib/classes/task/logging_trait.php
new file mode 100644 (file)
index 0000000..8701c2a
--- /dev/null
@@ -0,0 +1,112 @@
+<?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/>.
+
+/**
+ * This file defines a trait to assist with logging in tasks.
+ *
+ * @package    core
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\task;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * This trait includes functions to assist with logging in tasks.
+ *
+ * @package    core
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+trait logging_trait {
+
+    /**
+     * @var \progress_trace
+     */
+    protected $trace = null;
+
+    /**
+     * @var \stdClass
+     */
+    protected $tracestats = null;
+
+    /**
+     * Get the progress_trace.
+     *
+     * @return  \progress_trace
+     */
+    protected function get_trace() {
+        if (null === $this->trace) {
+            $this->trace = new \text_progress_trace();
+            $this->tracestats = new \stdClass();
+        }
+
+        return $this->trace;
+    }
+
+    /**
+     * Log a message to the progress tracer.
+     *
+     * @param   string  $message
+     * @param   int     $depth
+     */
+    protected function log($message, $depth = 1) {
+        $this->get_trace()
+            ->output($message, $depth);
+    }
+
+    /**
+     * Log a start message to the progress tracer.
+     *
+     * @param   string  $message
+     * @param   int     $depth
+     */
+    protected function log_start($message, $depth = 0) {
+        $this->log($message, $depth);
+
+        if (defined('MDL_PERFTOLOG') && MDL_PERFTOLOG) {
+            $this->tracestats->$depth = [
+                'mem' => memory_get_usage(),
+                'time' => microtime(),
+            ];
+        }
+    }
+
+    /**
+     * Log an end message to the progress tracer.
+     *
+     * @param   string  $message
+     * @param   int     $depth
+     */
+    protected function log_finish($message, $depth = 0) {
+        $this->log($message, $depth);
+
+        if (isset($this->tracestats->$depth)) {
+            $startstats = $this->tracestats->$depth;
+            $this->log(
+                    sprintf("Time taken %s, memory total: %s, Memory growth: %s, Memory peak: %s",
+                        microtime_diff($startstats['time'], microtime()),
+                        display_size(memory_get_usage()),
+                        display_size(memory_get_usage() - $startstats['mem']),
+                        display_size(memory_get_peak_usage())
+                    ),
+                    $depth + 1
+                );
+        }
+    }
+}
index 5952018..87fe342 100644 (file)
@@ -129,7 +129,18 @@ class manager {
      * @return bool
      */
     protected static function task_is_scheduled($task) {
+        return false !== self::get_queued_adhoc_task_record($task);
+    }
+
+    /**
+     * Checks if the task with the same classname, component and customdata is already scheduled
+     *
+     * @param adhoc_task $task
+     * @return bool
+     */
+    protected static function get_queued_adhoc_task_record($task) {
         global $DB;
+
         $record = self::record_from_adhoc_task($task);
         $params = [$record->classname, $record->component, $record->customdata];
         $sql = 'classname = ? AND component = ? AND ' .
@@ -139,14 +150,38 @@ class manager {
             $params[] = $record->userid;
             $sql .= " AND userid = ? ";
         }
-        return $DB->record_exists_select('task_adhoc', $sql, $params);
+        return $DB->get_record_select('task_adhoc', $sql, $params);
+    }
+
+    /**
+     * Schedule a new task, or reschedule an existing adhoc task which has matching data.
+     *
+     * Only a task matching the same user, classname, component, and customdata will be rescheduled.
+     * If these values do not match exactly then a new task is scheduled.
+     *
+     * @param \core\task\adhoc_task $task - The new adhoc task information to store.
+     * @since Moodle 3.7
+     */
+    public static function reschedule_or_queue_adhoc_task(adhoc_task $task) : void {
+        global $DB;
+
+        if ($existingrecord = self::get_queued_adhoc_task_record($task)) {
+            // Only update the next run time if it is explicitly set on the task.
+            $nextruntime = $task->get_next_run_time();
+            if ($nextruntime && ($existingrecord->nextruntime != $nextruntime)) {
+                $DB->set_field('task_adhoc', 'nextruntime', $nextruntime, ['id' => $existingrecord->id]);
+            }
+        } else {
+            // There is nothing queued yet. Just queue as normal.
+            self::queue_adhoc_task($task);
+        }
     }
 
     /**
      * Queue an adhoc task to run in the background.
      *
      * @param \core\task\adhoc_task $task - The new adhoc task information to store.
-     * @param bool $checkforexisting - If set to true and the task with the same classname, component and customdata
+     * @param bool $checkforexisting - If set to true and the task with the same user, classname, component and customdata
      *     is already scheduled then it will not schedule a new task. Can be used only for ASAP tasks.
      * @return boolean - True if the config was saved.
      */
index d8875b5..26e204b 100644 (file)
@@ -114,8 +114,8 @@ class EvalMath {
         'average'=>array(-1), 'max'=>array(-1),  'min'=>array(-1),
         'mod'=>array(2),      'pi'=>array(0),    'power'=>array(2),
         'round'=>array(1, 2), 'sum'=>array(-1), 'rand_int'=>array(2),
-        'rand_float'=>array(0), 'ifthenelse'=>array(3));
-    var $fcsynonyms = array('if' => 'ifthenelse');
+        'rand_float'=>array(0), 'ifthenelse'=>array(3), 'cond_and'=>array(-1), 'cond_or'=>array(-1));
+    var $fcsynonyms = array('if' => 'ifthenelse', 'and' => 'cond_and', 'or' => 'cond_or');
 
     var $allowimplicitmultiplication;
 
@@ -534,6 +534,27 @@ class EvalMathFuncs {
             return $else;
         }
     }
+
+    static function cond_and() {
+        $args = func_get_args();
+        foreach($args as $a) {
+            if ($a == false) {
+                return 0;
+            }
+        }
+        return 1;
+    }
+
+    static function cond_or() {
+        $args = func_get_args();
+        foreach($args as $a) {
+            if($a == true) {
+                return 1;
+            }
+        }
+        return 0;
+    }
+
     static function average() {
         $args = func_get_args();
         return (call_user_func_array(array('self', 'sum'), $args) / count($args));
index c9935c5..c34b31d 100644 (file)
@@ -22,3 +22,9 @@ skodak, Tim Hunt
 Changes by Juan Pablo de Castro (MDL-14274):
 * operators >,<,>=,<=,== added.
 * function if[thenelse](condition, true_value, false_value)
+
+Changes by Stefan Erlachner, Thomas Niedermaier (MDL-64414):
+* add function or:
+e.g. if (or(condition_1, condition_2, ... condition_n))
+* add function and:
+e.g. if (and(condition_1, condition_2, ... condition_n))
\ No newline at end of file
index 753ab80..2047541 100644 (file)
@@ -38,7 +38,7 @@
     data-region="popover-region">
     <div class="popover-region-toggle nav-link"
         data-region="popover-region-toggle"
-        aria-role="button"
+        role="button"
         aria-controls="popover-region-container-{{uniqid}}"
         aria-haspopup="true"
         aria-label="{{$togglelabel}}{{#str}}showpopovermenu{{/str}}{{/togglelabel}}"
index 0904896..7960b33 100644 (file)
@@ -74,6 +74,9 @@
     {{#js}}
         require(['jquery'], function($) {
             $('#{{id}}').change(function() {
+                if (!$(this).val()) {
+                    return false;
+                }
                 $('#{{formid}}').submit();
             });
         });
index 2b8b7d1..c5d7961 100644 (file)
@@ -150,6 +150,114 @@ class core_adhoc_task_testcase extends advanced_testcase {
         }
     }
 
+    /**
+     * Ensure that the reschedule_or_queue_adhoc_task function will schedule a new task if no tasks exist.
+     */
+    public function test_reschedule_or_queue_adhoc_task_no_existing() {
+        $this->resetAfterTest(true);
+
+        // Schedule adhoc task.
+        $task = new \core\task\adhoc_test_task();
+        $task->set_custom_data(['courseid' => 10]);
+        \core\task\manager::reschedule_or_queue_adhoc_task($task);
+        $this->assertEquals(1, count(\core\task\manager::get_adhoc_tasks('core\task\adhoc_test_task')));
+    }
+
+    /**
+     * Ensure that the reschedule_or_queue_adhoc_task function will schedule a new task if a task for the same user does
+     * not exist.
+     */
+    public function test_reschedule_or_queue_adhoc_task_different_user() {
+        $this->resetAfterTest(true);
+        $user = \core_user::get_user_by_username('admin');
+
+        // Schedule adhoc task.
+        $task = new \core\task\adhoc_test_task();
+        $task->set_custom_data(['courseid' => 10]);
+        \core\task\manager::reschedule_or_queue_adhoc_task($task);
+
+        // Schedule adhoc task for a different user.
+        $task = new \core\task\adhoc_test_task();
+        $task->set_custom_data(['courseid' => 10]);
+        $task->set_userid($user->id);
+        \core\task\manager::reschedule_or_queue_adhoc_task($task);
+
+        $this->assertEquals(2, count(\core\task\manager::get_adhoc_tasks('core\task\adhoc_test_task')));
+    }
+
+    /**
+     * Ensure that the reschedule_or_queue_adhoc_task function will schedule a new task if a task with different custom
+     * data exists.
+     */
+    public function test_reschedule_or_queue_adhoc_task_different_data() {
+        $this->resetAfterTest(true);
+
+        // Schedule adhoc task.
+        $task = new \core\task\adhoc_test_task();
+        $task->set_custom_data(['courseid' => 10]);
+        \core\task\manager::reschedule_or_queue_adhoc_task($task);
+
+        // Schedule adhoc task for a different user.
+        $task = new \core\task\adhoc_test_task();
+        $task->set_custom_data(['courseid' => 11]);
+        \core\task\manager::reschedule_or_queue_adhoc_task($task);
+
+        $this->assertEquals(2, count(\core\task\manager::get_adhoc_tasks('core\task\adhoc_test_task')));
+    }
+
+    /**
+     * Ensure that the reschedule_or_queue_adhoc_task function will not make any change for matching data if no time was
+     * specified.
+     */
+    public function test_reschedule_or_queue_adhoc_task_match_no_change() {
+        $this->resetAfterTest(true);
+
+        // Schedule adhoc task.
+        $task = new \core\task\adhoc_test_task();
+        $task->set_custom_data(['courseid' => 10]);
+        $task->set_next_run_time(time() + DAYSECS);
+        \core\task\manager::reschedule_or_queue_adhoc_task($task);
+
+        $before = \core\task\manager::get_adhoc_tasks('core\task\adhoc_test_task');
+
+        // Schedule the task again but do not specify a time.
+        $task = new \core\task\adhoc_test_task();
+        $task->set_custom_data(['courseid' => 10]);
+        \core\task\manager::reschedule_or_queue_adhoc_task($task);
+
+        $this->assertEquals(1, count(\core\task\manager::get_adhoc_tasks('core\task\adhoc_test_task')));
+        $this->assertEquals($before, \core\task\manager::get_adhoc_tasks('core\task\adhoc_test_task'));
+    }
+
+    /**
+     * Ensure that the reschedule_or_queue_adhoc_task function will update the run time if there are planned changes.
+     */
+    public function test_reschedule_or_queue_adhoc_task_match_update_runtime() {
+        $this->resetAfterTest(true);
+        $initialruntime = time() + DAYSECS;
+        $newruntime = time() + WEEKSECS;
+
+        // Schedule adhoc task.
+        $task = new \core\task\adhoc_test_task();
+        $task->set_custom_data(['courseid' => 10]);
+        $task->set_next_run_time($initialruntime);
+        \core\task\manager::reschedule_or_queue_adhoc_task($task);
+
+        $before = \core\task\manager::get_adhoc_tasks('core\task\adhoc_test_task');
+
+        // Schedule the task again.
+        $task = new \core\task\adhoc_test_task();
+        $task->set_custom_data(['courseid' => 10]);
+        $task->set_next_run_time($newruntime);
+        \core\task\manager::reschedule_or_queue_adhoc_task($task);
+
+        $tasks = \core\task\manager::get_adhoc_tasks('core\task\adhoc_test_task');
+        $this->assertEquals(1, count($tasks));
+        $this->assertNotEquals($before, $tasks);
+        $firsttask = reset($tasks);
+        $this->assertEquals($newruntime, $firsttask->get_next_run_time());
+    }
+
     /**
      * Test queue_adhoc_task "if not scheduled".
      */
index 6d37bcf..9992175 100644 (file)
@@ -82,6 +82,7 @@ class core_mathslib_testcase extends basic_testcase {
     }
 
     public function test_conditional_functions() {
+        // Test ifthenelse.
         $formula = new calc_formula('=ifthenelse(1,2,3)');
         $this->assertSame(2, (int)$formula->evaluate());
 
@@ -91,7 +92,7 @@ class core_mathslib_testcase extends basic_testcase {
         $formula = new calc_formula('=ifthenelse(2<3,2,3)');
         $this->assertSame(2, (int) $formula->evaluate());
 
-        // Test synonim if.
+        // Test synonym if.
         $formula = new calc_formula('=if(1,2,3)');
         $this->assertSame(2, (int)$formula->evaluate());
 
@@ -100,6 +101,46 @@ class core_mathslib_testcase extends basic_testcase {
 
         $formula = new calc_formula('=if(2<3,2,3)');
         $this->assertSame(2, (int) $formula->evaluate());
+
+        // Test cond_and.
+        $formula = new calc_formula('=cond_and(1,1,1)');
+        $this->assertSame(1, (int)$formula->evaluate());
+
+        $formula = new calc_formula('=cond_and(1,1,0)');
+        $this->assertSame(0, (int) $formula->evaluate());
+
+        $formula = new calc_formula('=cond_and(0,0,0)');
+        $this->assertSame(0, (int) $formula->evaluate());
+
+        // Test synonym and.
+        $formula = new calc_formula('=and(1,1,1)');
+        $this->assertSame(1, (int)$formula->evaluate());
+
+        $formula = new calc_formula('=and(1,1,0)');
+        $this->assertSame(0, (int) $formula->evaluate());
+
+        $formula = new calc_formula('=and(0,0,0)');
+        $this->assertSame(0, (int) $formula->evaluate());
+
+        // Test cond_or.
+        $formula = new calc_formula('=cond_or(1,1,1)');
+        $this->assertSame(1, (int)$formula->evaluate());
+
+        $formula = new calc_formula('=cond_or(1,1,0)');
+        $this->assertSame(1, (int) $formula->evaluate());
+
+        $formula = new calc_formula('=cond_or(0,0,0)');
+        $this->assertSame(0, (int) $formula->evaluate());
+
+        // Test synonym or.
+        $formula = new calc_formula('=or(1,1,1)');
+        $this->assertSame(1, (int)$formula->evaluate());
+
+        $formula = new calc_formula('=or(1,1,0)');
+        $this->assertSame(1, (int) $formula->evaluate());
+
+        $formula = new calc_formula('=or(0,0,0)');
+        $this->assertSame(0, (int) $formula->evaluate());
     }
 
     public function test_conditional_operators() {
index 85225fd..54e5523 100644 (file)
@@ -6,6 +6,8 @@ information provided here is intended especially for developers.
 * The method core_user::is_real_user() now returns false for userid = 0 parameter
 * 'mform1' dependencies (in themes, js...) will stop working because a randomly generated string has been added to the id
 attribute on forms to avoid collisions in forms loaded in AJAX requests.
+* A new method to allow queueing or rescheduling of an existing scheduled task was added. This allows an existing task
+  to be updated or queued as required. This new functionality can be found in \core\task\manager::reschedule_or_queue_adhoc_task.
 
 === 3.6 ===
 
index 53e2182..2cf5dbd 100644 (file)
@@ -236,7 +236,7 @@ class migrate_message_data extends \core\task\adhoc_task {
         }
 
         // Check if we need to mark this message as deleted for the user to.
-        if ($message->timeusertodeleted) {
+        if ($message->timeusertodeleted and ($message->useridfrom != $message->useridto)) {
             $mua = new \stdClass();
             $mua->userid = $message->useridto;
             $mua->messageid = $messageid;
index 2d46e4d..474b274 100644 (file)
@@ -857,9 +857,6 @@ function core_message_standard_after_main_region_html() {
     // Enter to send.
     $entertosend = get_user_preferences('message_entertosend', false, $USER);
 
-    // Get the unread counts for the current user.
-    $unreadcounts = \core_message\api::get_unread_conversation_counts($USER->id);
-
     return $renderer->render_from_template('core_message/message_drawer', [
         'contactrequestcount' => $requestcount,
         'loggedinuser' => [
index 37f7fc9..bb395da 100644 (file)
@@ -60,7 +60,7 @@
         {{#showonlinestatus}}
             <span class="contact-status {{#isonline}}online{{/isonline}}"></span>
         {{/showonlinestatus}}
-        <h6 class="ml-2" data-region="searchable">{{fullname}}</h6>
+        <h6 class="ml-2 font-weight-bold" data-region="searchable">{{fullname}}</h6>
         <div
             class="ml-auto align-self-end {{^isblocked}}hidden{{/isblocked}}"
             data-region="block-icon-container"
index 3373a79..fb96cf6 100644 (file)
@@ -73,7 +73,7 @@
                     data-region="last-message-date"
                 >
                     {{#lastmessagedate}}
-                        {{#userdate}} {{.}}, {{#str}} strftimetime24, core_langconfig  {{/str}} {{/userdate}}
+                        {{#userdate}} {{.}}, {{#str}} strftimedatefullshort, core_langconfig  {{/str}} {{/userdate}}
                     {{/lastmessagedate}}
                 </div>
             </div>
index 19852d9..9da0778 100644 (file)
@@ -55,7 +55,7 @@
             aria-hidden="true"
             style="height: 38px"
         >
-        <h6 class="ml-2" data-region="searchable">{{fullname}}</h6>
+        <h6 class="ml-2 font-weight-bold" data-region="searchable">{{fullname}}</h6>
         {{#isblocked}}
             <div class="ml-auto align-self-end">
                 {{#pix}} t/block, core, {{#str}} contactblocked, message {{/str}} {{/pix}}
index 3ec3009..a480e70 100644 (file)
@@ -324,6 +324,37 @@ class core_message_migrate_message_data_task_testcase extends advanced_testcase
         $this->assertEquals(FORMAT_MOODLE, $notification->fullmessageformat);
     }
 
+    /**
+     * Test migrating a legacy message that a user sent to themselves then deleted.
+     */
+    public function test_migrating_message_deleted_message_sent_to_self() {
+        global $DB;
+
+        // Create user to test with.
+        $user1 = $this->getDataGenerator()->create_user();
+
+        $m1 = $this->create_legacy_message_or_notification($user1->id, $user1->id, null, false, null, null);
+
+        // Let's delete the message for the 'user to' and 'user from' which in this case is the same user.
+        $messageupdate = new stdClass();
+        $messageupdate->id = $m1;
+        $messageupdate->timeuserfromdeleted = time();
+        $messageupdate->timeusertodeleted = time();
+        $DB->update_record('message', $messageupdate);
+
+        // Now, let's execute the task for the user.
+        $task = new \core_message\task\migrate_message_data();
+        $task->set_custom_data(
+            [
+                'userid' => $user1->id
+            ]
+        );
+        $task->execute();
+
+        $this->assertEquals(0, $DB->count_records('message'));
+        $this->assertEquals(1, $DB->count_records('message_user_actions'));
+    }
+
     /**
      * Creates a legacy message or notification to be used for testing.
      *
index c2a9d0d..1be55b7 100644 (file)
@@ -3017,7 +3017,11 @@ class assign {
                 $submitted = $assignment->count_submissions_with_status(ASSIGN_SUBMISSION_STATUS_SUBMITTED);
 
             } else if (has_capability('mod/assign:submit', $context)) {
-                $usersubmission = $assignment->get_user_submission($USER->id, false);
+                if ($assignment->get_instance()->teamsubmission) {
+                    $usersubmission = $assignment->get_group_submission($USER->id, 0, false);
+                } else {
+                    $usersubmission = $assignment->get_user_submission($USER->id, false);
+                }
 
                 if (!empty($usersubmission->status)) {
                     $submitted = get_string('submissionstatus_' . $usersubmission->status, 'assign');
@@ -5196,6 +5200,9 @@ class assign {
 
             $viewfullnames = has_capability('moodle/site:viewfullnames', $this->get_context());
 
+            if ($grade) {
+                \mod_assign\event\feedback_viewed::create_from_grade($this, $grade)->trigger();
+            }
             $feedbackstatus = new assign_feedback_status($gradefordisplay,
                                                   $gradeddate,
                                                   $grader,
index e6f6ea4..e341cb4 100644 (file)
@@ -293,3 +293,64 @@ Feature: Group assignment submissions
     And I press "Go"
     And I should see "1" in the "Groups" "table_row"
     And I should see "1" in the "Submitted" "table_row"
+
+  Scenario: Confirm that the submission status changes for each group member
+    Given the following "courses" exist:
+      | fullname | shortname | category | groupmode |
+      | Course 1 | C1 | 0 | 1 |
+    And the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+      | student1 | Student | 1 | student1@example.com |
+      | student2 | Student | 2 | student2@example.com |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+      | student2 | C1 | student |
+    And the following "groups" exist:
+      | name | course | idnumber |
+      | Group 1 | C1 | G1 |
+    And the following "group members" exist:
+      | user | group |
+      | student1 | G1 |
+      | student2 | G1 |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add a "Assignment" to section "1" and I fill the form with:
+      | Assignment name | Test assignment name |
+      | Description | Test assignment description |
+      | assignsubmission_onlinetext_enabled | 1 |
+      | assignsubmission_file_enabled | 0 |
+      | Require students to click the submit button | Yes |
+      | Students submit in groups | Yes |
+      | Group mode | No groups |
+      | Require group to make submission | No |
+      | Require all group members submit | No |
+    And I am on "Course 1" course homepage
+    And I add the "Activities" block
+    And I log out
+    And I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I follow "Test assignment name"
+    And I press "Add submission"
+    And I set the following fields to these values:
+      | Online text | I'm the student's first submission |
+    And I press "Save changes"
+    And I press "Submit assignment"
+    And I press "Continue"
+    And I am on "Course 1" course homepage
+    And I click on "Assignments" "link" in the "Activities" "block"
+    And I should see "Submitted for grading"
+    And I log out
+    And I log in as "student2"
+    And I am on "Course 1" course homepage
+    And I click on "Assignments" "link" in the "Activities" "block"
+    And I should see "Submitted for grading"
+    And I log out
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Test assignment name"
+    When I navigate to "View all submissions" in current page administration
+    Then "Student 1" row "Status" column of "generaltable" table should contain "Submitted for grading"
+    And "Student 2" row "Status" column of "generaltable" table should contain "Submitted for grading"
index 3a54f19..3dd167e 100644 (file)
@@ -105,6 +105,10 @@ function folder_add_instance($data, $mform) {
     $draftitemid = $data->files;
 
     $data->timemodified = time();
+    // If 'showexpanded' is not set, apply the site config.
+    if (!isset($data->showexpanded)) {
+        $data->showexpanded = get_config('folder', 'showexpanded');
+    }
     $data->id = $DB->insert_record('folder', $data);
 
     // we need to use context now, so we need to make sure all needed info is already in db
index f232c62..ca3b41e 100644 (file)
 /**
  * A scheduled task for forum cron.
  *
- * @todo MDL-44734 This job will be split up properly.
- *
  * @package    mod_forum
  * @copyright  2014 Dan Poltawski <dan@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 namespace mod_forum\task;
 
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->dirroot . '/mod/forum/lib.php');
+
+/**
+ * The main scheduled task for the forum.
+ *
+ * @package    mod_forum
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
 class cron_task extends \core\task\scheduled_task {
 
+    // Use the logging trait to get some nice, juicy, logging.
+    use \core\task\logging_trait;
+
+    /**
+     * @var The list of courses which contain posts to be sent.
+     */
+    protected $courses = [];
+
+    /**
+     * @var The list of forums which contain posts to be sent.
+     */
+    protected $forums = [];
+
+    /**
+     * @var The list of discussions which contain posts to be sent.
+     */
+    protected $discussions = [];
+
+    /**
+     * @var The list of posts to be sent.
+     */
+    protected $posts = [];
+
+    /**
+     * @var The list of post authors.
+     */
+    protected $users = [];
+
+    /**
+     * @var The list of subscribed users.
+     */
+    protected $subscribedusers = [];
+
+    /**
+     * @var The list of digest users.
+     */
+    protected $digestusers = [];
+
+    /**
+     * @var The list of adhoc data for sending.
+     */
+    protected $adhocdata = [];
+
     /**
      * Get a descriptive name for this task (shown to admins).
      *
@@ -37,12 +89,450 @@ class cron_task extends \core\task\scheduled_task {
     }
 
     /**
-     * Run forum cron.
+     * Execute the scheduled task.
      */
     public function execute() {
-        global $CFG;
-        require_once($CFG->dirroot . '/mod/forum/lib.php');
-        forum_cron();
+        global $CFG, $DB;
+
+        $timenow = time();
+
+        // Delete any really old posts in the digest queue.
+        $weekago = $timenow - (7 * 24 * 3600);
+        $this->log_start("Removing old digest records from 7 days ago.");
+        $DB->delete_records_select('forum_queue', "timemodified < ?", array($weekago));
+        $this->log_finish("Removed all old digest records.");
+
+        $endtime   = $timenow - $CFG->maxeditingtime;
+        $starttime = $endtime - (2 * DAYSECS);
+        $this->log_start("Fetching unmailed posts.");
+        if (!$posts = $this->get_unmailed_posts($starttime, $endtime, $timenow)) {
+            $this->log_finish("No posts found.", 1);
+            return false;
+        }
+        $this->log_finish("Done");
+
+        // Process post data and turn into adhoc tasks.
+        $this->process_post_data($posts);
+
+        // Mark posts as read.
+        list($in, $params) = $DB->get_in_or_equal(array_keys($posts));
+        $DB->set_field_select('forum_posts', 'mailed', 1, "id {$in}", $params);
+    }
+
+    /**
+     * Process all posts and convert to appropriated hoc tasks.
+     *
+     * @param   \stdClass[] $posts
+     */
+    protected function process_post_data($posts) {
+        $discussionids = [];
+        $forumids = [];
+        $courseids = [];
+
+        $this->log_start("Processing post information");
+
+        $start = microtime(true);
+        foreach ($posts as $id => $post) {
+            $discussionids[$post->discussion] = true;
+            $forumids[$post->forum] = true;
+            $courseids[$post->course] = true;
+            $this->add_data_for_post($post);
+            $this->posts[$id] = $post;
+        }
+        $this->log_finish(sprintf("Processed %s posts", count($this->posts)));
+
+        if (empty($this->posts)) {
+            $this->log("No posts found. Returning early.");
+            return;
+        }
+
+        // Please note, this order is intentional.
+        // The forum cache makes use of the course.
+        $this->log_start("Filling caches");
+
+        $start = microtime(true);
+        $this->log_start("Filling course cache", 1);
+        $this->fill_course_cache(array_keys($courseids));
+        $this->log_finish("Done", 1);
+
+        $this->log_start("Filling forum cache", 1);
+        $this->fill_forum_cache(array_keys($forumids));
+        $this->log_finish("Done", 1);
+
+        $this->log_start("Filling discussion cache", 1);
+        $this->fill_discussion_cache(array_keys($discussionids));
+        $this->log_finish("Done", 1);
+
+        $this->log_start("Filling user subscription cache", 1);
+        $this->fill_user_subscription_cache();
+        $this->log_finish("Done", 1);
+
+        $this->log_start("Filling digest cache", 1);
+        $this->fill_digest_cache();
+        $this->log_finish("Done", 1);
+
+        $this->log_finish("All caches filled");
+
+        $this->log_start("Queueing user tasks.");
+        $this->queue_user_tasks();
+        $this->log_finish("All tasks queued.");
+    }
+
+    /**
+     * Fill the course cache.
+     *
+     * @param   int[]       $courseids
+     */
+    protected function fill_course_cache($courseids) {
+        global $DB;
+
+        list($in, $params) = $DB->get_in_or_equal($courseids);
+        $this->courses = $DB->get_records_select('course', "id $in", $params);
+    }
+
+    /**
+     * Fill the forum cache.
+     *
+     * @param   int[]       $forumids
+     */
+    protected function fill_forum_cache($forumids) {
+        global $DB;
+
+        $requiredfields = [
+                'id',
+                'course',
+                'forcesubscribe',
+                'type',
+            ];
+        list($in, $params) = $DB->get_in_or_equal($forumids);
+        $this->forums = $DB->get_records_select('forum', "id $in", $params, '', implode(', ', $requiredfields));
+        foreach ($this->forums as $id => $forum) {
+            \mod_forum\subscriptions::fill_subscription_cache($id);
+            \mod_forum\subscriptions::fill_discussion_subscription_cache($id);
+        }
+    }
+
+    /**
+     * Fill the discussion cache.
+     *
+     * @param   int[]       $discussionids
+     */
+    protected function fill_discussion_cache($discussionids) {
+        global $DB;
+
+        if (empty($discussionids)) {
+            $this->discussion = [];
+        } else {
+
+            $requiredfields = [
+                    'id',
+                    'groupid',
+                    'firstpost',
+                    'timestart',
+                    'timeend',
+                ];
+
+            list($in, $params) = $DB->get_in_or_equal($discussionids);
+            $this->discussions = $DB->get_records_select(
+                    'forum_discussions', "id $in", $params, '', implode(', ', $requiredfields));
+        }
+    }
+
+    /**
+     * Fill the cache of user digest preferences.
+     */
+    protected function fill_digest_cache() {
+        global $DB;
+
+        if (empty($this->users)) {
+            return;
+        }
+        // Get the list of forum subscriptions for per-user per-forum maildigest settings.
+        list($in, $params) = $DB->get_in_or_equal(array_keys($this->users));
+        $digestspreferences = $DB->get_recordset_select(
+                'forum_digests', "userid $in", $params, '', 'id, userid, forum, maildigest');
+        foreach ($digestspreferences as $digestpreference) {
+            if (!isset($this->digestusers[$digestpreference->forum])) {
+                $this->digestusers[$digestpreference->forum] = [];
+            }
+            $this->digestusers[$digestpreference->forum][$digestpreference->userid] = $digestpreference->maildigest;
+        }
+        $digestspreferences->close();
+    }
+
+    /**
+     * Add dsta for the current forum post to the structure of adhoc data.
+     *
+     * @param   \stdClass   $post
+     */
+    protected function add_data_for_post($post) {
+        if (!isset($this->adhocdata[$post->course])) {
+            $this->adhocdata[$post->course] = [];
+        }
+
+        if (!isset($this->adhocdata[$post->course][$post->forum])) {
+            $this->adhocdata[$post->course][$post->forum] = [];
+        }
+
+        if (!isset($this->adhocdata[$post->course][$post->forum][$post->discussion])) {
+            $this->adhocdata[$post->course][$post->forum][$post->discussion] = [];
+        }
+
+        $this->adhocdata[$post->course][$post->forum][$post->discussion][$post->id] = $post->id;
+    }
+
+    /**
+     * Fill the cache of user subscriptions.
+     */
+    protected function fill_user_subscription_cache() {
+        foreach ($this->forums as $forum) {
+            $cm = get_fast_modinfo($this->courses[$forum->course])->instances['forum'][$forum->id];
+            $modcontext = \context_module::instance($cm->id);
+
+            $this->subscribedusers[$forum->id] = [];
+            if ($users = \mod_forum\subscriptions::fetch_subscribed_users($forum, 0, $modcontext, 'u.id, u.maildigest', true)) {
+                foreach ($users as $user) {
+                    // This user is subscribed to this forum.
+                    $this->subscribedusers[$forum->id][$user->id] = $user->id;
+                    if (!isset($this->users[$user->id])) {
+                        // Store minimal user info.
+                        $this->users[$user->id] = $user;
+                    }
+                }
+                // Release memory.
+                unset($users);
+            }
+        }
+    }
+
+    /**
+     * Queue the user tasks.
+     */
+    protected function queue_user_tasks() {
+        global $CFG, $DB;
+
+        $timenow = time();
+        $sitetimezone = \core_date::get_server_timezone();
+        $counts = [
+            'digests' => 0,
+            'individuals' => 0,
+            'users' => 0,
+            'ignored' => 0,
+            'messages' => 0,
+        ];
+        $this->log("Processing " . count($this->users) . " users", 1);
+        foreach ($this->users as $user) {
+            $usercounts = [
+                'digests' => 0,
+                'messages' => 0,
+            ];
+
+            $send = false;
+            // Setup this user so that the capabilities are cached, and environment matches receiving user.
+            cron_setup_user($user);
+
+            list($individualpostdata, $digestpostdata) = $this->fetch_posts_for_user($user);
+
+            if (!empty($digestpostdata)) {
+                // Insert all of the records for the digest.
+                $DB->insert_records('forum_queue', $digestpostdata);
+                $digesttime = usergetmidnight($timenow, $sitetimezone) + ($CFG->digestmailtime * 3600);
+
+                $task = new \mod_forum\task\send_user_digests();
+                $task->set_userid($user->id);
+                $task->set_component('mod_forum');
+                $task->set_next_run_time($digesttime);
+                \core\task\manager::reschedule_or_queue_adhoc_task($task);
+                $usercounts['digests']++;
+                $send = true;
+            }
+
+            if (!empty($individualpostdata)) {
+                $usercounts['messages'] += count($individualpostdata);
+
+                $task = new \mod_forum\task\send_user_notifications();
+                $task->set_userid($user->id);
+                $task->set_custom_data($individualpostdata);
+                $task->set_component('mod_forum');
+                \core\task\manager::queue_adhoc_task($task);
+                $counts['individuals']++;
+                $send = true;
+            }
+
+            if ($send) {
+                $counts['users']++;
+                $counts['messages'] += $usercounts['messages'];
+                $counts['digests'] += $usercounts['digests'];
+            } else {
+                $counts['ignored']++;
+            }
+
+            $this->log(sprintf("Queued %d digests and %d messages for %s",
+                    $usercounts['digests'],
+                    $usercounts['messages'],
+                    $user->id
+                ), 2);
+        }
+        $this->log(
+            sprintf(
+                "Queued %d digests, and %d individual tasks for %d post mails. " .
+                "Unique users: %d (%d ignored)",
+                $counts['digests'],
+                $counts['individuals'],
+                $counts['messages'],
+                $counts['users'],
+                $counts['ignored']
+            ), 1);
+    }
+
+    /**
+     * Fetch posts for this user.
+     *
+     * @param   \stdClass   $user The user to fetch posts for.
+     */
+    protected function fetch_posts_for_user($user) {
+        // We maintain a mapping of user groups for each forum.
+        $usergroups = [];
+        $digeststructure = [];
+
+        $poststructure = $this->adhocdata;
+        $poststosend = [];
+        foreach ($poststructure as $courseid => $forumids) {
+            $course = $this->courses[$courseid];
+            foreach ($forumids as $forumid => $discussionids) {
+                $forum = $this->forums[$forumid];
+                $maildigest = forum_get_user_maildigest_bulk($this->digestusers, $user, $forumid);
+
+                if (!isset($this->subscribedusers[$forumid][$user->id])) {
+                    // This user has no subscription of any kind to this forum.
+                    // Do not send them any posts at all.
+                    unset($poststructure[$courseid][$forumid]);
+                    continue;
+                }
+
+                $subscriptiontime = \mod_forum\subscriptions::fetch_discussion_subscription($forum->id, $user->id);
+
+                $cm = get_fast_modinfo($course)->instances['forum'][$forumid];
+                foreach ($discussionids as $discussionid => $postids) {
+                    $discussion = $this->discussions[$discussionid];
+                    if (!\mod_forum\subscriptions::is_subscribed($user->id, $forum, $discussionid, $cm)) {
+                        // The user does not subscribe to this forum as a whole, or to this specific discussion.
+                        unset($poststructure[$courseid][$forumid][$discussionid]);
+                        continue;
+                    }
+
+                    if ($discussion->groupid > 0 and $groupmode = groups_get_activity_groupmode($cm, $course)) {
+                        // This discussion has a groupmode set (SEPARATEGROUPS or VISIBLEGROUPS).
+                        // Check whether the user can view it based on their groups.
+                        if (!isset($usergroups[$forum->id])) {
+                            $usergroups[$forum->id] = groups_get_all_groups($courseid, $user->id, $cm->groupingid);
+                        }
+
+                        if (!isset($usergroups[$forum->id][$discussion->groupid])) {
+                            // This user is not a member of this group, or the group no longer exists.
+
+                            $modcontext = \context_module::instance($cm->id);
+                            if (!has_capability('moodle/site:accessallgroups', $modcontext, $user)) {
+                                // This user does not have the accessallgroups and is not a member of the group.
+                                // Do not send posts from other groups when in SEPARATEGROUPS or VISIBLEGROUPS.
+                                unset($poststructure[$courseid][$forumid][$discussionid]);
+                                continue;
+                            }
+                        }
+                    }
+
+                    foreach ($postids as $postid) {
+                        $post = $this->posts[$postid];
+                        if ($subscriptiontime) {
+                            // Skip posts if the user subscribed to the discussion after it was created.
+                            $subscribedafter = isset($subscriptiontime[$post->discussion]);
+                            $subscribedafter = $subscribedafter && ($subscriptiontime[$post->discussion] > $post->created);
+                            if ($subscribedafter) {
+                                // The user subscribed to the discussion/forum after this post was created.
+                                unset($poststructure[$courseid][$forumid][$discussionid][$postid]);
+                                continue;
+                            }
+                        }
+
+                        if ($maildigest > 0) {
+                            // This user wants the mails to be in digest form.
+                            $digeststructure[] = (object) [
+                                'userid' => $user->id,
+                                'discussionid' => $discussion->id,
+                                'postid' => $post->id,
+                                'timemodified' => $post->created,
+                            ];
+                            unset($poststructure[$courseid][$forumid][$discussionid][$postid]);
+                            continue;
+                        } else {
+                            // Add this post to the list of postids to be sent.
+                            $poststosend[] = $postid;
+                        }
+                    }
+                }
+
+                if (empty($poststructure[$courseid][$forumid])) {
+                    // This user is not subscribed to any discussions in this forum at all.
+                    unset($poststructure[$courseid][$forumid]);
+                    continue;
+                }
+            }
+            if (empty($poststructure[$courseid])) {
+                // This user is not subscribed to any forums in this course.
+                unset($poststructure[$courseid]);
+            }
+        }
+
+        return [$poststosend, $digeststructure];
     }
 
+    /**
+     * Returns a list of all new posts that have not been mailed yet
+     *
+     * @param int $starttime posts created after this time
+     * @param int $endtime posts created before this
+     * @param int $now used for timed discussions only
+     * @return array
+     */
+    protected function get_unmailed_posts($starttime, $endtime, $now = null) {
+        global $CFG, $DB;
+
+        $params = array();
+        $params['mailed'] = FORUM_MAILED_PENDING;
+        $params['ptimestart'] = $starttime;
+        $params['ptimeend'] = $endtime;
+        $params['mailnow'] = 1;
+
+        if (!empty($CFG->forum_enabletimedposts)) {
+            if (empty($now)) {
+                $now = time();
+            }
+            $selectsql = "AND (p.created >= :ptimestart OR d.timestart >= :pptimestart)";
+            $params['pptimestart'] = $starttime;
+            $timedsql = "AND (d.timestart < :dtimestart AND (d.timeend = 0 OR d.timeend > :dtimeend))";
+            $params['dtimestart'] = $now;
+            $params['dtimeend'] = $now;
+        } else {
+            $timedsql = "";
+            $selectsql = "AND p.created >= :ptimestart";
+        }
+
+        return $DB->get_records_sql(
+               "SELECT
+                    p.id,
+                    p.discussion,
+                    d.forum,
+                    d.course,
+                    p.created,
+                    p.parent,
+                    p.userid
+                  FROM {forum_posts} p
+                  JOIN {forum_discussions} d ON d.id = p.discussion
+                 WHERE p.mailed = :mailed
+                $selectsql
+                   AND (p.created < :ptimeend OR p.mailnow = :mailnow)
+                $timedsql
+                 ORDER BY p.modified ASC",
+             $params);
+    }
 }
diff --git a/mod/forum/classes/task/send_user_digests.php b/mod/forum/classes/task/send_user_digests.php
new file mode 100644 (file)
index 0000000..148afc5
--- /dev/null
@@ -0,0 +1,617 @@
+<?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/>.
+
+/**
+ * This file defines an adhoc task to send notifications.
+ *
+ * @package    mod_forum
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\task;
+
+defined('MOODLE_INTERNAL') || die();
+
+use html_writer;
+require_once($CFG->dirroot . '/mod/forum/lib.php');
+
+/**
+ * Adhoc task to send moodle forum digests for the specified user.
+ *
+ * @package    mod_forum
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class send_user_digests extends \core\task\adhoc_task {
+
+    // Use the logging trait to get some nice, juicy, logging.
+    use \core\task\logging_trait;
+
+    /**
+     * @var \stdClass   A shortcut to $USER.
+     */
+    protected $recipient;
+
+    /**
+     * @var bool[]  Whether the user can view fullnames for each forum.
+     */
+    protected $viewfullnames = [];
+
+    /**
+     * @var bool[]  Whether the user can post in each forum.
+     */
+    protected $canpostto = [];
+
+    /**
+     * @var \stdClass[] Courses with posts them.
+     */
+    protected $courses = [];
+
+    /**
+     * @var \stdClass[] Forums with posts them.
+     */
+    protected $forums = [];
+
+    /**
+     * @var \stdClass[] Discussions with posts them.
+     */
+    protected $discussions = [];
+
+    /**
+     * @var \stdClass[] The posts to be sent.
+     */
+    protected $posts = [];
+
+    /**
+     * @var \stdClass[] The various authors.
+     */
+    protected $users = [];
+
+    /**
+     * @var \stdClass[] A list of any per-forum digest preference that this user holds.
+     */
+    protected $forumdigesttypes = [];
+
+    /**
+     * @var bool    Whether the user has requested HTML or not.
+     */
+    protected $allowhtml = true;
+
+    /**
+     * @var string  The subject of the message.
+     */
+    protected $postsubject = '';
+
+    /**
+     * @var string  The plaintext content of the whole message.
+     */
+    protected $notificationtext = '';
+
+    /**
+     * @var string  The HTML content of the whole message.
+     */
+    protected $notificationhtml = '';
+
+    /**
+     * @var string  The plaintext content for the current discussion being processed.
+     */
+    protected $discussiontext = '';
+
+    /**
+     * @var string  The HTML content for the current discussion being processed.
+     */
+    protected $discussionhtml = '';
+
+    /**
+     * @var int     The number of messages sent in this digest.
+     */
+    protected $sentcount = 0;
+
+    /**
+     * @var \renderer[][] A cache of the different types of renderer, stored both by target (HTML, or Text), and type.
+     */
+    protected $renderers = [
+        'html' => [],
+        'text' => [],
+    ];
+
+    /**
+     * @var int[] A list of post IDs to be marked as read for this user.
+     */
+    protected $markpostsasread = [];
+
+    /**
+     * Send out messages.
+     */
+    public function execute() {
+        $starttime = time();
+
+        $this->recipient = \core_user::get_user($this->get_userid());
+        $this->log_start("Sending forum digests for {$this->recipient->username} ({$this->recipient->id})");
+
+        if (empty($this->recipient->mailformat) || $this->recipient->mailformat != 1) {
+            // This user does not want to receive HTML.
+            $this->allowhtml = false;
+        }
+
+        // Fetch all of the data we need to mail these posts.
+        $this->prepare_data($starttime);
+
+        if (empty($this->posts) || empty($this->discussions) || empty($this->forums)) {
+            $this->log_finish("No messages found to send.");
+            return;
+        }
+
+        // Add the message headers.
+        $this->add_message_header();
+
+        foreach ($this->discussions as $discussion) {
+            // Raise the time limit for each discussion.
+            \core_php_time_limit::raise(120);
+
+            // Grab the data pertaining to this discussion.
+            $forum = $this->forums[$discussion->forum];
+            $course = $this->courses[$forum->course];
+            $cm = get_fast_modinfo($course)->instances['forum'][$forum->id];
+            $modcontext = \context_module::instance($cm->id);
+            $coursecontext = \context_course::instance($course->id);
+
+            if (empty($this->posts[$discussion->id])) {
+                // Somehow there are no posts.
+                // This should not happen but better safe than sorry.
+                continue;
+            }
+
+            if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
+                // The course is hidden and the user does not have access to it.
+                // Permissions may have changed since it was queued.
+                continue;
+            }
+
+            if (!forum_user_can_see_discussion($forum, $discussion, $modcontext, $this->recipient)) {
+                // User cannot see this discussion.
+                // Permissions may have changed since it was queued.
+                continue;
+            }
+
+            if (!\mod_forum\subscriptions::is_subscribed($this->recipient->id, $forum, $discussion->id, $cm)) {
+                // The user does not subscribe to this forum as a whole, or to this specific discussion.
+                continue;
+            }
+
+            // Fetch additional values relating to this forum.
+            if (!isset($this->canpostto[$discussion->id])) {
+                $this->canpostto[$discussion->id] = forum_user_can_post(
+                        $forum, $discussion, $this->recipient, $cm, $course, $modcontext);
+            }
+
+            if (!isset($this->viewfullnames[$forum->id])) {
+                $this->viewfullnames = has_capability('moodle/site:viewfullnames', $modcontext, $this->recipient->id);
+            }
+
+            // Set the discussion storage values.
+            $discussionpostcount = 0;
+            $this->discussiontext = '';
+            $this->discussionhtml = '';
+
+            // Add the header for this discussion.
+            $this->add_discussion_header($discussion, $forum, $course);
+            $this->log_start("Adding messages in discussion {$discussion->id} (forum {$forum->id})", 1);
+
+            // Add all posts in this forum.
+            foreach ($this->posts[$discussion->id] as $post) {
+                $author = $this->get_post_author($post->userid, $course, $forum, $cm, $modcontext);
+                if (empty($author)) {
+                    // Unable to find the author. Skip to avoid errors.
+                    continue;
+                }
+
+                if (!forum_user_can_see_post($forum, $discussion, $post, $this->recipient, $cm)) {
+                    // User cannot see this post.
+                    // Permissions may have changed since it was queued.
+                    continue;
+                }
+
+                $this->add_post_body($author, $post, $discussion, $forum, $cm, $course);
+                $discussionpostcount++;
+            }
+
+            // Add the forum footer.
+            $this->add_discussion_footer($discussion, $forum, $course);
+
+            // Add the data for this discussion to the notification body.
+            if ($discussionpostcount) {
+                $this->sentcount += $discussionpostcount;
+                $this->notificationtext .= $this->discussiontext;
+                $this->notificationhtml .= $this->discussionhtml;
+                $this->log_finish("Added {$discussionpostcount} messages to discussion {$discussion->id}", 1);
+            } else {
+                $this->log_finish("No messages found in discussion {$discussion->id} - skipped.", 1);
+            }
+        }
+
+        if ($this->sentcount) {
+            // This digest has at least one post and should therefore be sent.
+            if ($this->send_mail()) {
+                $this->log_finish("Digest sent with {$this->sentcount} messages.");
+                if (get_user_preferences('forum_markasreadonnotification', 1, $this->recipient->id) == 1) {
+                    forum_tp_mark_posts_read($this->recipient, $this->markpostsasread);
+                }
+            } else {
+                $this->log_finish("Issue sending digest. Skipping.");
+            }
+        } else {
+            $this->log_finish("No messages found to send.");
+        }
+
+        // We have finishied all digest emails, update $CFG->digestmailtimelast.
+        set_config('digestmailtimelast', $starttime);
+    }
+
+    /**
+     * Prepare the data for this run.
+     *
+     * Note: This will also remove posts from the queue.
+     *
+     * @param   int     $timenow
+     */
+    protected function prepare_data(int $timenow) {
+        global $DB;
+
+        $sql = "SELECT p.*, f.id AS forum, f.course
+                  FROM {forum_queue} q
+            INNER JOIN {forum_posts} p ON p.id = q.postid
+            INNER JOIN {forum_discussions} d ON d.id = p.discussion
+            INNER JOIN {forum} f ON f.id = d.forum
+                 WHERE q.userid = :userid
+                   AND q.timemodified < :timemodified
+              ORDER BY d.id, q.timemodified ASC";
+
+        $queueparams = [
+                'userid' => $this->recipient->id,
+                'timemodified' => $timenow,
+            ];
+
+        $posts = $DB->get_recordset_sql($sql, $queueparams);
+        $discussionids = [];
+        $forumids = [];
+        $courseids = [];
+        $userids = [];
+        foreach ($posts as $post) {
+            $discussionids[] = $post->discussion;
+            $forumids[] = $post->forum;
+            $courseids[] = $post->course;
+            $userids[] = $post->userid;
+            unset($post->forum);
+            if (!isset($this->posts[$post->discussion])) {
+                $this->posts[$post->discussion] = [];
+            }
+            $this->posts[$post->discussion][$post->id] = $post;
+        }
+        $posts->close();
+
+        if (empty($discussionids)) {
+            // All posts have been removed since the task was queued.
+            $this->empty_queue($this->recipient->id, $timenow);
+            return;
+        }
+
+        list($in, $params) = $DB->get_in_or_equal($discussionids);
+        $this->discussions = $DB->get_records_select('forum_discussions', "id {$in}", $params);
+
+        list($in, $params) = $DB->get_in_or_equal($forumids);
+        $this->forums = $DB->get_records_select('forum', "id {$in}", $params);
+
+        list($in, $params) = $DB->get_in_or_equal($courseids);
+        $this->courses = $DB->get_records_select('course', "id $in", $params);
+
+        list($in, $params) = $DB->get_in_or_equal($userids);
+        $this->users = $DB->get_records_select('user', "id $in", $params);
+
+        $this->fill_digest_cache();
+
+        $this->empty_queue($this->recipient->id, $timenow);
+    }
+
+    /**
+     * Empty the queue of posts for this user.
+     *
+     * @param int $userid user id which queue elements are going to be removed.
+     * @param int $timemodified up time limit of the queue elements to be removed.
+     */
+    protected function empty_queue(int $userid, int $timemodified) : void {
+        global $DB;
+
+        $DB->delete_records_select('forum_queue', "userid = :userid AND timemodified < :timemodified", [
+                'userid' => $userid,
+                'timemodified' => $timemodified,
+            ]);
+    }
+
+    /**
+     * Fill the cron digest cache.
+     */
+    protected function fill_digest_cache() {
+        global $DB;
+
+        $this->forumdigesttypes = $DB->get_records_menu('forum_digests', [
+                'userid' => $this->recipient->id,
+            ], '', 'forum, maildigest');
+    }
+
+    /**
+     * Fetch and initialise the post author.
+     *
+     * @param   int         $userid The id of the user to fetch
+     * @param   \stdClass   $course
+     * @param   \stdClass   $forum
+     * @param   \stdClass   $cm
+     * @param   \context    $context
+     * @return  \stdClass
+     */
+    protected function get_post_author($userid, $course, $forum, $cm, $context) {
+        if (!isset($this->users[$userid])) {
+            // This user no longer exists.
+            return false;
+        }
+
+        $user = $this->users[$userid];
+
+        if (!isset($user->groups)) {
+            // Initialise the groups list.
+            $user->groups = [];
+        }
+
+        if (!isset($user->groups[$forum->id])) {
+            $user->groups[$forum->id] = groups_get_all_groups($course->id, $user->id, $cm->groupingid);
+        }
+
+        // Clone the user object to prevent leaks between messages.
+        return (object) (array) $user;
+    }
+
+    /**
+     * Add the header to this message.
+     */
+    protected function add_message_header() {
+        $site = get_site();
+
+        // Set the subject of the message.
+        $this->postsubject = get_string('digestmailsubject', 'forum', format_string($site->shortname, true));
+
+        // And the content of the header in body.
+        $headerdata = (object) [
+            'sitename' => format_string($site->fullname, true),
+            'userprefs' => (new \moodle_url('/user/forum.php', [
+                    'id' => $this->recipient->id,
+                    'course' => $site->id,
+                ]))->out(false),
+            ];
+
+        $this->notificationtext .= get_string('digestmailheader', 'forum', $headerdata) . "\n";
+
+        if ($this->allowhtml) {
+            $headerdata->userprefs = html_writer::link($headerdata->userprefs, get_string('digestmailprefs', 'forum'), [
+                    'target' => '_blank',
+                ]);
+
+            $this->notificationhtml .= html_writer::tag('p', get_string('digestmailheader', 'forum', $headerdata));
+            $this->notificationhtml .= html_writer::empty_tag('br');
+            $this->notificationhtml .= html_writer::empty_tag('hr', [
+                    'size' => 1,
+                    'noshade' => 'noshade',
+                ]);
+        }
+    }
+
+    /**
+     * Add the header for this discussion.
+     *
+     * @param   \stdClass   $discussion The discussion to add the footer for
+     * @param   \stdClass   $forum The forum that the discussion belongs to
+     * @param   \stdClass   $course The course that the forum belongs to
+     */
+    protected function add_discussion_header($discussion, $forum, $course) {
+        global $CFG;
+
+        $shortname = format_string($course->shortname, true, [
+                'context' => \context_course::instance($course->id),
+            ]);
+
+        $strforums = get_string('forums', 'forum');
+
+        $this->discussiontext .= "\n=====================================================================\n\n";
+        $this->discussiontext .= "$shortname -> $strforums -> " . format_string($forum->name, true);
+        if ($discussion->name != $forum->name) {
+            $this->discussiontext  .= " -> " . format_string($discussion->name, true);
+        }
+        $this->discussiontext .= "\n";
+        $this->discussiontext .= new \moodle_url('/mod/forum/discuss.php', [
+                'd' => $discussion->id,
+            ]);
+        $this->discussiontext .= "\n";
+
+        if ($this->allowhtml) {
+            $this->discussionhtml .= "<p><font face=\"sans-serif\">".
+                "<a target=\"_blank\" href=\"$CFG->wwwroot/course/view.php?id=$course->id\">$shortname</a> -> ".
+                "<a target=\"_blank\" href=\"$CFG->wwwroot/mod/forum/index.php?id=$course->id\">$strforums</a> -> ".
+                "<a target=\"_blank\" href=\"$CFG->wwwroot/mod/forum/view.php?f=$forum->id\">" .
+                        format_string($forum->name, true)."</a>";
+            if ($discussion->name == $forum->name) {
+                $this->discussionhtml .= "</font></p>";
+            } else {
+                $this->discussionhtml .=
+                        " -> <a target=\"_blank\" href=\"$CFG->wwwroot/mod/forum/discuss.php?d=$discussion->id\">" .
+                        format_string($discussion->name, true)."</a></font></p>";
+            }
+            $this->discussionhtml .= '<p>';
+        }
+
+    }
+
+    /**
+     * Add the body of this post.
+     *
+     * @param   \stdClass   $author The author of the post
+     * @param   \stdClass   $post The post being sent
+     * @param   \stdClass   $discussion The discussion that the post is in
+     * @param   \stdClass   $forum The forum that the discussion belongs to
+     * @param   \cminfo     $cm The cminfo object for the forum
+     * @param   \stdClass   $course The course that the forum belongs to
+     */
+    protected function add_post_body($author, $post, $discussion, $forum, $cm, $course) {
+        global $CFG;
+
+        $canreply = $this->canpostto[$discussion->id];
+
+        $data = new \mod_forum\output\forum_post_email(
+            $course,
+            $cm,
+            $forum,
+            $discussion,
+            $post,
+            $author,
+            $this->recipient,
+            $canreply
+        );
+
+        // Override the viewfullnames value.
+        $data->viewfullnames = $this->viewfullnames[$forum->id];
+
+        // Determine the type of digest being sent.
+        $maildigest = $this->get_maildigest($forum->id);
+
+        $textrenderer = $this->get_renderer($maildigest);
+        $this->discussiontext .= $textrenderer->render($data);
+        $this->discussiontext .= "\n";
+        if ($this->allowhtml) {
+            $htmlrenderer = $this->get_renderer($maildigest, true);
+            $this->discussionhtml .= $htmlrenderer->render($data);
+            $this->log("Adding post {$post->id} in format {$maildigest} with HTML", 2);
+        } else {
+            $this->log("Adding post {$post->id} in format {$maildigest} without HTML", 2);
+        }
+
+        if ($maildigest == 1 && $CFG->forum_usermarksread) {
+            // Create an array of postid's for this user to mark as read.
+            $this->markpostsasread[] = $post->id;
+        }
+
+    }
+
+    /**
+     * Add the footer for this discussion.
+     *
+     * @param   \stdClass   $discussion The discussion to add the footer for
+     */
+    protected function add_discussion_footer($discussion) {
+        global $CFG;
+
+        if ($this->allowhtml) {
+            $footerlinks = [];
+
+            $forum = $this->forums[$discussion->forum];
+            if (\mod_forum\subscriptions::is_forcesubscribed($forum)) {
+                // This forum is force subscribed. The user cannot unsubscribe.
+                $footerlinks[] = get_string("everyoneissubscribed", "forum");
+            } else {
+                $footerlinks[] = "<a href=\"$CFG->wwwroot/mod/forum/subscribe.php?id=$forum->id\">" .
+                    get_string("unsubscribe", "forum") . "</a>";
+            }
+            $footerlinks[] = "<a href='{$CFG->wwwroot}/mod/forum/index.php?id={$forum->course}'>" .
+                    get_string("digestmailpost", "forum") . '</a>';
+
+            $this->discussionhtml .= "\n<div class='mdl-right'><font size=\"1\">" .
+                    implode('&nbsp;', $footerlinks) . '</font></div>';
+            $this->discussionhtml .= '<hr size="1" noshade="noshade" /></p>';
+        }
+    }
+
+    /**
+     * Get the forum digest type for the specified forum, failing back to
+     * the default setting for the current user if not specified.
+     *
+     * @param   int     $forumid
+     * @return  int
+     */
+    protected function get_maildigest($forumid) {
+        $maildigest = -1;
+
+        if (isset($this->forumdigesttypes[$forumid])) {
+            $maildigest = $this->forumdigesttypes[$forumid];
+        }
+
+        if ($maildigest === -1 && !empty($this->recipient->maildigest)) {
+            $maildigest = $this->recipient->maildigest;
+        }
+
+        if ($maildigest === -1) {
+            // There is no maildigest type right now.
+            $maildigest = 1;
+        }
+
+        return $maildigest;
+    }
+
+    /**
+     * Send the composed message to the user.
+     */
+    protected function send_mail() {
+        // Headers to help prevent auto-responders.
+        $userfrom = \core_user::get_noreply_user();
+        $userfrom->customheaders = array(
+            "Precedence: Bulk",
+            'X-Auto-Response-Suppress: All',
+            'Auto-Submitted: auto-generated',
+        );
+
+        $eventdata = new \core\message\message();
+        $eventdata->courseid = SITEID;
+        $eventdata->component = 'mod_forum';
+        $eventdata->name = 'digests';
+        $eventdata->userfrom = $userfrom;
+        $eventdata->userto = $this->recipient;
+        $eventdata->subject = $this->postsubject;
+        $eventdata->fullmessage = $this->notificationtext;
+        $eventdata->fullmessageformat = FORMAT_PLAIN;
+        $eventdata->fullmessagehtml = $this->notificationhtml;
+        $eventdata->notification = 1;
+        $eventdata->smallmessage = get_string('smallmessagedigest', 'forum', $this->sentcount);
+
+        return message_send($eventdata);
+    }
+
+    /**
+     * Helper to fetch the required renderer, instantiating as required.
+     *
+     * @param   int     $maildigest The type of mail digest being sent
+     * @param   bool    $html Whether to fetch the HTML renderer
+     * @return  \core_renderer
+     */
+    protected function get_renderer($maildigest, $html = false) {
+        global $PAGE;
+
+        $type = $maildigest == 2 ? 'emaildigestbasic' : 'emaildigestfull';
+        $target = $html ? 'htmlemail' : 'textemail';
+
+        if (!isset($this->renderers[$target][$type])) {
+            $this->renderers[$target][$type] = $PAGE->get_renderer('mod_forum', $type, $target);
+        }
+
+        return $this->renderers[$target][$type];
+    }
+}
diff --git a/mod/forum/classes/task/send_user_notifications.php b/mod/forum/classes/task/send_user_notifications.php
new file mode 100644 (file)
index 0000000..acc4034
--- /dev/null
@@ -0,0 +1,542 @@
+<?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/>.
+
+/**
+ * This file defines an adhoc task to send notifications.
+ *
+ * @package    mod_forum
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\task;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Adhoc task to send user forum notifications.
+ *
+ * @package    mod_forum
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class send_user_notifications extends \core\task\adhoc_task {
+
+    // Use the logging trait to get some nice, juicy, logging.
+    use \core\task\logging_trait;
+
+    /**
+     * @var \stdClass   A shortcut to $USER.
+     */
+    protected $recipient;
+
+    /**
+     * @var \stdClass[] List of courses the messages are in, indexed by courseid.
+     */
+    protected $courses = [];
+
+    /**
+     * @var \stdClass[] List of forums the messages are in, indexed by courseid.
+     */
+    protected $forums = [];
+
+    /**
+     * @var int[] List of IDs for forums in each course.
+     */
+    protected $courseforums = [];
+
+    /**
+     * @var \stdClass[] List of discussions the messages are in, indexed by forumid.
+     */
+    protected $discussions = [];
+
+    /**
+     * @var \stdClass[] List of IDs for discussions in each forum.
+     */
+    protected $forumdiscussions = [];
+
+    /**
+     * @var \stdClass[] List of posts the messages are in, indexed by discussionid.
+     */
+    protected $posts = [];
+
+    /**
+     * @var bool[] Whether the user can view fullnames for each forum.
+     */
+    protected $viewfullnames = [];
+
+    /**
+     * @var bool[] Whether the user can post in each discussion.
+     */
+    protected $canpostto = [];
+
+    /**
+     * @var \renderer[] The renderers.
+     */
+    protected $renderers = [];
+
+    /**
+     * @var \core\message\inbound\address_manager The inbound message address manager.
+     */
+    protected $inboundmanager;
+
+    /**
+     * Send out messages.
+     */
+    public function execute() {
+        global $CFG;
+
+        // Raise the time limit for each discussion.
+        \core_php_time_limit::raise(120);
+
+        $this->recipient = \core_user::get_user($this->get_userid());
+
+        // Create the generic messageinboundgenerator.
+        $this->inboundmanager = new \core\message\inbound\address_manager();
+        $this->inboundmanager->set_handler('\mod_forum\message\inbound\reply_handler');
+
+        $data = $this->get_custom_data();
+
+        $this->prepare_data((array) $data);
+
+        $markposts = [];
+        $errorcount = 0;
+        $sentcount = 0;
+        $this->log_start("Sending messages to {$this->recipient->username} ({$this->recipient->id})");
+        foreach ($this->courses as $course) {
+            $coursecontext = \context_course::instance($course->id);
+            if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
+                // The course is hidden and the user does not have access to it.
+                // Permissions may have changed since it was queued.
+                continue;
+            }
+            foreach ($this->courseforums[$course->id] as $forumid) {
+                $forum = $this->forums[$forumid];
+
+                $cm = get_fast_modinfo($course)->instances['forum'][$forumid];
+                $modcontext = \context_module::instance($cm->id);
+
+                foreach (array_values($this->forumdiscussions[$forumid]) as $discussionid) {
+                    $discussion = $this->discussions[$discussionid];
+
+                    if (!forum_user_can_see_discussion($forum, $discussion, $modcontext, $this->recipient)) {
+                        // User cannot see this discussion.
+                        // Permissions may have changed since it was queued.
+                        continue;
+                    }
+
+                    if (!\mod_forum\subscriptions::is_subscribed($this->recipient->id, $forum, $discussionid, $cm)) {
+                        // The user does not subscribe to this forum as a whole, or to this specific discussion.
+                        continue;
+                    }
+
+                    foreach ($this->posts[$discussionid] as $post) {
+                        if (!forum_user_can_see_post($forum, $discussion, $post, $this->recipient, $cm)) {
+                            // User cannot see this post.
+                            // Permissions may have changed since it was queued.
+                            continue;
+                        }
+
+                        if ($this->send_post($course, $forum, $discussion, $post, $cm, $modcontext)) {
+                            $this->log("Post {$post->id} sent", 1);
+                            // Mark post as read if forum_usermarksread is set off.
+                            if (!$CFG->forum_usermarksread) {
+                                $markposts[$post->id] = true;
+                            }
+                            $sentcount++;
+                        } else {
+                            $this->log("Failed to send post {$post->id}", 1);
+                            $errorcount++;
+                        }
+                    }
+                }
+            }
+        }
+
+        $this->log_finish("Sent {$sentcount} messages with {$errorcount} failures");
+        if (!empty($markposts)) {
+            if (get_user_preferences('forum_markasreadonnotification', 1, $this->recipient->id) == 1) {
+                $this->log_start("Marking posts as read");
+                $count = count($markposts);
+                forum_tp_mark_posts_read($this->recipient, array_keys($markposts));
+                $this->log_finish("Marked {$count} posts as read");
+            }
+        }
+    }
+
+    /**
+     * Prepare all data for this run.
+     *
+     * Take all post ids, and fetch the relevant authors, discussions, forums, and courses for them.
+     *
+     * @param   int[]   $postids The list of post IDs
+     */
+    protected function prepare_data(array $postids) {
+        global $DB;
+
+        if (empty($postids)) {
+            return;
+        }
+
+        list($in, $params) = $DB->get_in_or_equal(array_values($postids));
+        $sql = "SELECT p.*, f.id AS forum, f.course
+                  FROM {forum_posts} p
+            INNER JOIN {forum_discussions} d ON d.id = p.discussion
+            INNER JOIN {forum} f ON f.id = d.forum
+                 WHERE p.id {$in}";
+
+        $posts = $DB->get_recordset_sql($sql, $params);
+        $discussionids = [];
+        $forumids = [];
+        $courseids = [];
+        $userids = [];
+        foreach ($posts as $post) {
+            $discussionids[] = $post->discussion;
+            $forumids[] = $post->forum;
+            $courseids[] = $post->course;
+            $userids[] = $post->userid;
+            unset($post->forum);
+            if (!isset($this->posts[$post->discussion])) {
+                $this->posts[$post->discussion] = [];
+            }
+            $this->posts[$post->discussion][$post->id] = $post;
+        }
+        $posts->close();
+
+        if (empty($discussionids)) {
+            // All posts have been removed since the task was queued.
+            return;
+        }
+
+        // Fetch all discussions.
+        list($in, $params) = $DB->get_in_or_equal(array_values($discussionids));
+        $this->discussions = $DB->get_records_select('forum_discussions', "id {$in}", $params);
+        foreach ($this->discussions as $discussion) {
+            if (empty($this->forumdiscussions[$discussion->forum])) {
+                $this->forumdiscussions[$discussion->forum] = [];
+            }
+            $this->forumdiscussions[$discussion->forum][] = $discussion->id;
+        }
+
+        // Fetch all forums.
+        list($in, $params) = $DB->get_in_or_equal(array_values($forumids));
+        $this->forums = $DB->get_records_select('forum', "id {$in}", $params);
+        foreach ($this->forums as $forum) {
+            if (empty($this->courseforums[$forum->course])) {
+                $this->courseforums[$forum->course] = [];
+            }
+            $this->courseforums[$forum->course][] = $forum->id;
+        }
+
+        // Fetch all courses.
+        list($in, $params) = $DB->get_in_or_equal(array_values($courseids));
+        $this->courses = $DB->get_records_select('course', "id $in", $params);
+
+        // Fetch all authors.
+        list($in, $params) = $DB->get_in_or_equal(array_values($userids));
+        $users = $DB->get_recordset_select('user', "id $in", $params);
+        foreach ($users as $user) {
+            $this->minimise_user_record($user);
+            $this->users[$user->id] = $user;
+        }
+        $users->close();
+
+        // Fill subscription caches for each forum.
+        // These are per-user.
+        foreach (array_values($forumids) as $id) {
+            \mod_forum\subscriptions::fill_subscription_cache($id);
+            \mod_forum\subscriptions::fill_discussion_subscription_cache($id);
+        }
+    }
+
+    /**
+     * Send the specified post for the current user.
+     *
+     * @param   \stdClass   $course
+     * @param   \stdClass   $forum
+     * @param   \stdClass   $discussion
+     * @param   \stdClass   $post
+     * @param   \stdClass   $cm
+     * @param   \context    $context
+     */
+    protected function send_post($course, $forum, $discussion, $post, $cm, $context) {
+        global $CFG;
+
+        $author = $this->get_post_author($post->userid, $course, $forum, $cm, $context);
+        if (empty($author)) {
+            return false;
+        }
+
+        // Prepare to actually send the post now, and build up the content.
+        $cleanforumname = str_replace('"', "'", strip_tags(format_string($forum->name)));
+
+        $shortname = format_string($course->shortname, true, [
+                'context' => \context_course::instance($course->id),
+            ]);
+
+        // Generate a reply-to address from using the Inbound Message handler.
+        $replyaddress = $this->get_reply_address($course, $forum, $discussion, $post, $cm, $context);
+
+        $data = new \mod_forum\output\forum_post_email(
+            $course,
+            $cm,
+            $forum,
+            $discussion,
+            $post,
+            $author,
+            $this->recipient,
+            $this->can_post($course, $forum, $discussion, $post, $cm, $context)
+        );
+        $data->viewfullnames = $this->can_view_fullnames($course, $forum, $discussion, $post, $cm, $context);
+
+        // Not all of these variables are used in the default string but are made available to support custom subjects.
+        $site = get_site();
+        $a = (object) [
+            'subject' => $data->get_subject(),
+            'forumname' => $cleanforumname,
+            'sitefullname' => format_string($site->fullname),
+            'siteshortname' => format_string($site->shortname),
+            'courseidnumber' => $data->get_courseidnumber(),
+            'coursefullname' => $data->get_coursefullname(),
+            'courseshortname' => $data->get_coursename(),
+        ];
+        $postsubject = html_to_text(get_string('postmailsubject', 'forum', $a), 0);
+
+        // Message headers are stored against the message author.
+        $author->customheaders = $this->get_message_headers($course, $forum, $discussion, $post, $a, $data);
+
+        $eventdata = new \core\message\message();
+        $eventdata->courseid            = $course->id;
+        $eventdata->component           = 'mod_forum';
+        $eventdata->name                = 'posts';
+        $eventdata->userfrom            = $author;
+        $eventdata->userto              = $this->recipient;
+        $eventdata->subject             = $postsubject;
+        $eventdata->fullmessage         = $this->get_renderer()->render($data);
+        $eventdata->fullmessageformat   = FORMAT_PLAIN;
+        $eventdata->fullmessagehtml     = $this->get_renderer(true)->render($data);
+        $eventdata->notification        = 1;
+        $eventdata->replyto             = $replyaddress;
+        if (!empty($replyaddress)) {
+            // Add extra text to email messages if they can reply back.
+            $eventdata->set_additional_content('email', [
+                    'fullmessage' => [
+                        'footer' => "\n\n" . get_string('replytopostbyemail', 'mod_forum'),
+                    ],
+                    'fullmessagehtml' => [
+                        'footer' => \html_writer::tag('p', get_string('replytopostbyemail', 'mod_forum')),
+                    ]
+                ]);
+        }
+
+        $eventdata->smallmessage = get_string('smallmessage', 'forum', (object) [
+                'user' => fullname($author),
+                'forumname' => "$shortname: " . format_string($forum->name, true) . ": " . $discussion->name,
+                'message' => $post->message,
+            ]);
+
+        $contexturl = new \moodle_url('/mod/forum/discuss.php', ['d' => $discussion->id], "p{$post->id}");
+        $eventdata->contexturl = $contexturl->out();
+        $eventdata->contexturlname = $discussion->name;
+
+        return message_send($eventdata);
+    }
+
+    /**
+     * Fetch and initialise the post author.
+     *
+     * @param   int         $userid The id of the user to fetch
+     * @param   \stdClass   $course
+     * @param   \stdClass   $forum
+     * @param   \stdClass   $cm
+     * @param   \context    $context
+     * @return  \stdClass
+     */
+    protected function get_post_author($userid, $course, $forum, $cm, $context) {
+        if (!isset($this->users[$userid])) {
+            // This user no longer exists.
+            return false;
+        }
+
+        $user = $this->users[$userid];
+
+        if (!isset($user->groups)) {
+            // Initialise the groups list.
+            $user->groups = [];
+        }
+
+        if (!isset($user->groups[$forum->id])) {
+            $user->groups[$forum->id] = groups_get_all_groups($course->id, $user->id, $cm->groupingid);
+        }
+
+        // Clone the user object to prevent leaks between messages.
+        return (object) (array) $user;
+    }
+
+    /**
+     * Helper to fetch the required renderer, instantiating as required.
+     *
+     * @param   bool    $html Whether to fetch the HTML renderer
+     * @return  \core_renderer
+     */
+    protected function get_renderer($html = false) {
+        global $PAGE;
+
+        $target = $html ? 'htmlemail' : 'textemail';
+
+        if (!isset($this->renderers[$target])) {
+            $this->renderers[$target] = $PAGE->get_renderer('mod_forum', 'email', $target);
+        }
+
+        return $this->renderers[$target];
+    }
+
+    /**
+     * Get the list of message headers.
+     *
+     * @param   \stdClass   $course
+     * @param   \stdClass   $forum
+     * @param   \stdClass   $discussion
+     * @param   \stdClass   $post
+     * @param   \stdClass   $a The list of strings for this  post
+     * @param   \core\message\message $message The message to be sent
+     * @return  \stdClass
+     */
+    protected function get_message_headers($course, $forum, $discussion, $post, $a, $message) {
+        $cleanforumname = str_replace('"', "'", strip_tags(format_string($forum->name)));
+        $viewurl = new \moodle_url('/mod/forum/view.php', ['f' => $forum->id]);
+
+        $headers = [
+            // Headers to make emails easier to track.
+            'List-Id: "' . $cleanforumname . '" ' . generate_email_messageid('moodleforum' . $forum->id),
+            'List-Help: ' . $viewurl->out(),
+            'Message-ID: ' . forum_get_email_message_id($post->id, $this->recipient->id),
+            'X-Course-Id: ' . $course->id,
+            'X-Course-Name: '. format_string($course->fullname, true),
+
+            // Headers to help prevent auto-responders.
+            'Precedence: Bulk',
+            'X-Auto-Response-Suppress: All',
+            'Auto-Submitted: auto-generated',
+            'List-Unsubscribe: <' . $message->get_unsubscribediscussionlink() . '>',
+        ];
+
+        $rootid = forum_get_email_message_id($discussion->firstpost, $this->recipient->id);
+
+        if ($post->parent) {
+            // This post is a reply, so add reply header (RFC 2822).
+            $parentid = forum_get_email_message_id($post->parent, $this->recipient->id);
+            $headers[] = "In-Reply-To: $parentid";
+
+            // If the post is deeply nested we also reference the parent message id and
+            // the root message id (if different) to aid threading when parts of the email
+            // conversation have been deleted (RFC1036).
+            if ($post->parent != $discussion->firstpost) {
+                $headers[] = "References: $rootid $parentid";
+            } else {
+                $headers[] = "References: $parentid";
+            }
+        }
+
+        // MS Outlook / Office uses poorly documented and non standard headers, including
+        // Thread-Topic which overrides the Subject and shouldn't contain Re: or Fwd: etc.
+        $aclone = (object) (array) $a;
+        $aclone->subject = $discussion->name;
+        $threadtopic = html_to_text(get_string('postmailsubject', 'forum', $aclone), 0);
+        $headers[] = "Thread-Topic: $threadtopic";
+        $headers[] = "Thread-Index: " . substr($rootid, 1, 28);
+
+        return $headers;
+    }
+
+    /**
+     * Get a no-reply address for this user to reply to the current post.
+     *
+     * @param   \stdClass   $course
+     * @param   \stdClass   $forum
+     * @param   \stdClass   $discussion
+     * @param   \stdClass   $post
+     * @param   \stdClass   $cm
+     * @param   \context    $context
+     * @return  string
+     */
+    protected function get_reply_address($course, $forum, $discussion, $post, $cm, $context) {
+        if ($this->can_post($course, $forum, $discussion, $post, $cm, $context)) {
+            // Generate a reply-to address from using the Inbound Message handler.
+            $this->inboundmanager->set_data($post->id);
+            return $this->inboundmanager->generate($this->recipient->id);
+        }
+
+        // TODO Check if we can return a string.
+        // This will be controlled by the event.
+        return null;
+    }
+
+    /**
+     * Check whether the user can post.
+     *
+     * @param   \stdClass   $course
+     * @param   \stdClass   $forum
+     * @param   \stdClass   $discussion
+     * @param   \stdClass   $post
+     * @param   \stdClass   $cm
+     * @param   \context    $context
+     * @return  bool
+     */
+    protected function can_post($course, $forum, $discussion, $post, $cm, $context) {
+        if (!isset($this->canpostto[$discussion->id])) {
+            $this->canpostto[$discussion->id] = forum_user_can_post($forum, $discussion, $this->recipient, $cm, $course, $context);
+        }
+        return $this->canpostto[$discussion->id];
+    }
+
+    /**
+     * Check whether the user can view full names of other users.
+     *
+     * @param   \stdClass   $course
+     * @param   \stdClass   $forum
+     * @param   \stdClass   $discussion
+     * @param   \stdClass   $post
+     * @param   \stdClass   $cm
+     * @param   \context    $context
+     * @return  bool
+     */
+    protected function can_view_fullnames($course, $forum, $discussion, $post, $cm, $context) {
+        if (!isset($this->viewfullnames[$forum->id])) {
+            $this->viewfullnames[$forum->id] = has_capability('moodle/site:viewfullnames', $context, $this->recipient->id);
+        }
+
+        return $this->viewfullnames[$forum->id];
+    }
+
+    /**
+     * Removes properties from user record that are not necessary for sending post notifications.
+     *
+     * @param   \stdClass   $user
+     */
+    protected function minimise_user_record(\stdClass $user) {
+        // We store large amount of users in one huge array, make sure we do not store info there we do not actually
+        // need in mail generation code or messaging.
+        unset($user->institution);
+        unset($user->department);
+        unset($user->address);
+        unset($user->city);
+        unset($user->url);
+        unset($user->currentlogin);
+        unset($user->description);
+        unset($user->descriptionformat);
+    }
+}
index be682bd..2ca2ee7 100644 (file)
@@ -372,3 +372,41 @@ function forum_make_mail_post($course, $cm, $forum, $discussion, $post, $userfro
 
     return $renderer->render($renderable);
 }
+
+/**
+ * Removes properties from user record that are not necessary for sending post notifications.
+ *
+ * @param stdClass $user
+ * @return void, $user parameter is modified
+ * @deprecated since Moodle 3.7
+ */
+function forum_cron_minimise_user_record(stdClass $user) {
+    debugging("forum_cron_minimise_user_record() has been deprecated and has not been replaced.",
+            DEBUG_DEVELOPER);
+
+    // We store large amount of users in one huge array,
+    // make sure we do not store info there we do not actually need
+    // in mail generation code or messaging.
+
+    unset($user->institution);
+    unset($user->department);
+    unset($user->address);
+    unset($user->city);
+    unset($user->url);
+    unset($user->currentlogin);
+    unset($user->description);
+    unset($user->descriptionformat);
+}
+
+/**
+ * Function to be run periodically according to the scheduled task.
+ *
+ * Finds all posts that have yet to be mailed out, and mails them out to all subscribers as well as other maintance
+ * tasks.
+ *
+ * @deprecated since Moodle 3.7
+ */
+function forum_cron() {
+    debugging("forum_cron() has been deprecated and replaced with new tasks. Please uses these instead.",
+            DEBUG_DEVELOPER);
+}
index 50e2866..83a7bc2 100644 (file)
@@ -423,829 +423,6 @@ function forum_get_email_message_id($postid, $usertoid) {
     return generate_email_messageid(hash('sha256', $postid . 'to' . $usertoid));
 }
 
-/**
- * Removes properties from user record that are not necessary
- * for sending post notifications.
- * @param stdClass $user
- * @return void, $user parameter is modified
- */
-function forum_cron_minimise_user_record(stdClass $user) {
-
-    // We store large amount of users in one huge array,
-    // make sure we do not store info there we do not actually need
-    // in mail generation code or messaging.
-
-    unset($user->institution);
-    unset($user->department);
-    unset($user->address);
-    unset($user->city);
-    unset($user->url);
-    unset($user->currentlogin);
-    unset($user->description);
-    unset($user->descriptionformat);
-}
-
-/**
- * Function to be run periodically according to the scheduled task.
- *
- * Finds all posts that have yet to be mailed out, and mails them
- * out to all subscribers as well as other maintance tasks.
- *
- * NOTE: Since 2.7.2 this function is run by scheduled task rather
- * than standard cron.
- *
- * @todo MDL-44734 The function will be split up into seperate tasks.
- */
-function forum_cron() {
-    global $CFG, $USER, $DB, $PAGE;
-
-    $site = get_site();
-
-    // The main renderers.
-    $htmlout = $PAGE->get_renderer('mod_forum', 'email', 'htmlemail');
-    $textout = $PAGE->get_renderer('mod_forum', 'email', 'textemail');
-    $htmldigestfullout = $PAGE->get_renderer('mod_forum', 'emaildigestfull', 'htmlemail');
-    $textdigestfullout = $PAGE->get_renderer('mod_forum', 'emaildigestfull', 'textemail');
-    $htmldigestbasicout = $PAGE->get_renderer('mod_forum', 'emaildigestbasic', 'htmlemail');
-    $textdigestbasicout = $PAGE->get_renderer('mod_forum', 'emaildigestbasic', 'textemail');
-
-    // All users that are subscribed to any post that needs sending,
-    // please increase $CFG->extramemorylimit on large sites that
-    // send notifications to a large number of users.
-    $users = array();
-    $userscount = 0; // Cached user counter - count($users) in PHP is horribly slow!!!
-
-    // Status arrays.
-    $mailcount  = array();
-    $errorcount = array();
-
-    // caches
-    $discussions        = array();
-    $forums             = array();
-    $courses            = array();
-    $coursemodules      = array();
-    $subscribedusers    = array();
-    $messageinboundhandlers = array();
-
-    // Posts older than 2 days will not be mailed.  This is to avoid the problem where
-    // cron has not been running for a long time, and then suddenly people are flooded
-    // with mail from the past few weeks or months
-    $timenow   = time();
-    $endtime   = $timenow - $CFG->maxeditingtime;
-    $starttime = $endtime - 48 * 3600;   // Two days earlier
-
-    // Get the list of forum subscriptions for per-user per-forum maildigest settings.
-    $digestsset = $DB->get_recordset('forum_digests', null, '', 'id, userid, forum, maildigest');
-    $digests = array();
-    foreach ($digestsset as $thisrow) {
-        if (!isset($digests[$thisrow->forum])) {
-            $digests[$thisrow->forum] = array();
-        }
-        $digests[$thisrow->forum][$thisrow->userid] = $thisrow->maildigest;
-    }
-    $digestsset->close();
-
-    // Create the generic messageinboundgenerator.
-    $messageinboundgenerator = new \core\message\inbound\address_manager();
-    $messageinboundgenerator->set_handler('\mod_forum\message\inbound\reply_handler');
-
-    if ($posts = forum_get_unmailed_posts($starttime, $endtime, $timenow)) {
-        // Mark them all now as being mailed.  It's unlikely but possible there
-        // might be an error later so that a post is NOT actually mailed out,
-        // but since mail isn't crucial, we can accept this risk.  Doing it now
-        // prevents the risk of duplicated mails, which is a worse problem.
-
-        if (!forum_mark_old_posts_as_mailed($endtime)) {
-            mtrace('Errors occurred while trying to mark some posts as being mailed.');
-            return false;  // Don't continue trying to mail them, in case we are in a cron loop
-        }
-
-        // checking post validity, and adding users to loop through later
-        foreach ($posts as $pid => $post) {
-
-            $discussionid = $post->discussion;
-            if (!isset($discussions[$discussionid])) {
-                if ($discussion = $DB->get_record('forum_discussions', array('id'=> $post->discussion))) {
-                    $discussions[$discussionid] = $discussion;
-                    \mod_forum\subscriptions::fill_subscription_cache($discussion->forum);
-                    \mod_forum\subscriptions::fill_discussion_subscription_cache($discussion->forum);
-
-                } else {
-                    mtrace('Could not find discussion ' . $discussionid);
-                    unset($posts[$pid]);
-                    continue;
-                }
-            }
-            $forumid = $discussions[$discussionid]->forum;
-            if (!isset($forums[$forumid])) {
-                if ($forum = $DB->get_record('forum', array('id' => $forumid))) {
-                    $forums[$forumid] = $forum;
-                } else {
-                    mtrace('Could not find forum '.$forumid);
-                    unset($posts[$pid]);
-                    continue;
-                }
-            }
-            $courseid = $forums[$forumid]->course;
-            if (!isset($courses[$courseid])) {
-                if ($course = $DB->get_record('course', array('id' => $courseid))) {
-                    $courses[$courseid] = $course;
-                } else {
-                    mtrace('Could not find course '.$courseid);
-                    unset($posts[$pid]);
-                    continue;
-                }
-            }
-            if (!isset($coursemodules[$forumid])) {
-                if ($cm = get_coursemodule_from_instance('forum', $forumid, $courseid)) {
-                    $coursemodules[$forumid] = $cm;
-                } else {
-                    mtrace('Could not find course module for forum '.$forumid);
-                    unset($posts[$pid]);
-                    continue;
-                }
-            }
-
-            $modcontext = context_module::instance($coursemodules[$forumid]->id);
-
-            // Save the Inbound Message datakey here to reduce DB queries later.
-            $messageinboundgenerator->set_data($pid);
-            $messageinboundhandlers[$pid] = $messageinboundgenerator->fetch_data_key();
-
-            // Caching subscribed users of each forum.
-            if (!isset($subscribedusers[$forumid])) {
-                if ($subusers = \mod_forum\subscriptions::fetch_subscribed_users($forums[$forumid], 0, $modcontext, 'u.*', true)) {
-
-                    foreach ($subusers as $postuser) {
-                        // this user is subscribed to this forum
-                        $subscribedusers[$forumid][$postuser->id] = $postuser->id;
-                        $userscount++;
-                        if ($userscount > FORUM_CRON_USER_CACHE) {
-                            // Store minimal user info.
-                            $minuser = new stdClass();
-                            $minuser->id = $postuser->id;
-                            $users[$postuser->id] = $minuser;
-                        } else {
-                            // Cache full user record.
-                            forum_cron_minimise_user_record($postuser);
-                            $users[$postuser->id] = $postuser;
-                        }
-                    }
-                    // Release memory.
-                    unset($subusers);
-                    unset($postuser);
-                }
-            }
-            $mailcount[$pid] = 0;
-            $errorcount[$pid] = 0;
-        }
-    }
-
-    if ($users && $posts) {
-
-        foreach ($users as $userto) {
-            // Terminate if processing of any account takes longer than 2 minutes.
-            core_php_time_limit::raise(120);
-
-            mtrace('Processing user ' . $userto->id);
-
-            // Init user caches - we keep the cache for one cycle only, otherwise it could consume too much memory.
-            if (isset($userto->username)) {
-                $userto = clone($userto);
-            } else {
-                $userto = $DB->get_record('user', array('id' => $userto->id));
-                forum_cron_minimise_user_record($userto);
-            }
-            $userto->viewfullnames = array();
-            $userto->canpost       = array();
-            $userto->markposts     = array();
-
-            // Setup this user so that the capabilities are cached, and environment matches receiving user.
-            cron_setup_user($userto);
-
-            // Reset the caches.
-            foreach ($coursemodules as $forumid => $unused) {
-                $coursemodules[$forumid]->cache       = new stdClass();
-                $coursemodules[$forumid]->cache->caps = array();
-                unset($coursemodules[$forumid]->uservisible);
-            }
-
-            foreach ($posts as $pid => $post) {
-                $discussion = $discussions[$post->discussion];
-                $forum      = $forums[$discussion->forum];
-                $course     = $courses[$forum->course];
-                $cm         =& $coursemodules[$forum->id];
-
-                // Do some checks to see if we can bail out now.
-
-                // Only active enrolled users are in the list of subscribers.
-                // This does not necessarily mean that the user is subscribed to the forum or to the discussion though.
-                if (!isset($subscribedusers[$forum->id][$userto->id])) {
-                    // The user does not subscribe to this forum.
-                    continue;
-                }
-
-                if (!\mod_forum\subscriptions::is_subscribed($userto->id, $forum, $post->discussion, $coursemodules[$forum->id])) {
-                    // The user does not subscribe to this forum, or to this specific discussion.
-                    continue;
-                }
-
-                if ($subscriptiontime = \mod_forum\subscriptions::fetch_discussion_subscription($forum->id, $userto->id)) {
-                    // Skip posts if the user subscribed to the discussion after it was created.
-                    if (isset($subscriptiontime[$post->discussion]) && ($subscriptiontime[$post->discussion] > $post->created)) {
-                        continue;
-                    }
-                }
-
-                $coursecontext = context_course::instance($course->id);
-                if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $coursecontext, $userto->id)) {
-                    // The course is hidden and the user does not have access to it.
-                    continue;
-                }
-
-                // Don't send email if the forum is Q&A and the user has not posted.
-                // Initial topics are still mailed.
-                if ($forum->type == 'qanda' && !forum_get_user_posted_time($discussion->id, $userto->id) && $pid != $discussion->firstpost) {
-                    mtrace('Did not email ' . $userto->id.' because user has not posted in discussion');
-                    continue;
-                }
-
-                // Get info about the sending user.
-                if (array_key_exists($post->userid, $users)) {
-                    // We might know the user already.
-                    $userfrom = $users[$post->userid];
-                    if (!isset($userfrom->idnumber)) {
-                        // Minimalised user info, fetch full record.
-                        $userfrom = $DB->get_record('user', array('id' => $userfrom->id));
-                        forum_cron_minimise_user_record($userfrom);
-                    }
-
-                } else if ($userfrom = $DB->get_record('user', array('id' => $post->userid))) {
-                    forum_cron_minimise_user_record($userfrom);
-                    // Fetch only once if possible, we can add it to user list, it will be skipped anyway.
-                    if ($userscount <= FORUM_CRON_USER_CACHE) {
-                        $userscount++;
-                        $users[$userfrom->id] = $userfrom;
-                    }
-                } else {
-                    mtrace('Could not find user ' . $post->userid . ', author of post ' . $post->id . '. Unable to send message.');
-                    continue;
-                }
-
-                // Note: If we want to check that userto and userfrom are not the same person this is probably the spot to do it.
-
-                // Setup global $COURSE properly - needed for roles and languages.
-                cron_setup_user($userto, $course);
-
-                // Fill caches.
-                if (!isset($userto->viewfullnames[$forum->id])) {
-                    $modcontext = context_module::instance($cm->id);
-                    $userto->viewfullnames[$forum->id] = has_capability('moodle/site:viewfullnames', $modcontext);
-                }
-                if (!isset($userto->canpost[$discussion->id])) {
-                    $modcontext = context_module::instance($cm->id);
-                    $userto->canpost[$discussion->id] = forum_user_can_post($forum, $discussion, $userto, $cm, $course, $modcontext);
-                }
-                if (!isset($userfrom->groups[$forum->id])) {
-                    if (!isset($userfrom->groups)) {
-                        $userfrom->groups = array();
-                        if (isset($users[$userfrom->id])) {
-                            $users[$userfrom->id]->groups = array();
-                        }
-                    }
-                    $userfrom->groups[$forum->id] = groups_get_all_groups($course->id, $userfrom->id, $cm->groupingid);
-                    if (isset($users[$userfrom->id])) {
-                        $users[$userfrom->id]->groups[$forum->id] = $userfrom->groups[$forum->id];
-                    }
-                }
-
-                // Make sure groups allow this user to see this email.
-                if ($discussion->groupid > 0 and $groupmode = groups_get_activity_groupmode($cm, $course)) {
-                    // Groups are being used.
-                    if (!groups_group_exists($discussion->groupid)) {
-                        // Can't find group - be safe and don't this message.
-                        continue;
-                    }
-
-                    if (!groups_is_member($discussion->groupid) and !has_capability('moodle/site:accessallgroups', $modcontext)) {
-                        // Do not send posts from other groups when in SEPARATEGROUPS or VISIBLEGROUPS.
-                        continue;
-                    }
-                }
-
-                // Make sure we're allowed to see the post.
-                if (!forum_user_can_see_post($forum, $discussion, $post, null, $cm)) {
-                    mtrace('User ' . $userto->id .' can not see ' . $post->id . '. Not sending message.');
-                    continue;
-                }
-
-                // OK so we need to send the email.
-
-                // Does the user want this post in a digest?  If so postpone it for now.
-                $maildigest = forum_get_user_maildigest_bulk($digests, $userto, $forum->id);
-
-                if ($maildigest > 0) {
-                    // This user wants the mails to be in digest form.
-                    $queue = new stdClass();
-                    $queue->userid       = $userto->id;
-                    $queue->discussionid = $discussion->id;
-                    $queue->postid       = $post->id;
-                    $queue->timemodified = $post->created;
-                    $DB->insert_record('forum_queue', $queue);
-                    continue;
-                }
-
-                // Prepare to actually send the post now, and build up the content.
-
-                $cleanforumname = str_replace('"', "'", strip_tags(format_string($forum->name)));
-
-                $userfrom->customheaders = array (
-                    // Headers to make emails easier to track.
-                    'List-Id: "'        . $cleanforumname . '" ' . generate_email_messageid('moodleforum' . $forum->id),
-                    'List-Help: '       . $CFG->wwwroot . '/mod/forum/view.php?f=' . $forum->id,
-                    'Message-ID: '      . forum_get_email_message_id($post->id, $userto->id),
-                    'X-Course-Id: '     . $course->id,
-                    'X-Course-Name: '   . format_string($course->fullname, true),
-
-                    // Headers to help prevent auto-responders.
-                    'Precedence: Bulk',
-                    'X-Auto-Response-Suppress: All',
-                    'Auto-Submitted: auto-generated',
-                );
-
-                $shortname = format_string($course->shortname, true, array('context' => context_course::instance($course->id)));
-
-                // Generate a reply-to address from using the Inbound Message handler.
-                $replyaddress = null;
-                if ($userto->canpost[$discussion->id] && array_key_exists($post->id, $messageinboundhandlers)) {
-                    $messageinboundgenerator->set_data($post->id, $messageinboundhandlers[$post->id]);
-                    $replyaddress = $messageinboundgenerator->generate($userto->id);
-                }
-
-                if (!isset($userto->canpost[$discussion->id])) {
-                    $canreply = forum_user_can_post($forum, $discussion, $userto, $cm, $course, $modcontext);
-                } else {
-                    $canreply = $userto->canpost[$discussion->id];
-                }
-
-                $data = new \mod_forum\output\forum_post_email(
-                        $course,
-                        $cm,
-                        $forum,
-                        $discussion,
-                        $post,
-                        $userfrom,
-                        $userto,
-                        $canreply
-                    );
-
-                $userfrom->customheaders[] = sprintf('List-Unsubscribe: <%s>',
-                    $data->get_unsubscribediscussionlink());
-
-                if (!isset($userto->viewfullnames[$forum->id])) {
-                    $data->viewfullnames = has_capability('moodle/site:viewfullnames', $modcontext, $userto->id);
-                } else {
-                    $data->viewfullnames = $userto->viewfullnames[$forum->id];
-                }
-
-                // Not all of these variables are used in the default language
-                // string but are made available to support custom subjects.
-                $a = new stdClass();
-                $a->subject = $data->get_subject();
-                $a->forumname = $cleanforumname;
-                $a->sitefullname = format_string($site->fullname);
-                $a->siteshortname = format_string($site->shortname);
-                $a->courseidnumber = $data->get_courseidnumber();
-                $a->coursefullname = $data->get_coursefullname();
-                $a->courseshortname = $data->get_coursename();
-                $postsubject = html_to_text(get_string('postmailsubject', 'forum', $a), 0);
-
-                $rootid = forum_get_email_message_id($discussion->firstpost, $userto->id);
-
-                if ($post->parent) {
-                    // This post is a reply, so add reply header (RFC 2822).
-                    $parentid = forum_get_email_message_id($post->parent, $userto->id);
-                    $userfrom->customheaders[] = "In-Reply-To: $parentid";
-
-                    // If the post is deeply nested we also reference the parent message id and
-                    // the root message id (if different) to aid threading when parts of the email
-                    // conversation have been deleted (RFC1036).
-                    if ($post->parent != $discussion->firstpost) {
-                        $userfrom->customheaders[] = "References: $rootid $parentid";
-                    } else {
-                        $userfrom->customheaders[] = "References: $parentid";
-                    }
-                }
-
-                // MS Outlook / Office uses poorly documented and non standard headers, including
-                // Thread-Topic which overrides the Subject and shouldn't contain Re: or Fwd: etc.
-                $a->subject = $discussion->name;
-                $threadtopic = html_to_text(get_string('postmailsubject', 'forum', $a), 0);
-                $userfrom->customheaders[] = "Thread-Topic: $threadtopic";
-                $userfrom->customheaders[] = "Thread-Index: " . substr($rootid, 1, 28);
-
-                // Send the post now!
-                mtrace('Sending ', '');
-
-                $eventdata = new \core\message\message();
-                $eventdata->courseid            = $course->id;
-                $eventdata->component           = 'mod_forum';
-                $eventdata->name                = 'posts';
-                $eventdata->userfrom            = $userfrom;
-                $eventdata->userto              = $userto;
-                $eventdata->subject             = $postsubject;
-                $eventdata->fullmessage         = $textout->render($data);
-                $eventdata->fullmessageformat   = FORMAT_PLAIN;
-                $eventdata->fullmessagehtml     = $htmlout->render($data);
-                $eventdata->notification        = 1;
-                $eventdata->replyto             = $replyaddress;
-                if (!empty($replyaddress)) {
-                    // Add extra text to email messages if they can reply back.
-                    $textfooter = "\n\n" . get_string('replytopostbyemail', 'mod_forum');
-                    $htmlfooter = html_writer::tag('p', get_string('replytopostbyemail', 'mod_forum'));
-                    $additionalcontent = array('fullmessage' => array('footer' => $textfooter),
-                                     'fullmessagehtml' => array('footer' => $htmlfooter));
-                    $eventdata->set_additional_content('email', $additionalcontent);
-                }
-
-                $smallmessagestrings = new stdClass();
-                $smallmessagestrings->user          = fullname($userfrom);
-                $smallmessagestrings->forumname     = "$shortname: " . format_string($forum->name, true) . ": " . $discussion->name;
-                $smallmessagestrings->message       = $post->message;
-
-                // Make sure strings are in message recipients language.
-                $eventdata->smallmessage = get_string_manager()->get_string('smallmessage', 'forum', $smallmessagestrings, $userto->lang);
-
-                $contexturl = new moodle_url('/mod/forum/discuss.php', array('d' => $discussion->id), 'p' . $post->id);
-                $eventdata->contexturl = $contexturl->out();
-                $eventdata->contexturlname = $discussion->name;
-
-                $mailresult = message_send($eventdata);
-                if (!$mailresult) {
-                    mtrace("Error: mod/forum/lib.php forum_cron(): Could not send out mail for id $post->id to user $userto->id".
-                            " ($userto->email) .. not trying again.");
-                    $errorcount[$post->id]++;
-                } else {
-                    $mailcount[$post->id]++;
-
-                    // Mark post as read if forum_usermarksread is set off.
-                    if (!$CFG->forum_usermarksread) {
-                        $userto->markposts[$post->id] = $post->id;
-                    }
-                }
-
-                mtrace('post ' . $post->id . ': ' . $post->subject);
-            }
-
-            // Mark processed posts as read.
-            if (get_user_preferences('forum_markasreadonnotification', 1, $userto->id) == 1) {
-                forum_tp_mark_posts_read($userto, $userto->markposts);
-            }
-
-            unset($userto);
-        }
-    }
-
-    if ($posts) {
-        foreach ($posts as $post) {
-            mtrace($mailcount[$post->id]." users were sent post $post->id, '$post->subject'");
-            if ($errorcount[$post->id]) {
-                $DB->set_field('forum_posts', 'mailed', FORUM_MAILED_ERROR, array('id' => $post->id));
-            }
-        }
-    }
-
-    // release some memory
-    unset($subscribedusers);
-    unset($mailcount);
-    unset($errorcount);
-
-    cron_setup_user();
-
-    $sitetimezone = core_date::get_server_timezone();
-
-    // Now see if there are any digest mails waiting to be sent, and if we should send them
-
-    mtrace('Starting digest processing...');
-
-    core_php_time_limit::raise(300); // terminate if not able to fetch all digests in 5 minutes
-
-    if (!isset($CFG->digestmailtimelast)) {    // To catch the first time
-        set_config('digestmailtimelast', 0);
-    }
-
-    $timenow = time();
-    $digesttime = usergetmidnight($timenow, $sitetimezone) + ($CFG->digestmailtime * 3600);
-
-    // Delete any really old ones (normally there shouldn't be any)
-    $weekago = $timenow - (7 * 24 * 3600);
-    $DB->delete_records_select('forum_queue', "timemodified < ?", array($weekago));
-    mtrace ('Cleaned old digest records');
-
-    if ($CFG->digestmailtimelast < $digesttime and $timenow > $digesttime) {
-
-        mtrace('Sending forum digests: '.userdate($timenow, '', $sitetimezone));
-
-        $digestposts_rs = $DB->get_recordset_select('forum_queue', "timemodified < ?", array($digesttime));
-
-        if ($digestposts_rs->valid()) {
-
-            // We have work to do
-            $usermailcount = 0;
-
-            //caches - reuse the those filled before too
-            $discussionposts = array();
-            $userdiscussions = array();
-
-            foreach ($digestposts_rs as $digestpost) {
-                if (!isset($posts[$digestpost->postid])) {
-                    if ($post = $DB->get_record('forum_posts', array('id' => $digestpost->postid))) {
-                        $posts[$digestpost->postid] = $post;
-                    } else {
-                        continue;
-                    }
-                }
-                $discussionid = $digestpost->discussionid;
-                if (!isset($discussions[$discussionid])) {
-                    if ($discussion = $DB->get_record('forum_discussions', array('id' => $discussionid))) {
-                        $discussions[$discussionid] = $discussion;
-                    } else {
-                        continue;
-                    }
-                }
-                $forumid = $discussions[$discussionid]->forum;
-                if (!isset($forums[$forumid])) {
-                    if ($forum = $DB->get_record('forum', array('id' => $forumid))) {
-                        $forums[$forumid] = $forum;
-                    } else {
-                        continue;
-                    }
-                }
-
-                $courseid = $forums[$forumid]->course;
-                if (!isset($courses[$courseid])) {
-                    if ($course = $DB->get_record('course', array('id' => $courseid))) {
-                        $courses[$courseid] = $course;
-                    } else {
-                        continue;
-                    }
-                }
-
-                if (!isset($coursemodules[$forumid])) {
-                    if ($cm = get_coursemodule_from_instance('forum', $forumid, $courseid)) {
-                        $coursemodules[$forumid] = $cm;
-                    } else {
-                        continue;
-                    }
-                }
-                $userdiscussions[$digestpost->userid][$digestpost->discussionid] = $digestpost->discussionid;
-                $discussionposts[$digestpost->discussionid][$digestpost->postid] = $digestpost->postid;
-            }
-            $digestposts_rs->close(); /// Finished iteration, let's close the resultset
-
-            // Data collected, start sending out emails to each user
-            foreach ($userdiscussions as $userid => $thesediscussions) {
-
-                core_php_time_limit::raise(120); // terminate if processing of any account takes longer than 2 minutes
-
-                cron_setup_user();
-
-                mtrace(get_string('processingdigest', 'forum', $userid), '... ');
-
-                // First of all delete all the queue entries for this user
-                $DB->delete_records_select('forum_queue', "userid = ? AND timemodified < ?", array($userid, $digesttime));
-
-                // Init user caches - we keep the cache for one cycle only,
-                // otherwise it would unnecessarily consume memory.
-                if (array_key_exists($userid, $users) and isset($users[$userid]->username)) {
-                    $userto = clone($users[$userid]);
-                } else {
-                    $userto = $DB->get_record('user', array('id' => $userid));
-                    forum_cron_minimise_user_record($userto);
-                }
-                $userto->viewfullnames = array();
-                $userto->canpost       = array();
-                $userto->markposts     = array();
-
-                // Override the language and timezone of the "current" user, so that
-                // mail is customised for the receiver.
-                cron_setup_user($userto);
-
-                $postsubject = get_string('digestmailsubject', 'forum', format_string($site->shortname, true));
-
-                $headerdata = new stdClass();
-                $headerdata->sitename = format_string($site->fullname, true);
-                $headerdata->userprefs = $CFG->wwwroot.'/user/forum.php?id='.$userid.'&amp;course='.$site->id;
-
-                $posttext = get_string('digestmailheader', 'forum', $headerdata)."\n\n";
-                $headerdata->userprefs = '<a target="_blank" href="'.$headerdata->userprefs.'">'.get_string('digestmailprefs', 'forum').'</a>';
-
-                $posthtml = '<p>'.get_string('digestmailheader', 'forum', $headerdata).'</p>'
-                    . '<br /><hr size="1" noshade="noshade" />';
-
-                foreach ($thesediscussions as $discussionid) {
-
-                    core_php_time_limit::raise(120);   // to be reset for each post
-
-                    $discussion = $discussions[$discussionid];
-                    $forum      = $forums[$discussion->forum];
-                    $course     = $courses[$forum->course];
-                    $cm         = $coursemodules[$forum->id];
-
-                    //override language
-                    cron_setup_user($userto, $course);
-
-                    // Fill caches
-                    if (!isset($userto->viewfullnames[$forum->id])) {
-                        $modcontext = context_module::instance($cm->id);
-                        $userto->viewfullnames[$forum->id] = has_capability('moodle/site:viewfullnames', $modcontext);
-                    }
-                    if (!isset($userto->canpost[$discussion->id])) {
-                        $modcontext = context_module::instance($cm->id);
-                        $userto->canpost[$discussion->id] = forum_user_can_post($forum, $discussion, $userto, $cm, $course, $modcontext);
-                    }
-
-                    $strforums      = get_string('forums', 'forum');
-                    $canunsubscribe = ! \mod_forum\subscriptions::is_forcesubscribed($forum);
-                    $canreply       = $userto->canpost[$discussion->id];
-                    $shortname = format_string($course->shortname, true, array('context' => context_course::instance($course->id)));
-
-                    $posttext .= "\n \n";
-                    $posttext .= '=====================================================================';
-                    $posttext .= "\n \n";
-                    $posttext .= "$shortname -> $strforums -> ".format_string($forum->name,true);
-                    if ($discussion->name != $forum->name) {
-                        $posttext  .= " -> ".format_string($discussion->name,true);
-                    }
-                    $posttext .= "\n";
-                    $posttext .= $CFG->wwwroot.'/mod/forum/discuss.php?d='.$discussion->id;
-                    $posttext .= "\n";
-
-                    $posthtml .= "<p><font face=\"sans-serif\">".
-                    "<a target=\"_blank\" href=\"$CFG->wwwroot/course/view.php?id=$course->id\">$shortname</a> -> ".
-                    "<a target=\"_blank\" href=\"$CFG->wwwroot/mod/forum/index.php?id=$course->id\">$strforums</a> -> ".
-                    "<a target=\"_blank\" href=\"$CFG->wwwroot/mod/forum/view.php?f=$forum->id\">".format_string($forum->name,true)."</a>";
-                    if ($discussion->name == $forum->name) {
-                        $posthtml .= "</font></p>";
-                    } else {
-                        $posthtml .= " -> <a target=\"_blank\" href=\"$CFG->wwwroot/mod/forum/discuss.php?d=$discussion->id\">".format_string($discussion->name,true)."</a></font></p>";
-                    }
-                    $posthtml .= '<p>';
-
-                    $postsarray = $discussionposts[$discussionid];
-                    sort($postsarray);
-                    $sentcount = 0;
-
-                    foreach ($postsarray as $postid) {
-                        $post = $posts[$postid];
-
-                        if (array_key_exists($post->userid, $users)) { // we might know him/her already
-                            $userfrom = $users[$post->userid];
-                            if (!isset($userfrom->idnumber)) {
-                                $userfrom = $DB->get_record('user', array('id' => $userfrom->id));
-                                forum_cron_minimise_user_record($userfrom);
-                            }
-
-                        } else if ($userfrom = $DB->get_record('user', array('id' => $post->userid))) {
-                            forum_cron_minimise_user_record($userfrom);
-                            if ($userscount <= FORUM_CRON_USER_CACHE) {
-                                $userscount++;
-                                $users[$userfrom->id] = $userfrom;
-                            }
-
-                        } else {
-                            mtrace('Could not find user '.$post->userid);
-                            continue;
-                        }
-
-                        if (!isset($userfrom->groups[$forum->id])) {
-                            if (!isset($userfrom->groups)) {
-                                $userfrom->groups = array();
-                                if (isset($users[$userfrom->id])) {
-                                    $users[$userfrom->id]->groups = array();
-                                }
-                            }
-                            $userfrom->groups[$forum->id] = groups_get_all_groups($course->id, $userfrom->id, $cm->groupingid);
-                            if (isset($users[$userfrom->id])) {
-                                $users[$userfrom->id]->groups[$forum->id] = $userfrom->groups[$forum->id];
-                            }
-                        }
-
-                        // Headers to help prevent auto-responders.
-                        $userfrom->customheaders = array(
-                                "Precedence: Bulk",
-                                'X-Auto-Response-Suppress: All',
-                                'Auto-Submitted: auto-generated',
-                            );
-
-                        $maildigest = forum_get_user_maildigest_bulk($digests, $userto, $forum->id);
-                        if (!isset($userto->canpost[$discussion->id])) {
-                            $canreply = forum_user_can_post($forum, $discussion, $userto, $cm, $course, $modcontext);
-                        } else {
-                            $canreply = $userto->canpost[$discussion->id];
-                        }
-
-                        $data = new \mod_forum\output\forum_post_email(
-                                $course,
-                                $cm,
-                                $forum,
-                                $discussion,
-                                $post,
-                                $userfrom,
-                                $userto,
-                                $canreply
-                            );
-
-                        if (!isset($userto->viewfullnames[$forum->id])) {
-                            $data->viewfullnames = has_capability('moodle/site:viewfullnames', $modcontext, $userto->id);
-                        } else {
-                            $data->viewfullnames = $userto->viewfullnames[$forum->id];
-                        }
-
-                        if ($maildigest == 2) {
-                            // Subjects and link only.
-                            $posttext .= $textdigestbasicout->render($data);
-                            $posthtml .= $htmldigestbasicout->render($data);
-                        } else {
-                            // The full treatment.
-                            $posttext .= $textdigestfullout->render($data);
-                            $posthtml .= $htmldigestfullout->render($data);
-
-                            // Create an array of postid's for this user to mark as read.
-                            if (!$CFG->forum_usermarksread) {
-                                $userto->markposts[$post->id] = $post->id;
-                            }
-                        }
-                        $sentcount++;
-                    }
-                    $footerlinks = array();
-                    if ($canunsubscribe) {
-                        $footerlinks[] = "<a href=\"$CFG->wwwroot/mod/forum/subscribe.php?id=$forum->id\">" . get_string("unsubscribe", "forum") . "</a>";
-                    } else {
-                        $footerlinks[] = get_string("everyoneissubscribed", "forum");
-                    }
-                    $footerlinks[] = "<a href='{$CFG->wwwroot}/mod/forum/index.php?id={$forum->course}'>" . get_string("digestmailpost", "forum") . '</a>';
-                    $posthtml .= "\n<div class='mdl-right'><font size=\"1\">" . implode('&nbsp;', $footerlinks) . '</font></div>';
-                    $posthtml .= '<hr size="1" noshade="noshade" /></p>';
-                }
-
-                if (empty($userto->mailformat) || $userto->mailformat != 1) {
-                    // This user DOESN'T want to receive HTML
-                    $posthtml = '';
-                }
-
-                $eventdata = new \core\message\message();
-                $eventdata->courseid            = SITEID;
-                $eventdata->component           = 'mod_forum';
-                $eventdata->name                = 'digests';
-                $eventdata->userfrom            = core_user::get_noreply_user();
-                $eventdata->userto              = $userto;
-                $eventdata->subject             = $postsubject;
-                $eventdata->fullmessage         = $posttext;
-                $eventdata->fullmessageformat   = FORMAT_PLAIN;
-                $eventdata->fullmessagehtml     = $posthtml;
-                $eventdata->notification        = 1;
-                $eventdata->smallmessage        = get_string('smallmessagedigest', 'forum', $sentcount);
-                $mailresult = message_send($eventdata);
-
-                if (!$mailresult) {
-                    mtrace("ERROR: mod/forum/cron.php: Could not send out digest mail to user $userto->id ".
-                        "($userto->email)... not trying again.");
-                } else {
-                    mtrace("success.");
-                    $usermailcount++;
-
-                    // Mark post as read if forum_usermarksread is set off
-                    if (get_user_preferences('forum_markasreadonnotification', 1, $userto->id) == 1) {
-                        forum_tp_mark_posts_read($userto, $userto->markposts);
-                    }
-                }
-            }
-        }
-    /// We have finishied all digest emails, update $CFG->digestmailtimelast
-        set_config('digestmailtimelast', $timenow);
-    }
-
-    cron_setup_user();
-
-    if (!empty($usermailcount)) {
-        mtrace(get_string('digestsentusers', 'forum', $usermailcount));
-    }
-
-    if (!empty($CFG->forum_lastreadclean)) {
-        $timenow = time();
-        if ($CFG->forum_lastreadclean + (24*3600) < $timenow) {
-            set_config('forum_lastreadclean', $timenow);
-            mtrace('Removing old forum read tracking info...');
-            forum_tp_clean_read_records();
-        }
-    } else {
-        set_config('forum_lastreadclean', time());
-    }
-
-    return true;
-}
-
 /**
  *
  * @param object $course
@@ -2161,86 +1338,6 @@ function forum_search_posts($searchterms, $courseid=0, $limitfrom=0, $limitnum=5
     return $DB->get_records_sql($searchsql, $params, $limitfrom, $limitnum);
 }
 
-/**
- * Returns a list of all new posts that have not been mailed yet
- *
- * @param int $starttime posts created after this time
- * @param int $endtime posts created before this
- * @param int $now used for timed discussions only
- * @return array
- */
-function forum_get_unmailed_posts($starttime, $endtime, $now=null) {
-    global $CFG, $DB;
-
-    $params = array();
-    $params['mailed'] = FORUM_MAILED_PENDING;
-    $params['ptimestart'] = $starttime;
-    $params['ptimeend'] = $endtime;
-    $params['mailnow'] = 1;
-
-    if (!empty($CFG->forum_enabletimedposts)) {
-        if (empty($now)) {
-            $now = time();
-        }
-        $selectsql = "AND (p.created >= :ptimestart OR d.timestart >= :pptimestart)";
-        $params['pptimestart'] = $starttime;
-        $timedsql = "AND (d.timestart < :dtimestart AND (d.timeend = 0 OR d.timeend > :dtimeend))";
-        $params['dtimestart'] = $now;
-        $params['dtimeend'] = $now;
-    } else {
-        $timedsql = "";
-        $selectsql = "AND p.created >= :ptimestart";
-    }
-
-    return $DB->get_records_sql("SELECT p.*, d.course, d.forum
-                                 FROM {forum_posts} p
-                                 JOIN {forum_discussions} d ON d.id = p.discussion
-                                 WHERE p.mailed = :mailed
-                                 $selectsql
-                                 AND (p.created < :ptimeend OR p.mailnow = :mailnow)
-                                 $timedsql
-                                 ORDER BY p.modified ASC", $params);
-}
-
-/**
- * Marks posts before a certain time as being mailed already
- *
- * @global object
- * @global object
- * @param int $endtime
- * @param int $now Defaults to time()
- * @return bool
- */
-function forum_mark_old_posts_as_mailed($endtime, $now=null) {
-    global $CFG, $DB;
-
-    if (empty($now)) {
-        $now = time();
-    }
-
-    $params = array();
-    $params['mailedsuccess'] = FORUM_MAILED_SUCCESS;
-    $params['now'] = $now;
-    $params['endtime'] = $endtime;
-    $params['mailnow'] = 1;
-    $params['mailedpending'] = FORUM_MAILED_PENDING;
-
-    if (empty($CFG->forum_enabletimedposts)) {
-        return $DB->execute("UPDATE {forum_posts}
-                             SET mailed = :mailedsuccess
-                             WHERE (created < :endtime OR mailnow = :mailnow)
-                             AND mailed = :mailedpending", $params);
-    } else {
-        return $DB->execute("UPDATE {forum_posts}
-                             SET mailed = :mailedsuccess
-                             WHERE discussion NOT IN (SELECT d.id
-                                                      FROM {forum_discussions} d
-                                                      WHERE d.timestart > :now)
-                             AND (created < :endtime OR mailnow = :mailnow)
-                             AND mailed = :mailedpending", $params);
-    }
-}
-
 /**
  * Get all the posts for a user in a forum suitable for forum_print_post
  *
index 2cec6e7..1c2251a 100644 (file)
@@ -44,7 +44,8 @@
     } {{/ str }}
 ---------------------------------------------------------------------
 {{{ message }}}
-
+{{# attachments }}
 {{{ attachments }}}
+{{/ attachments }}
 ---------------------------------------------------------------------
 {{# str }} digestmailpostlink, forum, {{{ forumindexlink }}} {{/ str }}
diff --git a/mod/forum/tests/cron_trait.php b/mod/forum/tests/cron_trait.php
new file mode 100644 (file)
index 0000000..c68306f
--- /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/>.
+
+/**
+ * The forum module cron trait.
+ *
+ * @package    mod_forum
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+trait mod_forum_tests_cron_trait {
+    /**
+     * Run the main cron task to queue all tasks, and ensure that posts
+     * were sent to the correct users.
+     *
+     * @param   \stdClass[] $expectations The list of users, along with their expected count of messages and digests.
+     */
+    protected function queue_tasks_and_assert($expectations = []) {
+        global $DB;
+
+        // Note, we cannot use expectOutputRegex because it only allows for a single RegExp.
+        ob_start();
+        cron_setup_user();
+        $cron = new \mod_forum\task\cron_task();
+        $cron->execute();
+        $output = ob_get_contents();
+        ob_end_clean();
+
+        $uniqueusers = 0;
+        foreach ($expectations as $expect) {
+            $expect->digests = isset($expect->digests) ? $expect->digests : 0;
+            $expect->messages = isset($expect->messages) ? $expect->messages : 0;
+            $expect->mentioned = isset($expect->mentioned) ? $expect->mentioned : false;
+            if ($expect->digests || $expect->messages) {
+                $expect->mentioned = true;
+            }
+            if (!$expect->mentioned) {
+                $this->assertNotRegExp("/Queued 0 for {$expect->userid}/", $output);
+            } else {
+                $uniqueusers++;
+                $this->assertRegExp(
+                        "/Queued {$expect->digests} digests and {$expect->messages} messages for {$expect->userid}/",
+                        $output
+                    );
+            }
+        }
+
+        if (empty($expectations)) {
+            $this->assertRegExp("/No posts found./", $output);
+        } else {
+            $this->assertRegExp("/Unique users: {$uniqueusers}/", $output);
+        }
+
+        // Update the forum queue for digests.
+        $DB->execute("UPDATE {forum_queue} SET timemodified = timemodified - 1");
+    }
+
+    /**
+     * Run any send_user_notifications tasks for the specified user, and
+     * ensure that the posts specified were sent.
+     *
+     * @param   \stdClass   $user
+     * @param   \stdClass[] $posts
+     * @param   bool        $ignoreemptyposts
+     */
+    protected function send_notifications_and_assert($user, $posts = [], $ignoreemptyposts = false) {
+        ob_start();
+        $this->runAdhocTasks(\mod_forum\task\send_user_notifications::class, $user->id);
+        $output = ob_get_contents();
+        ob_end_clean();
+
+        if (empty($posts) && !$ignoreemptyposts) {
+            $this->assertEquals('', $output);
+        } else {
+            $this->assertRegExp("/Sending messages to {$user->username}/", $output);
+            foreach ($posts as $post) {
+                $this->assertRegExp("/Post {$post->id} sent/", $output);
+            }
+            $count = count($posts);
+            $this->assertRegExp("/Sent {$count} messages with 0 failures/", $output);
+        }
+    }
+
+    /**
+     * Run any send_user_digests tasks for the specified user, and
+     * ensure that the posts specified were sent.
+     *
+     * @param   \stdClass   $user
+     * @param   \stdClass[] $fullposts
+     * @param   \stdClass[] $shortposts
+     */
+    protected function send_digests_and_assert($user, $fullposts = [], $shortposts = []) {
+        ob_start();
+        $this->runAdhocTasks(\mod_forum\task\send_user_digests::class, $user->id);
+        $output = ob_get_contents();
+        ob_end_clean();
+
+        if (empty($shortposts) && empty($fullposts)) {
+            $this->assertEquals('', $output);
+            $this->assertRegExp("/Digest sent with 0 messages./", $output);
+        } else {
+            $this->assertRegExp("/Sending forum digests for {$user->username}/", $output);
+            foreach ($fullposts as $post) {
+                $this->assertRegExp("/Adding post {$post->id} in format 1/", $output);
+            }
+            foreach ($shortposts as $post) {
+                $this->assertRegExp("/Adding post {$post->id} in format 2/", $output);
+            }
+            $count = count($fullposts) + count($shortposts);
+            $this->assertRegExp("/Digest sent with {$count} messages./", $output);
+        }
+    }
+}
diff --git a/mod/forum/tests/generator_trait.php b/mod/forum/tests/generator_trait.php
new file mode 100644 (file)
index 0000000..4c536ab
--- /dev/null
@@ -0,0 +1,159 @@
+<?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/>.
+
+/**
+ * The forum module trait with additional generator helpers.
+ *
+ * @package    mod_forum
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+trait mod_forum_tests_generator_trait {
+
+    /**
+     * Helper to create the required number of users in the specified course.
+     * Users are enrolled as students by default.
+     *
+     * @param   stdClass $course The course object
+     * @param   integer $count The number of users to create
+     * @param   string  $role The role to assign users as
+     * @return  array The users created
+     */
+    protected function helper_create_users($course, $count, $role = null) {
+        $users = array();
+
+        for ($i = 0; $i < $count; $i++) {
+            $user = $this->getDataGenerator()->create_user();
+            $this->getDataGenerator()->enrol_user($user->id, $course->id, $role);
+            $users[] = $user;
+        }
+
+        return $users;
+    }
+
+    /**
+     * Create a new discussion and post within the specified forum, as the
+     * specified author.
+     *
+     * @param stdClass $forum The forum to post in
+     * @param stdClass $author The author to post as
+     * @param array $fields any other fields in discussion (name, message, messageformat, ...)
+     * @return array An array containing the discussion object, and the post object
+     */
+    protected function helper_post_to_forum($forum, $author, $fields = array()) {
+        global $DB;
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_forum');
+
+        // Create a discussion in the forum, and then add a post to that discussion.
+        $record = (object)$fields;
+        $record->course = $forum->course;
+        $record->userid = $author->id;
+        $record->forum = $forum->id;
+        $discussion = $generator->create_discussion($record);
+
+        // Retrieve the post which was created by create_discussion.
+        $post = $DB->get_record('forum_posts', array('discussion' => $discussion->id));
+
+        return array($discussion, $post);
+    }
+
+    /**
+     * Update the post time for the specified post by $factor.
+     *
+     * @param stdClass $post The post to update
+     * @param int $factor The amount to update by
+     */
+    protected function helper_update_post_time($post, $factor) {
+        global $DB;
+
+        // Update the post to have a created in the past.
+        $DB->set_field('forum_posts', 'created', $post->created + $factor, array('id' => $post->id));
+    }
+
+    /**
+     * Update the subscription time for the specified user/discussion by $factor.
+     *
+     * @param stdClass $user The user to update
+     * @param stdClass $discussion The discussion to update for this user
+     * @param int $factor The amount to update by
+     */
+    protected function helper_update_subscription_time($user, $discussion, $factor) {
+        global $DB;
+
+        $sub = $DB->get_record('forum_discussion_subs', array('userid' => $user->id, 'discussion' => $discussion->id));
+
+        // Update the subscription to have a preference in the past.
+        $DB->set_field('forum_discussion_subs', 'preference', $sub->preference + $factor, array('id' => $sub->id));
+    }
+
+    /**
+     * Create a new post within an existing discussion, as the specified author.
+     *
+     * @param stdClass $forum The forum to post in
+     * @param stdClass $discussion The discussion to post in
+     * @param stdClass $author The author to post as
+     * @return stdClass The forum post
+     */
+    protected function helper_post_to_discussion($forum, $discussion, $author) {
+        global $DB;
+
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_forum');
+
+        // Add a post to the discussion.
+        $record = new stdClass();
+        $record->course = $forum->course;
+        $strre = get_string('re', 'forum');
+        $record->subject = $strre . ' ' . $discussion->subject;
+        $record->userid = $author->id;
+        $record->forum = $forum->id;
+        $record->discussion = $discussion->id;
+        $record->mailnow = 1;
+
+        $post = $generator->create_post($record);
+
+        return $post;
+    }
+
+    /**
+     * Create a new post within an existing discussion, as the specified author.
+     *
+     * @param stdClass $parent The post being replied to
+     * @param stdClass $author The author to post as
+     * @return stdClass The forum post
+     */
+    protected function helper_reply_to_post($parent, $author) {
+        global $DB;
+
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_forum');
+
+        // Add a post to the discussion.
+        $strre = get_string('re', 'forum');
+        $record = (object) [
+            'discussion' => $parent->discussion,
+            'parent' => $parent->id,
+            'userid' => $author->id,
+            'mailnow' => 1,
+            'subject' => $strre . ' ' . $parent->subject,
+        ];
+
+        $post = $generator->create_post($record);
+
+        return $post;
+    }
+}
index cce7861..8099d0c 100644 (file)
@@ -2961,65 +2961,6 @@ class mod_forum_lib_testcase extends advanced_testcase {
         $this->assertEmpty($neighbours['next']);
     }
 
-    /**
-     * @dataProvider forum_get_unmailed_posts_provider
-     */
-    public function test_forum_get_unmailed_posts($discussiondata, $enabletimedposts, $expectedcount, $expectedreplycount) {
-        global $CFG, $DB;
-
-        $this->resetAfterTest();
-
-        // Configure timed posts.
-        $CFG->forum_enabletimedposts = $enabletimedposts;
-
-        $course = $this->getDataGenerator()->create_course();
-        $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
-        $user = $this->getDataGenerator()->create_user();
-        $forumgen = $this->getDataGenerator()->get_plugin_generator('mod_forum');
-
-        // Keep track of the start time of the test. Do not use time() after this point to prevent random failures.
-        $time = time();
-
-        $record = new stdClass();
-        $record->course = $course->id;
-        $record->userid = $user->id;
-        $record->forum = $forum->id;
-        if (isset($discussiondata['timecreated'])) {
-            $record->timemodified = $time + $discussiondata['timecreated'];
-        }
-        if (isset($discussiondata['timestart'])) {
-            $record->timestart = $time + $discussiondata['timestart'];
-        }
-        if (isset($discussiondata['timeend'])) {
-            $record->timeend = $time + $discussiondata['timeend'];
-        }
-        if (isset($discussiondata['mailed'])) {
-            $record->mailed = $discussiondata['mailed'];
-        }
-
-        $discussion = $forumgen->create_discussion($record);
-
-        // Fetch the unmailed posts.
-        $timenow   = $time;
-        $endtime   = $timenow - $CFG->maxeditingtime;
-        $starttime = $endtime - 2 * DAYSECS;
-
-        $unmailed = forum_get_unmailed_posts($starttime, $endtime, $timenow);
-        $this->assertCount($expectedcount, $unmailed);
-
-        // Add a reply just outside the maxeditingtime.
-        $replyto = $DB->get_record('forum_posts', array('discussion' => $discussion->id));
-        $reply = new stdClass();
-        $reply->userid = $user->id;
-        $reply->discussion = $discussion->id;
-        $reply->parent = $replyto->id;
-        $reply->created = max($replyto->created, $endtime - 1);
-        $forumgen->create_post($reply);
-
-        $unmailed = forum_get_unmailed_posts($starttime, $endtime, $timenow);
-        $this->assertCount($expectedreplycount, $unmailed);
-    }
-
     /**
      * Test for forum_is_author_hidden.
      */
@@ -3055,163 +2996,6 @@ class mod_forum_lib_testcase extends advanced_testcase {
         forum_is_author_hidden($post, $forum);
     }
 
-    public function forum_get_unmailed_posts_provider() {
-        return [
-            'Untimed discussion; Single post; maxeditingtime not expired' => [
-                'discussion'        => [
-                ],
-                'timedposts'        => false,
-                'postcount'         => 0,
-                'replycount'        => 0,
-            ],
-            'Untimed discussion; Single post; maxeditingtime expired' => [
-                'discussion'        => [
-                    'timecreated'   => - DAYSECS,
-                ],
-                'timedposts'        => false,
-                'postcount'         => 1,
-                'replycount'        => 2,
-            ],
-            'Timed discussion; Single post; Posted 1 week ago; timestart maxeditingtime not expired' => [
-                'discussion'        => [
-                    'timecreated'   => - WEEKSECS,
-                    'timestart'     => 0,
-                ],
-                'timedposts'        => true,
-                'postcount'         => 0,
-                'replycount'        => 0,
-            ],
-            'Timed discussion; Single post; Posted 1 week ago; timestart maxeditingtime expired' => [
-                'discussion'        => [
-                    'timecreated'   => - WEEKSECS,
-                    'timestart'     => - DAYSECS,
-                ],
-                'timedposts'        => true,
-                'postcount'         => 1,
-                'replycount'        => 2,
-            ],
-            'Timed discussion; Single post; Posted 1 week ago; timestart maxeditingtime expired; timeend not reached' => [
-                'discussion'        => [
-                    'timecreated'   => - WEEKSECS,
-                    'timestart'     => - DAYSECS,
-                    'timeend'       => + DAYSECS
-                ],
-                'timedposts'        => true,
-                'postcount'         => 1,
-                'replycount'        => 2,
-            ],
-            'Timed discussion; Single post; Posted 1 week ago; timestart maxeditingtime expired; timeend passed' => [
-                'discussion'        => [
-                    'timecreated'   => - WEEKSECS,
-                    'timestart'     => - DAYSECS,
-                    'timeend'       => - HOURSECS,
-                ],
-                'timedposts'        => true,
-                'postcount'         => 0,
-                'replycount'        => 0,
-            ],
-            'Timed discussion; Single post; Posted 1 week ago; timeend not reached' => [
-                'discussion'        => [
-                    'timecreated'   => - WEEKSECS,
-                    'timeend'       => + DAYSECS
-                ],
-                'timedposts'        => true,
-                'postcount'         => 0,
-                'replycount'        => 1,
-            ],
-            'Timed discussion; Single post; Posted 1 week ago; timeend passed' => [
-                'discussion'        => [
-                    'timecreated'   => - WEEKSECS,
-                    'timeend'       => - DAYSECS,
-                ],
-                'timedposts'        => true,
-                'postcount'         => 0,
-                'replycount'        => 0,
-            ],
-
-            'Previously mailed; Untimed discussion; Single post; maxeditingtime not expired' => [
-                'discussion'        => [
-                    'mailed'        => 1,
-                ],
-                'timedposts'        => false,
-                'postcount'         => 0,
-                'replycount'        => 0,
-            ],
-
-            'Previously mailed; Untimed discussion; Single post; maxeditingtime expired' => [
-                'discussion'        => [
-                    'timecreated'   => - DAYSECS,
-                    'mailed'        => 1,
-                ],
-                'timedposts'        => false,
-                'postcount'         => 0,
-                'replycount'        => 1,
-            ],
-            'Previously mailed; Timed discussion; Single post; Posted 1 week ago; timestart maxeditingtime not expired' => [
-                'discussion'        => [
-                    'timecreated'   => - WEEKSECS,
-                    'timestart'     => 0,
-                    'mailed'        => 1,
-                ],
-                'timedposts'        => true,
-                'postcount'         => 0,
-                'replycount'        => 0,
-            ],
-            'Previously mailed; Timed discussion; Single post; Posted 1 week ago; timestart maxeditingtime expired' => [
-                'discussion'        => [
-                    'timecreated'   => - WEEKSECS,
-                    'timestart'     => - DAYSECS,
-                    'mailed'        => 1,
-                ],
-                'timedposts'        => true,
-                'postcount'         => 0,
-                'replycount'        => 1,
-            ],
-            'Previously mailed; Timed discussion; Single post; Posted 1 week ago; timestart maxeditingtime expired; timeend not reached' => [
-                'discussion'        => [
-                    'timecreated'   => - WEEKSECS,
-                    'timestart'     => - DAYSECS,
-                    'timeend'       => + DAYSECS,
-                    'mailed'        => 1,
-                ],
-                'timedposts'        => true,
-                'postcount'         => 0,
-                'replycount'        => 1,
-            ],
-            'Previously mailed; Timed discussion; Single post; Posted 1 week ago; timestart maxeditingtime expired; timeend passed' => [
-                'discussion'        => [
-                    'timecreated'   => - WEEKSECS,
-                    'timestart'     => - DAYSECS,
-                    'timeend'       => - HOURSECS,
-                    'mailed'        => 1,
-                ],
-                'timedposts'        => true,
-                'postcount'         => 0,
-                'replycount'        => 0,
-            ],
-            'Previously mailed; Timed discussion; Single post; Posted 1 week ago; timeend not reached' => [
-                'discussion'        => [
-                    'timecreated'   => - WEEKSECS,
-                    'timeend'       => + DAYSECS,
-                    'mailed'        => 1,
-                ],
-                'timedposts'        => true,
-                'postcount'         => 0,
-                'replycount'        => 1,
-            ],
-            'Previously mailed; Timed discussion; Single post; Posted 1 week ago; timeend passed' => [
-                'discussion'        => [
-                    'timecreated'   => - WEEKSECS,
-                    'timeend'       => - DAYSECS,
-                    'mailed'        => 1,
-                ],
-                'timedposts'        => true,
-                'postcount'         => 0,
-                'replycount'        => 0,
-            ],
-        ];
-    }
-
     /**
      * Test the forum_discussion_is_locked function.
      *
diff --git a/mod/forum/tests/mail_group_test.php b/mod/forum/tests/mail_group_test.php
new file mode 100644 (file)
index 0000000..904151f
--- /dev/null
@@ -0,0 +1,246 @@
+<?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/>.
+
+/**
+ * The forum module mail generation tests for groups.
+ *
+ * @package    mod_forum
+ * @copyright  2013 Andrew Nicols
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/mod/forum/lib.php');
+require_once(__DIR__ . '/cron_trait.php');
+require_once(__DIR__ . '/generator_trait.php');
+
+/**
+ * The forum module mail generation tests for groups.
+ *
+ * @copyright  2013 Andrew Nicols
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mod_forum_mail_group_testcase extends advanced_testcase {
+    // Make use of the cron tester trait.
+    use mod_forum_tests_cron_trait;
+
+    // Make use of the test generator trait.
+    use mod_forum_tests_generator_trait;
+
+    /**
+     * @var \phpunit_message_sink
+     */
+    protected $messagesink;
+
+    /**
+     * @var \phpunit_mailer_sink
+     */
+    protected $mailsink;
+
+    public function setUp() {
+        global $CFG;
+
+        // We must clear the subscription caches. This has to be done both before each test, and after in case of other
+        // tests using these functions.
+        \mod_forum\subscriptions::reset_forum_cache();
+        \mod_forum\subscriptions::reset_discussion_cache();
+
+        // Messaging is not compatible with transactions...
+        $this->preventResetByRollback();
+
+        // Catch all messages.
+        $this->messagesink = $this->redirectMessages();
+        $this->mailsink = $this->redirectEmails();
+
+        // Forcibly reduce the maxeditingtime to a second in the past to
+        // ensure that messages are sent out.
+        $CFG->maxeditingtime = -1;
+    }
+
+    public function tearDown() {
+        // We must clear the subscription caches. This has to be done both before each test, and after in case of other
+        // tests using these functions.
+        \mod_forum\subscriptions::reset_forum_cache();
+
+        $this->messagesink->clear();
+        $this->messagesink->close();
+        unset($this->messagesink);
+
+        $this->mailsink->clear();
+        $this->mailsink->close();
+        unset($this->mailsink);
+    }
+
+    /**
+     * Ensure that posts written in a forum marked for separate groups includes notifications for the members of that
+     * group, and any user with accessallgroups.
+     */
+    public function test_separate_group() {
+        global $CFG, $DB;
+
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $forum = $this->getDataGenerator()->create_module('forum', [
+            'course' => $course->id,
+            'forcesubscribe' => FORUM_INITIALSUBSCRIBE,
+            'groupmode' => SEPARATEGROUPS,
+        ]);
+
+        // Create three students:
+        // - author, enrolled in group A; and
+        // - recipient, enrolled in group B; and
+        // - other, enrolled in the course, but no groups.
+        list($author, $recipient, $otheruser) = $this->helper_create_users($course, 3);
+
+        // Create one teacher, not in any group and no accessallgroups capability.
+        list($teacher) = $this->helper_create_users($course, 1, 'teacher');
+
+        // Create one editing teacher, not in any group but with accessallgroups capability.
+        list($editingteacher) = $this->helper_create_users($course, 1, 'editingteacher');
+
+        $groupa = $this->getDataGenerator()->create_group(['courseid' => $course->id]);
+        $groupb = $this->getDataGenerator()->create_group(['courseid' => $course->id]);
+        $this->getDataGenerator()->create_group_member([
+            'groupid' => $groupa->id,
+            'userid' => $author->id,
+        ]);
+        $this->getDataGenerator()->create_group_member([
+            'groupid' => $groupb->id,
+            'userid' => $recipient->id,
+        ]);
+
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author, [
+            'groupid' => $groupa->id,
+        ]);
+
+        // Only the author should receive.
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 0,
+            ],
+            'otheruser' => (object) [
+                'userid' => $otheruser->id,
+                'messages' => 0,
+            ],
+            'teacher' => (object) [
+                'userid' => $teacher->id,
+                'messages' => 0,
+            ],
+            'editingteacher' => (object) [
+                'userid' => $editingteacher->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        // No notifications should be queued.
+        $this->send_notifications_and_assert($author, [$post]);
+        $this->send_notifications_and_assert($recipient, []);
+        $this->send_notifications_and_assert($otheruser, []);
+        $this->send_notifications_and_assert($teacher, []);
+        $this->send_notifications_and_assert($editingteacher, [$post]);
+    }
+
+    /**
+     * Ensure that posts written in a forum marked for visible groups includes notifications for the members of that
+     * group, and any user with accessallgroups.
+     */
+    public function test_visible_group() {
+        global $CFG, $DB;
+
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $forum = $this->getDataGenerator()->create_module('forum', [
+            'course' => $course->id,
+            'forcesubscribe' => FORUM_INITIALSUBSCRIBE,
+            'groupmode' => VISIBLEGROUPS,
+        ]);
+
+        // Create three students:
+        // - author, enrolled in group A; and
+        // - recipient, enrolled in group B; and
+        // - other, enrolled in the course, but no groups.
+        list($author, $recipient, $otheruser) = $this->helper_create_users($course, 3);
+
+        // Create one teacher, not in any group and no accessallgroups capability.
+        list($teacher) = $this->helper_create_users($course, 1, 'teacher');
+
+        // Create one editing teacher, not in any group but with accessallgroups capability.
+        list($editingteacher) = $this->helper_create_users($course, 1, 'editingteacher');
+
+        $groupa = $this->getDataGenerator()->create_group(['courseid' => $course->id]);
+        $groupb = $this->getDataGenerator()->create_group(['courseid' => $course->id]);
+        $this->getDataGenerator()->create_group_member([
+            'groupid' => $groupa->id,
+            'userid' => $author->id,
+        ]);
+        $this->getDataGenerator()->create_group_member([
+            'groupid' => $groupb->id,
+            'userid' => $recipient->id,
+        ]);
+
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author, [
+            'groupid' => $groupa->id,
+        ]);
+
+        // Only the author should receive.
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 0,
+            ],
+            'otheruser' => (object) [
+                'userid' => $otheruser->id,
+                'messages' => 0,
+            ],
+            'teacher' => (object) [
+                'userid' => $teacher->id,
+                'messages' => 0,
+            ],
+            'editingteacher' => (object) [
+                'userid' => $editingteacher->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        // No notifications should be queued.
+        $this->send_notifications_and_assert($author, [$post]);
+        $this->send_notifications_and_assert($recipient, []);
+        $this->send_notifications_and_assert($otheruser, []);
+        $this->send_notifications_and_assert($teacher, []);
+        $this->send_notifications_and_assert($editingteacher, [$post]);
+    }
+}
index d233252..9e1afdf 100644 (file)
 defined('MOODLE_INTERNAL') || die();
 
 global $CFG;
+require_once($CFG->dirroot . '/mod/forum/lib.php');
+require_once(__DIR__ . '/cron_trait.php');
+require_once(__DIR__ . '/generator_trait.php');
 
 class mod_forum_mail_testcase extends advanced_testcase {
+    // Make use of the cron tester trait.
+    use mod_forum_tests_cron_trait;
 
+    // Make use of the test generator trait.
+    use mod_forum_tests_generator_trait;
 
-    protected $helper;
+    /**
+     * @var \phpunit_message_sink
+     */
+    protected $messagesink;
+
+    /**
+     * @var \phpunit_mailer_sink
+     */
+    protected $mailsink;
 
     public function setUp() {
+        global $CFG;
+
         // We must clear the subscription caches. This has to be done both before each test, and after in case of other
         // tests using these functions.
         \mod_forum\subscriptions::reset_forum_cache();
         \mod_forum\subscriptions::reset_discussion_cache();
 
-        global $CFG;
-        require_once($CFG->dirroot . '/mod/forum/lib.php');
-
-        $helper = new stdClass();
-
         // Messaging is not compatible with transactions...
         $this->preventResetByRollback();
 
         // Catch all messages.
-        $helper->messagesink = $this->redirectMessages();
-        $helper->mailsink = $this->redirectEmails();
-
-        // Confirm that we have an empty message sink so far.
-        $messages = $helper->messagesink->get_messages();
-        $this->assertEquals(0, count($messages));
-
-        $messages = $helper->mailsink->get_messages();
-        $this->assertEquals(0, count($messages));
+        $this->messagesink = $this->redirectMessages();
+        $this->mailsink = $this->redirectEmails();
 
         // Forcibly reduce the maxeditingtime to a second in the past to
         // ensure that messages are sent out.
         $CFG->maxeditingtime = -1;
-
-        $this->helper = $helper;
     }
 
     public function tearDown() {
@@ -69,11 +72,13 @@ class mod_forum_mail_testcase extends advanced_testcase {
         // tests using these functions.
         \mod_forum\subscriptions::reset_forum_cache();
 
-        $this->helper->messagesink->clear();
-        $this->helper->messagesink->close();
+        $this->messagesink->clear();
+        $this->messagesink->close();
+        unset($this->messagesink);
 
-        $this->helper->mailsink->clear();
-        $this->helper->mailsink->close();
+        $this->mailsink->clear();
+        $this->mailsink->close();
+        unset($this->mailsink);
     }
 
     /**
@@ -93,166 +98,6 @@ class mod_forum_mail_testcase extends advanced_testcase {
         $record->id = $DB->update_record('messageinbound_handlers', $record);
     }
 
-    /**
-     * Helper to create the required number of users in the specified
-     * course.
-     * Users are enrolled as students.
-     *
-     * @param stdClass $course The course object
-     * @param integer $count The number of users to create
-     * @return array The users created
-     */
-    protected function helper_create_users($course, $count) {
-        $users = array();
-
-        for ($i = 0; $i < $count; $i++) {
-            $user = $this->getDataGenerator()->create_user();
-            $this->getDataGenerator()->enrol_user($user->id, $course->id);
-            $users[] = $user;
-        }
-
-        return $users;
-    }
-
-    /**
-     * Create a new discussion and post within the specified forum, as the
-     * specified author.
-     *
-     * @param stdClass $forum The forum to post in
-     * @param stdClass $author The author to post as
-     * @param array $fields any other fields in discussion (name, message, messageformat, ...)
-     * @param array An array containing the discussion object, and the post object
-     */
-    protected function helper_post_to_forum($forum, $author, $fields = array()) {
-        global $DB;
-        $generator = $this->getDataGenerator()->get_plugin_generator('mod_forum');
-
-        // Create a discussion in the forum, and then add a post to that discussion.
-        $record = (object)$fields;
-        $record->course = $forum->course;
-        $record->userid = $author->id;
-        $record->forum = $forum->id;
-        $discussion = $generator->create_discussion($record);
-
-        // Retrieve the post which was created by create_discussion.
-        $post = $DB->get_record('forum_posts', array('discussion' => $discussion->id));
-
-        return array($discussion, $post);
-    }
-
-    /**
-     * Update the post time for the specified post by $factor.
-     *
-     * @param stdClass $post The post to update
-     * @param int $factor The amount to update by
-     */
-    protected function helper_update_post_time($post, $factor) {
-        global $DB;
-
-        // Update the post to have a created in the past.
-        $DB->set_field('forum_posts', 'created', $post->created + $factor, array('id' => $post->id));
-    }
-
-    /**
-     * Update the subscription time for the specified user/discussion by $factor.
-     *
-     * @param stdClass $user The user to update
-     * @param stdClass $discussion The discussion to update for this user
-     * @param int $factor The amount to update by
-     */
-    protected function helper_update_subscription_time($user, $discussion, $factor) {
-        global $DB;
-
-        $sub = $DB->get_record('forum_discussion_subs', array('userid' => $user->id, 'discussion' => $discussion->id));
-
-        // Update the subscription to have a preference in the past.
-        $DB->set_field('forum_discussion_subs', 'preference', $sub->preference + $factor, array('id' => $sub->id));
-    }
-
-    /**
-     * Create a new post within an existing discussion, as the specified author.
-     *
-     * @param stdClass $forum The forum to post in
-     * @param stdClass $discussion The discussion to post in
-     * @param stdClass $author The author to post as
-     * @return stdClass The forum post
-     */
-    protected function helper_post_to_discussion($forum, $discussion, $author) {
-        global $DB;
-
-        $generator = $this->getDataGenerator()->get_plugin_generator('mod_forum');
-
-        // Add a post to the discussion.
-        $record = new stdClass();
-        $record->course = $forum->course;
-        $strre = get_string('re', 'forum');
-        $record->subject = $strre . ' ' . $discussion->subject;
-        $record->userid = $author->id;
-        $record->forum = $forum->id;
-        $record->discussion = $discussion->id;
-        $record->mailnow = 1;
-
-        $post = $generator->create_post($record);
-
-        return $post;
-    }
-
-    /**
-     * Run the forum cron, and check that the specified post was sent the
-     * specified number of times.
-     *
-     * @param stdClass $post The forum post object
-     * @param integer $expected The number of times that the post should have been sent
-     * @return array An array of the messages caught by the message sink
-     */
-    protected function helper_run_cron_check_count($post, $expected) {
-
-        // Clear the sinks before running cron.
-        $this->helper->messagesink->clear();
-        $this->helper->mailsink->clear();
-
-        // Cron daily uses mtrace, turn on buffering to silence output.
-        $this->expectOutputRegex("/{$expected} users were sent post {$post->id}, '{$post->subject}'/");
-        forum_cron();
-
-        // Now check the results in the message sink.
-        $messages = $this->helper->messagesink->get_messages();
-
-        // There should be the expected number of messages.
-        $this->assertEquals($expected, count($messages));
-
-        return $messages;
-    }
-
-    /**
-     * Run the forum cron, and check that the specified posts were sent the
-     * specified number of times.
-     *
-     * @param stdClass $post The forum post object
-     * @param integer $expected The number of times that the post should have been sent
-     * @return array An array of the messages caught by the message sink
-     */
-    protected function helper_run_cron_check_counts($posts, $expected) {
-
-        // Clear the sinks before running cron.
-        $this->helper->messagesink->clear();
-        $this->helper->mailsink->clear();
-
-        // Cron daily uses mtrace, turn on buffering to silence output.
-        foreach ($posts as $post) {
-            $this->expectOutputRegex("/{$post['count']} users were sent post {$post['id']}, '{$post['subject']}'/");
-        }
-        forum_cron();
-
-        // Now check the results in the message sink.
-        $messages = $this->helper->messagesink->get_messages();
-
-        // There should be the expected number of messages.
-        $this->assertEquals($expected, count($messages));
-
-        return $messages;
-    }
-
     public function test_cron_message_includes_courseid() {
         $this->resetAfterTest(true);
 
@@ -268,25 +113,22 @@ class mod_forum_mail_testcase extends advanced_testcase {
         // Post a discussion to the forum.
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
 
-        // Run cron and check that \core\event\message_sent contains the course id.
-        // Close the message sink so that message_send is run.
-        $this->helper->messagesink->close();
-
-        // Catch just the cron events. For each message sent two events are fired:
-        // core\event\message_sent
-        // core\event\message_viewed.
-        $this->helper->eventsink = $this->redirectEvents();
-        $this->expectOutputRegex('/Processing user/');
-
-        forum_cron();
-
-        // Get the events and close the sink so that remaining events can be triggered.
-        $events = $this->helper->eventsink->get_events();
-        $this->helper->eventsink->close();
-
-        // Reset the message sink for other tests.
-        $this->helper->messagesink = $this->redirectMessages();
-        // Notification has been marked as read, so now first event should be a 'notification_viewed' one.
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->messagesink->close();
+        $this->eventsink = $this->redirectEvents();
+        $this->send_notifications_and_assert($author, [$post]);
+        $events = $this->eventsink->get_events();
         $event = reset($events);
         $this->assertInstanceOf('\core\event\notification_viewed', $event);
 
@@ -294,6 +136,8 @@ class mod_forum_mail_testcase extends advanced_testcase {
         $event = $events[1];
         $this->assertInstanceOf('\core\event\notification_sent', $event);
         $this->assertEquals($course->id, $event->other['courseid']);
+
+        $this->send_notifications_and_assert($recipient, [$post]);
     }
 
     public function test_forced_subscription() {
@@ -311,31 +155,26 @@ class mod_forum_mail_testcase extends advanced_testcase {
         // Post a discussion to the forum.
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
 
-        // We expect both users to receive this post.
-        $expected = 2;
-
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, $expected);
-
-        $seenauthor = false;
-        $seenrecipient = false;
-        foreach ($messages as $message) {
-            // They should both be from our user.
-            $this->assertEquals($author->id, $message->useridfrom);
-
-            if ($message->useridto == $author->id) {
-                $seenauthor = true;
-            } else if ($message->useridto = $recipient->id) {
-                $seenrecipient = true;
-            }
-        }
-
-        // Check we saw messages for both users.
-        $this->assertTrue($seenauthor);
-        $this->assertTrue($seenrecipient);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, [$post]);
+        $this->send_notifications_and_assert($recipient, [$post]);
     }
 
-    public function test_subscription_disabled() {
+    /**
+     * Ensure that for a forum with subscription disabled that standard users will not receive posts.
+     */
+    public function test_subscription_disabled_standard_users() {
         global $DB;
 
         $this->resetAfterTest(true);
@@ -352,50 +191,119 @@ class mod_forum_mail_testcase extends advanced_testcase {
         // Post a discussion to the forum.
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
 
-        // We expect both users to receive this post.
-        $expected = 0;
-
         // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, $expected);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 0,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 0,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, []);
+    }
+
+    /**
+     * Ensure that for a forum with subscription disabled that a user subscribed to the forum will receive the post.
+     */
+    public function test_subscription_disabled_user_subscribed_forum() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $options = array('course' => $course->id, 'forcesubscribe' => FORUM_DISALLOWSUBSCRIBE);
+        $forum = $this->getDataGenerator()->create_module('forum', $options);
+
+        // Create two users enrolled in the course as students.
+        list($author, $recipient) = $this->helper_create_users($course, 2);
 
         // A user with the manageactivities capability within the course can subscribe.
-        $expected = 1;
         $roleids = $DB->get_records_menu('role', null, '', 'shortname, id');
         assign_capability('moodle/course:manageactivities', CAP_ALLOW, $roleids['student'], context_course::instance($course->id));
+
+        // Suscribe the recipient only.
         \mod_forum\subscriptions::subscribe_user($recipient->id, $forum);
 
-        $this->assertEquals($expected, $DB->count_records('forum_subscriptions', array(
+        $this->assertEquals(1, $DB->count_records('forum_subscriptions', array(
             'userid'        => $recipient->id,
             'forum'         => $forum->id,
         )));
 
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
+
         // Run cron and check that the expected number of users received the notification.
-        list($discussion, $post) = $this->helper_post_to_forum($forum, $recipient);
-        $messages = $this->helper_run_cron_check_count($post, $expected);
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, [$post]);
+    }
 
-        // Unsubscribe the user again.
-        \mod_forum\subscriptions::unsubscribe_user($recipient->id, $forum);
+    /**
+     * Ensure that for a forum with subscription disabled that a user subscribed to the discussion will receive the
+     * post.
+     */
+    public function test_subscription_disabled_user_subscribed_discussion() {
+        global $DB;
 
-        $expected = 0;
-        $this->assertEquals($expected, $DB->count_records('forum_subscriptions', array(
-            'userid'        => $recipient->id,
-            'forum'         => $forum->id,
-        )));
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $options = array('course' => $course->id, 'forcesubscribe' => FORUM_DISALLOWSUBSCRIBE);
+        $forum = $this->getDataGenerator()->create_module('forum', $options);
+
+        // Create two users enrolled in the course as students.
+        list($author, $recipient) = $this->helper_create_users($course, 2);
+
+        // A user with the manageactivities capability within the course can subscribe.
+        $roleids = $DB->get_records_menu('role', null, '', 'shortname, id');
+        assign_capability('moodle/course:manageactivities', CAP_ALLOW, $roleids['student'], context_course::instance($course->id));
 
         // Run cron and check that the expected number of users received the notification.
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
-        $messages = $this->helper_run_cron_check_count($post, $expected);
 
         // Subscribe the user to the discussion.
         \mod_forum\subscriptions::subscribe_user_to_discussion($recipient->id, $discussion);
         $this->helper_update_subscription_time($recipient, $discussion, -60);
 
-        $reply = $this->helper_post_to_discussion($forum, $discussion, $author);
-        $this->helper_update_post_time($reply, -30);
-
-        $messages = $this->helper_run_cron_check_count($reply, $expected);
+        // Run cron and check that the expected number of users received the notification.
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, [$post]);
     }
 
+    /**
+     * Ensure that for a forum with automatic subscription that users receive posts.
+     */
     public function test_automatic() {
         $this->resetAfterTest(true);
 
@@ -411,28 +319,20 @@ class mod_forum_mail_testcase extends advanced_testcase {
         // Post a discussion to the forum.
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
 
-        // We expect both users to receive this post.
-        $expected = 2;
-
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, $expected);
-
-        $seenauthor = false;
-        $seenrecipient = false;
-        foreach ($messages as $message) {
-            // They should both be from our user.
-            $this->assertEquals($author->id, $message->useridfrom);
-
-            if ($message->useridto == $author->id) {
-                $seenauthor = true;
-            } else if ($message->useridto = $recipient->id) {
-                $seenrecipient = true;
-            }
-        }
-
-        // Check we saw messages for both users.
-        $this->assertTrue($seenauthor);
-        $this->assertTrue($seenrecipient);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, [$post]);
+        $this->send_notifications_and_assert($recipient, [$post]);
     }
 
     public function test_optional() {
@@ -450,11 +350,20 @@ class mod_forum_mail_testcase extends advanced_testcase {
         // Post a discussion to the forum.
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
 
-        // We expect both users to receive this post.
-        $expected = 0;
-
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, $expected);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 0,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 0,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, []);
     }
 
     public function test_automatic_with_unsubscribed_user() {
@@ -475,28 +384,20 @@ class mod_forum_mail_testcase extends advanced_testcase {
         // Post a discussion to the forum.
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
 
-        // We expect only one user to receive this post.
-        $expected = 1;
-
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, $expected);
-
-        $seenauthor = false;
-        $seenrecipient = false;
-        foreach ($messages as $message) {
-            // They should both be from our user.
-            $this->assertEquals($author->id, $message->useridfrom);
-
-            if ($message->useridto == $author->id) {
-                $seenauthor = true;
-            } else if ($message->useridto = $recipient->id) {
-                $seenrecipient = true;
-            }
-        }
-
-        // Check we only saw one user.
-        $this->assertFalse($seenauthor);
-        $this->assertTrue($seenrecipient);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 0,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, [$post]);
     }
 
     public function test_optional_with_subscribed_user() {
@@ -517,28 +418,20 @@ class mod_forum_mail_testcase extends advanced_testcase {
         // Post a discussion to the forum.
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
 
-        // We expect only one user to receive this post.
-        $expected = 1;
-
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, $expected);
-
-        $seenauthor = false;
-        $seenrecipient = false;
-        foreach ($messages as $message) {
-            // They should both be from our user.
-            $this->assertEquals($author->id, $message->useridfrom);
-
-            if ($message->useridto == $author->id) {
-                $seenauthor = true;
-            } else if ($message->useridto = $recipient->id) {
-                $seenrecipient = true;
-            }
-        }
-
-        // Check we only saw one user.
-        $this->assertFalse($seenauthor);
-        $this->assertTrue($seenrecipient);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 0,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, [$post]);
     }
 
     public function test_automatic_with_unsubscribed_discussion() {
@@ -562,28 +455,20 @@ class mod_forum_mail_testcase extends advanced_testcase {
         $this->assertFalse(\mod_forum\subscriptions::is_subscribed($author->id, $forum, $discussion->id));
         $this->assertTrue(\mod_forum\subscriptions::is_subscribed($recipient->id, $forum, $discussion->id));
 
-        // We expect only one user to receive this post.
-        $expected = 1;
-
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, $expected);
-
-        $seenauthor = false;
-        $seenrecipient = false;
-        foreach ($messages as $message) {
-            // They should both be from our user.
-            $this->assertEquals($author->id, $message->useridfrom);
-
-            if ($message->useridto == $author->id) {
-                $seenauthor = true;
-            } else if ($message->useridto = $recipient->id) {
-                $seenrecipient = true;
-            }
-        }
-
-        // Check we only saw one user.
-        $this->assertFalse($seenauthor);
-        $this->assertTrue($seenrecipient);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 0,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, [$post]);
     }
 
     public function test_optional_with_subscribed_discussion() {
@@ -608,37 +493,86 @@ class mod_forum_mail_testcase extends advanced_testcase {
 
         // Initially we don't expect any user to receive this post as you cannot subscribe to a discussion until after
         // you have read it.
-        $expected = 0;
-
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, $expected);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 0,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 0,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, []);
 
         // Have a user reply to the discussion.
         $reply = $this->helper_post_to_discussion($forum, $discussion, $author);
         $this->helper_update_post_time($reply, -30);
 
         // We expect only one user to receive this post.
-        $expected = 1;
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 0,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, [$reply]);
+    }
 
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($reply, $expected);
+    public function test_optional_with_subscribed_discussion_and_post() {
+        $this->resetAfterTest(true);
 
-        $seenauthor = false;
-        $seenrecipient = false;
-        foreach ($messages as $message) {
-            // They should both be from our user.
-            $this->assertEquals($author->id, $message->useridfrom);
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
 
-            if ($message->useridto == $author->id) {
-                $seenauthor = true;
-            } else if ($message->useridto = $recipient->id) {
-                $seenrecipient = true;
-            }
-        }
+        $options = array('course' => $course->id, 'forcesubscribe' => FORUM_CHOOSESUBSCRIBE);
+        $forum = $this->getDataGenerator()->create_module('forum', $options);
+
+        // Create two users enrolled in the course as students.
+        list($author, $recipient) = $this->helper_create_users($course, 2);
+
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
+        $this->helper_update_post_time($post, -90);
 
-        // Check we only saw one user.
-        $this->assertFalse($seenauthor);
-        $this->assertTrue($seenrecipient);
+        // Have a user reply to the discussion before we subscribed.
+        $reply = $this->helper_post_to_discussion($forum, $discussion, $author);
+        $this->helper_update_post_time($reply, -75);
+
+        // Subscribe the 'recipient' user to the discussion.
+        \mod_forum\subscriptions::subscribe_user_to_discussion($recipient->id, $discussion);
+        $this->helper_update_subscription_time($recipient, $discussion, -60);
+
+        // Have a user reply to the discussion.
+        $reply = $this->helper_post_to_discussion($forum, $discussion, $author);
+        $this->helper_update_post_time($reply, -30);
+
+        // We expect only one user to receive this post.
+        // The original post won't be received as it was written before the user subscribed.
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 0,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, [$reply]);
     }
 
     public function test_automatic_with_subscribed_discussion_in_unsubscribed_forum() {
@@ -664,56 +598,39 @@ class mod_forum_mail_testcase extends advanced_testcase {
         \mod_forum\subscriptions::subscribe_user_to_discussion($author->id, $discussion);
         $this->helper_update_subscription_time($author, $discussion, -60);
 
-        // We expect just the user subscribed to the forum to receive this post at the moment as the discussion
-        // subscription time is after the post time.
-        $expected = 1;
-
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, $expected);
-
-        $seenauthor = false;
-        $seenrecipient = false;
-        foreach ($messages as $message) {
-            // They should both be from our user.
-            $this->assertEquals($author->id, $message->useridfrom);
-
-            if ($message->useridto == $author->id) {
-                $seenauthor = true;
-            } else if ($message->useridto = $recipient->id) {
-                $seenrecipient = true;
-            }
-        }
-
-        // Check we only saw one user.
-        $this->assertFalse($seenauthor);
-        $this->assertTrue($seenrecipient);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 0,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, [$post]);
 
         // Now post a reply to the original post.
         $reply = $this->helper_post_to_discussion($forum, $discussion, $author);
         $this->helper_update_post_time($reply, -30);
 
-        // We expect two users to receive this post.
-        $expected = 2;
-
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($reply, $expected);
-
-        $seenauthor = false;
-        $seenrecipient = false;
-        foreach ($messages as $message) {
-            // They should both be from our user.
-            $this->assertEquals($author->id, $message->useridfrom);
-
-            if ($message->useridto == $author->id) {
-                $seenauthor = true;
-            } else if ($message->useridto = $recipient->id) {
-                $seenrecipient = true;
-            }
-        }
-
-        // Check we saw both users.
-        $this->assertTrue($seenauthor);
-        $this->assertTrue($seenrecipient);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, [$reply]);
+        $this->send_notifications_and_assert($recipient, [$reply]);
     }
 
     public function test_optional_with_unsubscribed_discussion_in_subscribed_forum() {
@@ -738,10 +655,20 @@ class mod_forum_mail_testcase extends advanced_testcase {
         \mod_forum\subscriptions::unsubscribe_user_from_discussion($recipient->id, $discussion);
 
         // We don't expect any users to receive this post.
-        $expected = 0;
-
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, $expected);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 0,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 0,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, []);
     }
 
     /**
@@ -780,16 +707,15 @@ class mod_forum_mail_testcase extends advanced_testcase {
         $reply = $this->helper_post_to_discussion($forum, $discussion, $author);
         $this->helper_update_post_time($reply, -30);
 
-        $expectedmessages[] = array(
-            'id' => $reply->id,
-            'subject' => $reply->subject,
-            'count' => 1,
-        );
-
-        $expectedcount = 1;
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
 
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_counts($expectedmessages, $expectedcount);
+        $this->send_notifications_and_assert($author, [$reply]);
     }
 
     public function test_forum_message_inbound_multiple_posts() {
@@ -809,23 +735,21 @@ class mod_forum_mail_testcase extends advanced_testcase {
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
         $this->helper_update_post_time($post, -90);
 
-        $expectedmessages[] = array(
+        $expectedmessages[] = (object) [
             'id' => $post->id,
             'subject' => $post->subject,
             'count' => 0,
-        );
+        ];
 
         // Then post a reply to the first discussion.
         $reply = $this->helper_post_to_discussion($forum, $discussion, $author);
         $this->helper_update_post_time($reply, -60);
 
-        $expectedmessages[] = array(
+        $expectedmessages[] = (object) [
             'id' => $reply->id,
             'subject' => $reply->subject,
             'count' => 1,
-        );
-
-        $expectedcount = 2;
+        ];
 
         // Ensure that messageinbound is enabled and configured for the forum handler.
         $this->helper_spoof_message_inbound_setup();
@@ -836,19 +760,22 @@ class mod_forum_mail_testcase extends advanced_testcase {
 
         // Run cron and check that the expected number of users received the notification.
         // Clear the mailsink, and close the messagesink.
-        $this->helper->mailsink->clear();
-        $this->helper->messagesink->close();
+        $this->mailsink->clear();
+        $this->messagesink->close();
 
-        // Cron daily uses mtrace, turn on buffering to silence output.
-        foreach ($expectedmessages as $post) {
-            $this->expectOutputRegex("/{$post['count']} users were sent post {$post['id']}, '{$post['subject']}'/");
-        }
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => count($expectedmessages),
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
 
-        forum_cron();
-        $messages = $this->helper->mailsink->get_messages();
+        $this->send_notifications_and_assert($author, $expectedmessages);
+        $messages = $this->mailsink->get_messages();
 
         // There should be the expected number of messages.
-        $this->assertEquals($expectedcount, count($messages));
+        $this->assertEquals(2, count($messages));
 
         foreach ($messages as $message) {
             $this->assertRegExp('/Reply-To: moodlemoodle123\+[^@]*@example.com/', $message->header);
@@ -874,7 +801,16 @@ class mod_forum_mail_testcase extends advanced_testcase {
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author, array('name' => $subject));
 
         // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, 1);
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, [$post]);
+        $messages = $this->messagesink->get_messages();
         $message = reset($messages);
         $this->assertEquals($author->id, $message->useridfrom);
         $this->assertEquals($expectedsubject, $message->subject);
@@ -898,13 +834,44 @@ class mod_forum_mail_testcase extends advanced_testcase {
 
         // New posts should not have Re: in the subject.
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
-        $messages = $this->helper_run_cron_check_count($post, 2);
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            'commenter' => (object) [
+                'userid' => $commenter->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, [$post]);
+        $this->send_notifications_and_assert($commenter, [$post]);
+        $messages = $this->messagesink->get_messages();
         $this->assertNotContains($strre, $messages[0]->subject);
+        $this->messagesink->clear();
 
         // Replies should have Re: in the subject.
         $reply = $this->helper_post_to_discussion($forum, $discussion, $commenter);
-        $messages = $this->helper_run_cron_check_count($reply, 2);
+
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            'commenter' => (object) [
+                'userid' => $commenter->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($commenter, [$reply]);
+        $this->send_notifications_and_assert($author, [$reply]);
+        $messages = $this->messagesink->get_messages();
         $this->assertContains($strre, $messages[0]->subject);
+        $this->assertContains($strre, $messages[1]->subject);
     }
 
     /**
@@ -1127,17 +1094,21 @@ class mod_forum_mail_testcase extends advanced_testcase {
 
         // Clear the mailsink and close the messagesink.
         // (surely setup should provide us this cleared but...)
-        $this->helper->mailsink->clear();
-        $this->helper->messagesink->close();
+        $this->mailsink->clear();
+        $this->messagesink->close();
 
-        // Capture and silence cron output, verifying contents.
-        foreach ($posts as $post) {
-            $this->expectOutputRegex("/1 users were sent post {$post->id}, '{$post->subject}'/");
-        }
-        forum_cron(); // It's really annoying that we have to run cron to test this.
+        $expect = [
+            'author' => (object) [
+                'userid' => $user->id,
+                'messages' => count($posts),
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($user, $posts);
 
         // Get the mails.
-        $mails = $this->helper->mailsink->get_messages();
+        $mails = $this->mailsink->get_messages();
 
         // Start testing the expectations.
         $expectations = $data['expectations'];
@@ -1182,7 +1153,301 @@ class mod_forum_mail_testcase extends advanced_testcase {
                 }
             }
         }
+
         // Finished, there should not be remaining expectations.
         $this->assertCount(0, $expectations);
     }
+
+    /**
+     * Ensure that posts already mailed are not re-sent.
+     */
+    public function test_already_mailed() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $options = array('course' => $course->id, 'forcesubscribe' => FORUM_INITIALSUBSCRIBE);
+        $forum = $this->getDataGenerator()->create_module('forum', $options);
+
+        // Create two users enrolled in the course as students.
+        list($author, $recipient) = $this->helper_create_users($course, 2);
+
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
+        $DB->set_field('forum_posts', 'mailed', 1);
+
+        // No posts shoudl be considered.
+        $this->queue_tasks_and_assert([]);
+
+        // No notifications should be queued.
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, []);
+    }
+
+    /**
+     * Ensure that posts marked mailnow are not suspect to the maxeditingtime.
+     */
+    public function test_mailnow() {
+        global $CFG, $DB;
+
+        // Update the maxeditingtime to 1 day so that posts won't be sent.
+        $CFG->maxeditingtime = DAYSECS;
+
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $options = array('course' => $course->id, 'forcesubscribe' => FORUM_INITIALSUBSCRIBE);
+        $forum = $this->getDataGenerator()->create_module('forum', $options);
+
+        // Create two users enrolled in the course as students.
+        list($author, $recipient) = $this->helper_create_users($course, 2);
+
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
+
+        // Post a discussion to the forum.
+        list($discussion, $postmailednow) = $this->helper_post_to_forum($forum, $author, ['mailnow' => 1]);
+
+        // Only the mailnow post should be considered.
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        // No notifications should be queued.
+        $this->send_notifications_and_assert($author, [$postmailednow]);
+        $this->send_notifications_and_assert($recipient, [$postmailednow]);
+    }
+
+    /**
+     * Ensure that if a user has no permission to view a post, then it is not sent.
+     */
+    public function test_access_coursemodule_hidden() {
+        global $CFG, $DB;
+
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $options = array('course' => $course->id, 'forcesubscribe' => FORUM_INITIALSUBSCRIBE);
+        $forum = $this->getDataGenerator()->create_module('forum', $options);
+
+        // Create two users enrolled in the course as students.
+        list($author, $recipient) = $this->helper_create_users($course, 2);
+
+        // Create one users enrolled in the course as an editing teacher.
+        list($editor) = $this->helper_create_users($course, 1, 'editingteacher');
+
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
+
+        // Hide the coursemodule.
+        set_coursemodule_visible($forum->cmid, 0);
+
+        // Only the mailnow post should be considered.
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+            'editor' => (object) [
+                'userid' => $editor->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        // No notifications should be queued.
+        $this->send_notifications_and_assert($author, [], true);
+        $this->send_notifications_and_assert($recipient, [], true);
+        $this->send_notifications_and_assert($editor, [$post], true);
+    }
+
+    /**
+     * Ensure that if a user loses permission to view a post after it is queued, that it is not sent.
+     */
+    public function test_access_coursemodule_hidden_after_queue() {
+        global $CFG, $DB;
+
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $options = array('course' => $course->id, 'forcesubscribe' => FORUM_INITIALSUBSCRIBE);
+        $forum = $this->getDataGenerator()->create_module('forum', $options);
+
+        // Create two users enrolled in the course as students.
+        list($author, $recipient) = $this->helper_create_users($course, 2);
+
+        // Create one users enrolled in the course as an editing teacher.
+        list($editor) = $this->helper_create_users($course, 1, 'editingteacher');
+
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
+
+        // Only the mailnow post should be considered.
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+            'editor' => (object) [
+                'userid' => $editor->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        // Hide the coursemodule.
+        set_coursemodule_visible($forum->cmid, 0);
+
+        // No notifications should be queued for the students.
+        $this->send_notifications_and_assert($author, [], true);
+        $this->send_notifications_and_assert($recipient, [], true);
+
+        // The editing teacher should still receive the post.
+        $this->send_notifications_and_assert($editor, [$post]);
+    }
+
+    /**
+     * Ensure that messages are not sent until the timestart.
+     */
+    public function test_access_before_timestart() {
+        global $CFG, $DB;
+
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $options = array('course' => $course->id, 'forcesubscribe' => FORUM_INITIALSUBSCRIBE);
+        $forum = $this->getDataGenerator()->create_module('forum', $options);
+
+        // Create two users enrolled in the course as students.
+        list($author, $recipient) = $this->helper_create_users($course, 2);
+
+        // Create one users enrolled in the course as an editing teacher.
+        list($editor) = $this->helper_create_users($course, 1, 'editingteacher');
+
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
+
+        // Update the discussion to have a timestart in the future.
+        $DB->set_field('forum_discussions', 'timestart', time() + DAYSECS);
+
+        // None should be sent.
+        $this->queue_tasks_and_assert([]);
+
+        // No notifications should be queued for any user.
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, []);
+        $this->send_notifications_and_assert($editor, []);
+
+        // Update the discussion to have a timestart in the past.
+        $DB->set_field('forum_discussions', 'timestart', time() - DAYSECS);
+
+        // Now should be sent to all.
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+            'editor' => (object) [
+                'userid' => $editor->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        // No notifications should be queued for any user.
+        $this->send_notifications_and_assert($author, [$post]);
+        $this->send_notifications_and_assert($recipient, [$post]);
+        $this->send_notifications_and_assert($editor, [$post]);
+    }
+
+    /**
+     * Ensure that messages are not sent after the timeend.
+     */
+    public function test_access_after_timeend() {
+        global $CFG, $DB;
+
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $options = array('course' => $course->id, 'forcesubscribe' => FORUM_INITIALSUBSCRIBE);
+        $forum = $this->getDataGenerator()->create_module('forum', $options);
+
+        // Create two users enrolled in the course as students.
+        list($author, $recipient) = $this->helper_create_users($course, 2);
+
+        // Create one users enrolled in the course as an editing teacher.
+        list($editor) = $this->helper_create_users($course, 1, 'editingteacher');
+
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
+
+        // Update the discussion to have a timestart in the past.
+        $DB->set_field('forum_discussions', 'timeend', time() - DAYSECS);
+
+        // None should be sent.
+        $this->queue_tasks_and_assert([]);
+
+        // No notifications should be queued for any user.
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, []);
+        $this->send_notifications_and_assert($editor, []);
+
+        // Update the discussion to have a timestart in the past.
+        $DB->set_field('forum_discussions', 'timeend', time() + DAYSECS);
+
+        // Now should be sent to all.
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+            'editor' => (object) [
+                'userid' => $editor->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        // No notifications should be queued for any user.
+        $this->send_notifications_and_assert($author, [$post]);
+        $this->send_notifications_and_assert($recipient, [$post]);
+        $this->send_notifications_and_assert($editor, [$post]);
+    }
 }
index 8dbb5e1..86a3613 100644 (file)
 
 defined('MOODLE_INTERNAL') || die();
 
-global $CFG;
+require_once(__DIR__ . '/cron_trait.php');
+require_once(__DIR__ . '/generator_trait.php');
 
 class mod_forum_maildigest_testcase extends advanced_testcase {
 
-    /**
-     * Keep track of the message and mail sinks that we set up for each
-     * test.
-     *
-     * @var stdClass $helper
-     */
-    protected $helper;
+    // Make use of the cron tester trait.
+    use mod_forum_tests_cron_trait;
+
+    // Make use of the test generator trait.
+    use mod_forum_tests_generator_trait;
 
     /**
      * Set up message and mail sinks, and set up other requirements for the
@@ -45,20 +44,18 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
     public function setUp() {
         global $CFG;
 
-        $this->helper = new stdClass();
-
         // Messaging is not compatible with transactions...
         $this->preventResetByRollback();
 
         // Catch all messages
-        $this->helper->messagesink = $this->redirectMessages();
-        $this->helper->mailsink = $this->redirectEmails();
+        $this->messagesink = $this->redirectMessages();
+        $this->mailsink = $this->redirectEmails();
 
         // Confirm that we have an empty message sink so far.
-        $messages = $this->helper->messagesink->get_messages();
+        $messages = $this->messagesink->get_messages();
         $this->assertEquals(0, count($messages));
 
-        $messages = $this->helper->mailsink->get_messages();
+        $messages = $this->mailsink->get_messages();
         $this->assertEquals(0, count($messages));
 
         // Tell Moodle that we've not sent any digest messages out recently.
@@ -82,11 +79,11 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
      * Clear the message sinks set up in this test.
      */
     public function tearDown() {
-        $this->helper->messagesink->clear();
-        $this->helper->messagesink->close();
+        $this->messagesink->clear();
+        $this->messagesink->close();
 
-        $this->helper->mailsink->clear();
-        $this->helper->mailsink->close();
+        $this->mailsink->clear();
+        $this->mailsink->close();
     }
 
     /**
@@ -131,52 +128,6 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         return $return;
     }
 
-    /**
-     * Helper to falsify all forum post records for a digest run.
-     */
-    protected function helper_force_digest_mail_times() {
-        global $CFG, $DB;
-        // Fake all of the post editing times because digests aren't sent until
-        // the start of an hour where the modification time on the message is before
-        // the start of that hour
-        $sitetimezone = core_date::get_server_timezone();
-        $digesttime = usergetmidnight(time(), $sitetimezone) + ($CFG->digestmailtime * 3600) - (60 * 60);
-        $DB->set_field('forum_posts', 'modified', $digesttime, array('mailed' => 0));
-        $DB->set_field('forum_posts', 'created', $digesttime, array('mailed' => 0));
-    }
-
-    /**
-     * Run the forum cron, and check that the specified post was sent the
-     * specified number of times.
-     *
-     * @param integer $expected The number of times that the post should have been sent
-     * @param integer $individualcount The number of individual messages sent
-     * @param integer $digestcount The number of digest messages sent
-     */
-    protected function helper_run_cron_check_count($expected, $individualcount, $digestcount) {
-        if ($expected === 0) {
-            $this->expectOutputRegex('/(Email digests successfully sent to .* users.){0}/');
-        } else {
-            $this->expectOutputRegex("/Email digests successfully sent to {$expected} users/");
-        }
-        forum_cron();
-
-        // Now check the results in the message sink.
-        $messages = $this->helper->messagesink->get_messages();
-
-        $counts = (object) array('digest' => 0, 'individual' => 0);
-        foreach ($messages as $message) {
-            if (strpos($message->subject, 'forum digest') !== false) {
-                $counts->digest++;
-            } else {
-                $counts->individual++;
-            }
-        }
-
-        $this->assertEquals($digestcount, $counts->digest);
-        $this->assertEquals($individualcount, $counts->individual);
-    }
-
     public function test_set_maildigest() {
         global $DB;
 
@@ -302,10 +253,9 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
 
         $this->resetAfterTest(true);
 
-        $this->helper_force_digest_mail_times();
-
         // Initially the forum cron should generate no messages as we've made no posts.
-        $this->helper_run_cron_check_count(0, 0, 0);
+        $expect = [];
+        $this->queue_tasks_and_assert($expect);
     }
 
     /**
@@ -324,27 +274,19 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         $forum1 = $userhelper->forums->forum1;
         $forum2 = $userhelper->forums->forum2;
 
-        // Add some discussions to the forums.
-        $record = new stdClass();
-        $record->course = $course1->id;
-        $record->userid = $user->id;
-        $record->mailnow = 1;
-
         // Add 5 discussions to forum 1.
-        $record->forum = $forum1->id;
+        $posts = [];
         for ($i = 0; $i < 5; $i++) {
-            $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+            list($discussion, $post) = $this->helper_post_to_forum($forum1, $user, ['mailnow' => 1]);
+            $posts[] = $post;
         }
 
         // Add 5 discussions to forum 2.
-        $record->forum = $forum2->id;
         for ($i = 0; $i < 5; $i++) {
-            $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+            list($discussion, $post) = $this->helper_post_to_forum($forum2, $user, ['mailnow' => 1]);
+            $posts[] = $post;
         }
 
-        // Ensure that the creation times mean that the messages will be sent.
-        $this->helper_force_digest_mail_times();
-
         // Set the tested user's default maildigest setting.
         $DB->set_field('user', 'maildigest', 0, array('id' => $user->id));
 
@@ -355,7 +297,16 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         forum_set_user_maildigest($forum2, -1, $user);
 
         // No digests mails should be sent, but 10 forum mails will be sent.
-        $this->helper_run_cron_check_count(0, 10, 0);
+        $expect = [
+            (object) [
+                'userid' => $user->id,
+                'messages' => 10,
+                'digests' => 0,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($user, $posts);
     }
 
     /**
@@ -373,28 +324,20 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         $course1 = $userhelper->courses->course1;
         $forum1 = $userhelper->forums->forum1;
         $forum2 = $userhelper->forums->forum2;
-
-        // Add a discussion to the forums.
-        $record = new stdClass();
-        $record->course = $course1->id;
-        $record->userid = $user->id;
-        $record->mailnow = 1;
+        $posts = [];
 
         // Add 5 discussions to forum 1.
-        $record->forum = $forum1->id;
         for ($i = 0; $i < 5; $i++) {
-            $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+            list($discussion, $post) = $this->helper_post_to_forum($forum1, $user, ['mailnow' => 1]);
+            $posts[] = $post;
         }
 
         // Add 5 discussions to forum 2.
-        $record->forum = $forum2->id;
         for ($i = 0; $i < 5; $i++) {
-            $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+            list($discussion, $post) = $this->helper_post_to_forum($forum2, $user, ['mailnow' => 1]);
+            $posts[] = $post;
         }
 
-        // Ensure that the creation times mean that the messages will be sent.
-        $this->helper_force_digest_mail_times();
-
         // Set the tested user's default maildigest setting.
         $DB->set_field('user', 'maildigest', 1, array('id' => $user->id));
 
@@ -404,8 +347,17 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         // Set the maildigest preference for forum2 to default.
         forum_set_user_maildigest($forum2, -1, $user);
 
-        // One digest mail should be sent, with no notifications, and one e-mail.
-        $this->helper_run_cron_check_count(1, 0, 1);
+        // No digests mails should be sent, but 10 forum mails will be sent.
+        $expect = [
+            (object) [
+                'userid' => $user->id,
+                'messages' => 0,
+                'digests' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_digests_and_assert($user, $posts);
     }
 
     /**
@@ -424,28 +376,21 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         $course1 = $userhelper->courses->course1;
         $forum1 = $userhelper->forums->forum1;
         $forum2 = $userhelper->forums->forum2;
-
-        // Add a discussion to the forums.
-        $record = new stdClass();
-        $record->course = $course1->id;
-        $record->userid = $user->id;
-        $record->mailnow = 1;
+        $posts = [];
+        $digests = [];
 
         // Add 5 discussions to forum 1.
-        $record->forum = $forum1->id;
         for ($i = 0; $i < 5; $i++) {
-            $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+            list($discussion, $post) = $this->helper_post_to_forum($forum1, $user, ['mailnow' => 1]);
+            $digests[] = $post;
         }
 
         // Add 5 discussions to forum 2.
-        $record->forum = $forum2->id;
         for ($i = 0; $i < 5; $i++) {
-            $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+            list($discussion, $post) = $this->helper_post_to_forum($forum2, $user, ['mailnow' => 1]);
+            $posts[] = $post;
         }
 
-        // Ensure that the creation times mean that the messages will be sent.
-        $this->helper_force_digest_mail_times();
-
         // Set the tested user's default maildigest setting.
         $DB->set_field('user', 'maildigest', 0, array('id' => $user->id));
 
@@ -456,7 +401,17 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         forum_set_user_maildigest($forum2, -1, $user);
 
         // One digest e-mail should be sent, and five individual notifications.
-        $this->helper_run_cron_check_count(1, 5, 1);
+        $expect = [
+            (object) [
+                'userid' => $user->id,
+                'messages' => 5,
+                'digests' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($user, $posts);
+        $this->send_digests_and_assert($user, $digests);
     }
 
     /**
@@ -475,28 +430,21 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         $course1 = $userhelper->courses->course1;
         $forum1 = $userhelper->forums->forum1;
         $forum2 = $userhelper->forums->forum2;
-
-        // Add a discussion to the forums.
-        $record = new stdClass();
-        $record->course = $course1->id;
-        $record->userid = $user->id;
-        $record->mailnow = 1;
+        $posts = [];
+        $digests = [];
 
         // Add 5 discussions to forum 1.
-        $record->forum = $forum1->id;
         for ($i = 0; $i < 5; $i++) {
-            $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+            list($discussion, $post) = $this->helper_post_to_forum($forum1, $user, ['mailnow' => 1]);
+            $digests[] = $post;
         }
 
         // Add 5 discussions to forum 2.
-        $record->forum = $forum2->id;
         for ($i = 0; $i < 5; $i++) {
-            $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+            list($discussion, $post) = $this->helper_post_to_forum($forum2, $user, ['mailnow' => 1]);
+            $posts[] = $post;
         }
 
-        // Ensure that the creation times mean that the messages will be sent.
-        $this->helper_force_digest_mail_times();
-
         // Set the tested user's default maildigest setting.
         $DB->set_field('user', 'maildigest', 1, array('id' => $user->id));
 
@@ -507,7 +455,17 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         forum_set_user_maildigest($forum2, 0, $user);
 
         // One digest e-mail should be sent, and five individual notifications.
-        $this->helper_run_cron_check_count(1, 5, 1);
+        $expect = [
+            (object) [
+                'userid' => $user->id,
+                'messages' => 5,
+                'digests' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($user, $posts);
+        $this->send_digests_and_assert($user, $digests);
     }
 
     /**
@@ -525,28 +483,21 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         $course1 = $userhelper->courses->course1;
         $forum1 = $userhelper->forums->forum1;
         $forum2 = $userhelper->forums->forum2;
-
-        // Add a discussion to the forums.
-        $record = new stdClass();
-        $record->course = $course1->id;
-        $record->userid = $user->id;
-        $record->mailnow = 1;
+        $fulldigests = [];
+        $shortdigests = [];
 
         // Add 5 discussions to forum 1.
-        $record->forum = $forum1->id;
         for ($i = 0; $i < 5; $i++) {
-            $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+            list($discussion, $post) = $this->helper_post_to_forum($forum1, $user, ['mailnow' => 1]);
+            $fulldigests[] = $post;
         }
 
         // Add 5 discussions to forum 2.
-        $record->forum = $forum2->id;
         for ($i = 0; $i < 5; $i++) {
-            $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+            list($discussion, $post) = $this->helper_post_to_forum($forum2, $user, ['mailnow' => 1]);
+            $shortdigests[] = $post;
         }
 
-        // Ensure that the creation times mean that the messages will be sent.
-        $this->helper_force_digest_mail_times();
-
         // Set the tested user's default maildigest setting.
         $DB->set_field('user', 'maildigest', 0, array('id' => $user->id));
 
@@ -557,7 +508,14 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         forum_set_user_maildigest($forum2, 2, $user);
 
         // One digest e-mail should be sent, and no individual notifications.
-        $this->helper_run_cron_check_count(1, 0, 1);
+        $expect = [
+            (object) [
+                'userid' => $user->id,
+                'digests' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_digests_and_assert($user, $fulldigests, $shortdigests);
     }
-
 }
diff --git a/mod/forum/tests/qanda_test.php b/mod/forum/tests/qanda_test.php
new file mode 100644 (file)
index 0000000..3e47952
--- /dev/null
@@ -0,0 +1,153 @@
+<?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/>.
+
+/**
+ * The forum module mail generation tests for groups.
+ *
+ * @package    mod_forum
+ * @copyright  2013 Andrew Nicols
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/mod/forum/lib.php');
+require_once(__DIR__ . '/cron_trait.php');
+require_once(__DIR__ . '/generator_trait.php');
+
+/**
+ * The forum module mail generation tests for groups.
+ *
+ * @copyright  2013 Andrew Nicols
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mod_forum_qanda_testcase extends advanced_testcase {
+    // Make use of the cron tester trait.
+    use mod_forum_tests_cron_trait;
+
+    // Make use of the test generator trait.
+    use mod_forum_tests_generator_trait;
+
+    /**
+     * @var \phpunit_message_sink
+     */
+    protected $messagesink;
+
+    /**
+     * @var \phpunit_mailer_sink
+     */
+    protected $mailsink;
+
+    public function setUp() {
+        global $CFG;
+
+        // We must clear the subscription caches. This has to be done both before each test, and after in case of other
+        // tests using these functions.
+        \mod_forum\subscriptions::reset_forum_cache();
+        \mod_forum\subscriptions::reset_discussion_cache();
+
+        // Messaging is not compatible with transactions...
+        $this->preventResetByRollback();
+
+        // Catch all messages.
+        $this->messagesink = $this->redirectMessages();
+        $this->mailsink = $this->redirectEmails();
+
+        // Forcibly reduce the maxeditingtime to a second in the past to
+        // ensure that messages are sent out.
+        $CFG->maxeditingtime = -1;
+    }
+
+    public function tearDown() {
+        // We must clear the subscription caches. This has to be done both before each test, and after in case of other
+        // tests using these functions.
+        \mod_forum\subscriptions::reset_forum_cache();
+
+        $this->messagesink->clear();
+        $this->messagesink->close();
+        unset($this->messagesink);
+
+        $this->mailsink->clear();
+        $this->mailsink->close();
+        unset($this->mailsink);
+    }
+
+    /**
+     * Test that a user who has not posted in a q&a forum does not receive
+     * notificatinos.
+     */
+    public function test_user_has_not_posted() {
+        global $CFG, $DB;
+
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $forum = $this->getDataGenerator()->create_module('forum', [
+            'course' => $course->id,
+            'forcesubscribe' => FORUM_INITIALSUBSCRIBE,
+            'groupmode' => SEPARATEGROUPS,
+            'type' => 'qanda',
+        ]);
+
+        // Create three students:
+        // - author, enrolled in group A; and
+        // - recipient, enrolled in group B; and
+        // - other, enrolled in the course, but no groups.
+        list($author, $recipient, $otheruser) = $this->helper_create_users($course, 3);
+
+        // Create one editing teacher, not in any group but with accessallgroups capability.
+        list($editingteacher) = $this->helper_create_users($course, 1, 'editingteacher');
+
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $editingteacher);
+        $reply = $this->helper_reply_to_post($post, $author);
+        $otherreply = $this->helper_reply_to_post($post, $recipient);
+        $DB->execute("UPDATE {forum_posts} SET modified = modified - 1");
+        $DB->execute("UPDATE {forum_posts} SET created = created - 1");
+        $DB->execute("UPDATE {forum_discussions} SET timemodified = timemodified - 1");
+
+        // Only the author, recipient, and teachers should receive.
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 3,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 3,
+            ],
+            'otheruser' => (object) [
+                'userid' => $otheruser->id,
+                'messages' => 3,
+            ],
+            'editingteacher' => (object) [
+                'userid' => $editingteacher->id,
+                'messages' => 3,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+        $posts = [$post, $reply, $otherreply];
+
+        // No notifications should be queued.
+        $this->send_notifications_and_assert($author, $posts);
+        $this->send_notifications_and_assert($recipient, $posts);
+        $this->send_notifications_and_assert($otheruser, [$post]);
+        $this->send_notifications_and_assert($editingteacher, $posts);
+    }
+}
index c65a6ca..c38dbe9 100644 (file)
@@ -1307,6 +1307,7 @@ abstract class lesson_add_page_form_base extends moodleform {
      * and then calls custom_definition();
      */
     public final function definition() {
+        global $CFG;
         $mform = $this->_form;
         $editoroptions = $this->_customdata['editoroptions'];
 
@@ -1334,8 +1335,12 @@ abstract class lesson_add_page_form_base extends moodleform {
             $mform->setType('qtype', PARAM_INT);
 
             $mform->addElement('text', 'title', get_string('pagetitle', 'lesson'), array('size'=>70));
-            $mform->setType('title', PARAM_TEXT);
             $mform->addRule('title', get_string('required'), 'required', null, 'client');
+            if (!empty($CFG->formatstringstriptags)) {
+                $mform->setType('title', PARAM_TEXT);
+            } else {
+                $mform->setType('title', PARAM_CLEANHTML);
+            }
 
             $this->editoroptions = array('noclean'=>true, 'maxfiles'=>EDITOR_UNLIMITED_FILES, 'maxbytes'=>$this->_customdata['maxbytes']);
             $mform->addElement('editor', 'contents_editor', get_string('pagecontents', 'lesson'), null, $this->editoroptions);
index dec96a9..9838069 100644 (file)
@@ -315,7 +315,7 @@ class lesson_add_page_form_branchtable extends lesson_add_page_form_base {
     protected $standard = false;
 
     public function custom_definition() {
-        global $PAGE;
+        global $PAGE, $CFG;
 
         $mform = $this->_form;
         $lesson = $this->_customdata['lesson'];
@@ -338,8 +338,12 @@ class lesson_add_page_form_branchtable extends lesson_add_page_form_base {
         $mform->setType('qtype', PARAM_INT);
 
         $mform->addElement('text', 'title', get_string("pagetitle", "lesson"), array('size'=>70));
-        $mform->setType('title', PARAM_TEXT);
         $mform->addRule('title', null, 'required', null, 'server');
+        if (!empty($CFG->formatstringstriptags)) {
+            $mform->setType('title', PARAM_TEXT);
+        } else {
+            $mform->setType('title', PARAM_CLEANHTML);
+        }
 
         $this->editoroptions = array('noclean'=>true, 'maxfiles'=>EDITOR_UNLIMITED_FILES, 'maxbytes'=>$PAGE->course->maxbytes);
         $mform->addElement('editor', 'contents_editor', get_string("pagecontents", "lesson"), null, $this->editoroptions);
index 31c01b3..c093c07 100644 (file)
@@ -96,7 +96,7 @@ class lesson_add_page_form_cluster extends lesson_add_page_form_base {
     protected $standard = false;
 
     public function custom_definition() {
-        global $PAGE;
+        global $PAGE, $CFG;
 
         $mform = $this->_form;
         $lesson = $this->_customdata['lesson'];
@@ -109,7 +109,11 @@ class lesson_add_page_form_cluster extends lesson_add_page_form_base {
         $mform->setType('qtype', PARAM_TEXT);
 
         $mform->addElement('text', 'title', get_string("pagetitle", "lesson"), array('size'=>70));
-        $mform->setType('title', PARAM_TEXT);
+        if (!empty($CFG->formatstringstriptags)) {
+            $mform->setType('title', PARAM_TEXT);
+        } else {
+            $mform->setType('title', PARAM_CLEANHTML);
+        }
 
         $this->editoroptions = array('noclean'=>true, 'maxfiles'=>EDITOR_UNLIMITED_FILES, 'maxbytes'=>$PAGE->course->maxbytes);
         $mform->addElement('editor', 'contents_editor', get_string("pagecontents", "lesson"), null, $this->editoroptions);
index 97a6483..568f58a 100644 (file)
@@ -124,7 +124,7 @@ class lesson_add_page_form_endofbranch extends lesson_add_page_form_base {
     protected $standard = false;
 
     public function custom_definition() {
-        global $PAGE;
+        global $PAGE, $CFG;
 
         $mform = $this->_form;
         $lesson = $this->_customdata['lesson'];
@@ -137,7 +137,11 @@ class lesson_add_page_form_endofbranch extends lesson_add_page_form_base {
         $mform->setType('qtype', PARAM_TEXT);
 
         $mform->addElement('text', 'title', get_string("pagetitle", "lesson"), array('size'=>70));
-        $mform->setType('title', PARAM_TEXT);
+        if (!empty($CFG->formatstringstriptags)) {
+            $mform->setType('title', PARAM_TEXT);
+        } else {
+            $mform->setType('title', PARAM_CLEANHTML);
+        }
 
         $this->editoroptions = array('noclean'=>true, 'maxfiles'=>EDITOR_UNLIMITED_FILES, 'maxbytes'=>$PAGE->course->maxbytes);
         $mform->addElement('editor', 'contents_editor', get_string("pagecontents", "lesson"), null, $this->editoroptions);
index 8a0aaad..207297a 100644 (file)
@@ -104,7 +104,7 @@ class lesson_add_page_form_endofcluster extends lesson_add_page_form_base {
     protected $standard = false;
 
     public function custom_definition() {
-        global $PAGE;
+        global $PAGE, $CFG;
 
         $mform = $this->_form;
         $lesson = $this->_customdata['lesson'];
@@ -117,7 +117,11 @@ class lesson_add_page_form_endofcluster extends lesson_add_page_form_base {
         $mform->setType('qtype', PARAM_TEXT);
 
         $mform->addElement('text', 'title', get_string("pagetitle", "lesson"), array('size'=>70));
-        $mform->setType('title', PARAM_TEXT);
+        if (!empty($CFG->formatstringstriptags)) {
+            $mform->setType('title', PARAM_TEXT);
+        } else {
+            $mform->setType('title', PARAM_CLEANHTML);
+        }
 
         $this->editoroptions = array('noclean'=>true, 'maxfiles'=>EDITOR_UNLIMITED_FILES, 'maxbytes'=>$PAGE->course->maxbytes);
         $mform->addElement('editor', 'contents_editor', get_string("pagecontents", "lesson"), null, $this->editoroptions);
index ef504ce..16d21a6 100644 (file)
@@ -181,9 +181,9 @@ if ($ADMIN->fulltree) {
             array('value' => 0, 'adv' => true)));
 
     // Password.
-    $quizsettings->add(new admin_setting_configtext_with_advanced('quiz/password',
+    $quizsettings->add(new admin_setting_configpasswordunmask_with_advanced('quiz/password',
             get_string('requirepassword', 'quiz'), get_string('configrequirepassword', 'quiz'),
-            array('value' => '', 'adv' => false), PARAM_TEXT));
+            array('value' => '', 'adv' => false)));
 
     // IP restrictions.
     $quizsettings->add(new admin_setting_configtext_with_advanced('quiz/subnet',
index 3c37a45..f3ce788 100644 (file)
@@ -1042,8 +1042,11 @@ class mod_workshop_external extends external_api {
             return null;
         }
 
-        // Remove the feedback for the reviewer if the feedback phase is not valid or if we don't have enough permissions to see it.
-        if ($workshop->phase < workshop::PHASE_EVALUATION || !($isreviewer || $canviewallassessments)) {
+        // Remove the feedback for the reviewer if:
+        // I can't see it in the evaluation phase because I'm not a teacher or the reviewer AND
+        // I can't see it in the assessment phase because I'm not a teacher.
+        if (($workshop->phase < workshop::PHASE_EVALUATION || !($isreviewer || $canviewallassessments)) &&
+                ($workshop->phase < workshop::PHASE_ASSESSMENT || !$canviewallassessments) ) {
             // Remove all the feedback information (all the optional fields).
             foreach ($properties as $attribute => $settings) {
                 if (!empty($settings['optional'])) {
index afb9a27..1338a51 100644 (file)
@@ -1451,6 +1451,7 @@ class mod_workshop_external_testcase extends externallib_advanced_testcase {
         $result = mod_workshop_external::get_reviewer_assessments($this->workshop->id, $this->student->id);
         $result = external_api::clean_returnvalue(mod_workshop_external::get_reviewer_assessments_returns(), $result);
         $this->assertCount(2, $result['assessments']);
+        $this->assertArrayNotHasKey('feedbackreviewer', $result['assessments'][0]);
     }
 
     /**
index 62edd6d..63d4c71 100644 (file)
@@ -267,8 +267,8 @@ class qtype_ddimageortext_edit_form extends qtype_ddtoimage_edit_form_base {
         for ($dragindex = 0; $dragindex < $data['noitems']; $dragindex++) {
             $label = $data['draglabel'][$dragindex];
             if ($data['drags'][$dragindex]['dragitemtype'] == 'word') {
-                $allowedtags = '<br><sub><sup><b><i><strong><em>';
-                $errormessage = get_string('formerror_disallowedtags', 'qtype_ddimageortext');
+                $allowedtags = '<br><sub><sup><b><i><strong><em><span>';
+                $errormessage = get_string('formerror_disallowedtags', 'qtype_ddimageortext', s($allowedtags));
             } else {
                 $allowedtags = '';
                 $errormessage = get_string('formerror_noallowedtags', 'qtype_ddimageortext');
index 5acf026..4143d01 100644 (file)
@@ -37,7 +37,7 @@ $string['draggableword'] = 'Draggable text';
 $string['dropbackground'] = 'Background image for dragging markers onto';
 $string['dropzone'] = 'Drop zone {$a}';
 $string['dropzoneheader'] = 'Drop zones';
-$string['formerror_disallowedtags'] = 'Sorry, HTML tags are not allowed in draggable text.';
+$string['formerror_disallowedtags'] = 'Only "{$a}" tags are allowed in this draggable text.';
 $string['formerror_noallowedtags'] = 'HTML tags are not allowed in this text which is the alt text for a draggable image.';
 $string['formerror_noytop'] = 'You must provide a value for the y coordinate for the top left corner of this drop area. You can drag and drop the drop area above to set the coordinates or enter them manually here.';
 $string['formerror_noxleft'] = 'You must provide a value for the x coordinate for the top left corner of this drop area. You can drag and drop the drop area above to set the coordinates or enter them manually here.';
diff --git a/question/type/ddimageortext/tests/edit_form_test.php b/question/type/ddimageortext/tests/edit_form_test.php
new file mode 100644 (file)
index 0000000..4e7b224
--- /dev/null
@@ -0,0 +1,108 @@
+<?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 drag-and-drop onto image edit form.
+ *
+ * @package   qtype_ddimageortext
+ * @copyright 2019 The Open University
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+global $CFG;
+
+require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
+require_once($CFG->dirroot . '/question/type/edit_question_form.php');
+require_once($CFG->dirroot . '/question/type/ddimageortext/edit_ddimageortext_form.php');
+
+/**
+ * Unit tests for the drag-and-drop onto image edit form.
+ *
+ * @copyright  2019 The Open University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qtype_ddimageortext_edit_form_test extends advanced_testcase {
+    /**
+     * Helper method.
+     *
+     * @return array with two elements:
+     *      question_edit_form great a question form instance that can be tested.
+     *      stdClass the question category.
+     */
+    protected function get_form() {
+        $this->setAdminUser();
+        $this->resetAfterTest();
+
+        $syscontext = context_system::instance();
+        $category = question_make_default_categories(array($syscontext));
+        $fakequestion = new stdClass();
+        $fakequestion->qtype = 'ddimageortext';
+        $fakequestion->contextid = $syscontext->id;
+        $fakequestion->createdby = 2;
+        $fakequestion->category = $category->id;
+        $fakequestion->questiontext = 'Test question';
+        $fakequestion->options = new stdClass();
+        $fakequestion->options->answers = array();
+        $fakequestion->formoptions = new stdClass();
+        $fakequestion->formoptions->movecontext = null;
+        $fakequestion->formoptions->repeatelements = true;
+        $fakequestion->inputs = null;
+
+        $form = new qtype_ddimageortext_edit_form(new moodle_url('/'), $fakequestion, $category,
+                new question_edit_contexts($syscontext));
+
+        return [$form, $category];
+    }
+
+    /**
+     * Test the form correctly validates the HTML allowed in items.
+     */
+    public function test_item_validation() {
+        list($form, $category) = $this->get_form();
+
+        $submitteddata = [
+            'category' => $category->id,
+            'bgimage' => '',
+            'nodropzone' => 0,
+            'noitems' => 5,
+            'drags' => [
+                ['dragitemtype' => 'image'],
+                ['dragitemtype' => 'image'],
+                ['dragitemtype' => 'word'],
+                ['dragitemtype' => 'word'],
+                ['dragitemtype' => 'word'],
+            ],
+            'draglabel' => [
+                'frog',
+                '<b>toad</b>',
+                'cat',
+                '<span lang="fr"><b>chien</b></span>',
+                '<textarea>evil!</textarea>',
+            ],
+        ];
+
+        $errors = $form->validation($submitteddata, []);
+
+        $this->assertArrayNotHasKey('drags[0]', $errors);
+        $this->assertEquals('HTML tags are not allowed in this text which is the alt text for a draggable image.',
+                $errors['drags[1]']);
+        $this->assertArrayNotHasKey('drags[2]', $errors);
+        $this->assertArrayNotHasKey('drags[3]', $errors);
+        $this->assertEquals('Only "&lt;br&gt;&lt;sub&gt;&lt;sup&gt;&lt;b&gt;&lt;i&gt;&lt;strong&gt;&lt;em&gt;&lt;span&gt;" ' .
+                'tags are allowed in this draggable text.', $errors['drags[4]']);
+    }
+}
index 62d00d5..718469f 100644 (file)
@@ -34,7 +34,7 @@ defined('MOODLE_INTERNAL') || die();
  */
 class qtype_ddimageortext_test_helper extends question_test_helper {
     public function get_test_questions() {
-        return array('fox', 'maths', 'xsection');
+        return array('fox', 'maths', 'xsection', 'mixedlang');
     }
 
     /**
@@ -248,4 +248,39 @@ class qtype_ddimageortext_test_helper extends question_test_helper {
 
         return $fromform;
     }
+
+    /**
+     * Make a test question where the drag items are a different language than the main question text.
+     *
+     * @return qtype_ddimageortext_question
+     */
+    public function make_ddimageortext_question_mixedlang() {
+        question_bank::load_question_definition_classes('ddimageortext');
+        $dd = new qtype_ddimageortext_question();
+
+        test_question_maker::initialise_a_question($dd);
+
+        $dd->name = 'Question about French in English.';
+        $dd->questiontext = '<p>Complete the blanks in this sentence.</p>' .
+                '<p lang="fr">J\'ai perdu [[1]] plume de [[2]] tante - l\'avez-vous vue?</p>';
+        $dd->generalfeedback = 'This sentence uses each letter of the alphabet.';
+        $dd->qtype = question_bank::get_qtype('ddimageortext');
+
+        $dd->shufflechoices = true;
+
+        test_question_maker::set_standard_combined_feedback_fields($dd);
+
+        $dd->choices = $this->make_choice_structure(array(
+                new qtype_ddimageortext_drag_item('<span lang="fr">la</span>', 1, 1),
+                new qtype_ddimageortext_drag_item('<span lang="fr">ma</span>', 2, 1),
+        ));
+
+        $dd->places = $this->make_place_structure(array(
+                new qtype_ddimageortext_drop_zone('', 1, 1),
+                new qtype_ddimageortext_drop_zone('', 2, 1)
+        ));
+        $dd->rightchoices = array(1 => 1, 2 => 2);
+
+        return $dd;
+    }
 }
index e9cad59..b71e4af 100644 (file)
@@ -854,4 +854,20 @@ class qtype_ddimageortext_walkthrough_test extends qbehaviour_walkthrough_test_b
         $this->check_current_state(question_state::$gradedright);
         $this->check_current_mark(3);
     }
+
+    public function test_mixed_lang_rendering() {
+
+        // Create a mixe drag-and-drop question.
+        $dd = test_question_maker::make_question('ddimageortext', 'mixedlang');
+        $dd->shufflechoices = false;
+        $this->start_attempt_at_question($dd, 'interactive', 1);
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                new question_pattern_expectation('~<div class="group1 draghome choice1"><span lang="fr">la</span></div>~'),
+                new question_pattern_expectation('~<div class="group1 draghome choice2"><span lang="fr">ma</span></div>~')
+        );
+    }
 }
index fede3d4..f42bd85 100644 (file)
@@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die();
 require_once($CFG->dirroot.'/question/type/ddimageortext/edit_ddtoimage_form_base.php');
 require_once($CFG->dirroot.'/question/type/ddmarker/shapes.php');
 
-define('QTYPE_DDMARKER_ALLOWED_TAGS_IN_MARKER', '<br><i><em><b><strong><sup><sub><u>');
+define('QTYPE_DDMARKER_ALLOWED_TAGS_IN_MARKER', '<br><i><em><b><strong><sup><sub><u><span>');
 
 
 /**
diff --git a/question/type/ddmarker/tests/edit_form_test.php b/question/type/ddmarker/tests/edit_form_test.php
new file mode 100644 (file)
index 0000000..45032c5
--- /dev/null
@@ -0,0 +1,99 @@
+<?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 drag-and-drop markers edit form.
+ *
+ * @package   qtype_ddmarker
+ * @copyright 2019 The Open University
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+global $CFG;
+
+require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
+require_once($CFG->dirroot . '/question/type/edit_question_form.php');
+require_once($CFG->dirroot . '/question/type/ddmarker/edit_ddmarker_form.php');
+
+/**
+ * Unit tests for the drag-and-drop markers edit form.
+ *
+ * @copyright  2019 The Open University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qtype_ddmarker_edit_form_test extends advanced_testcase {
+    /**
+     * Helper method.
+     *
+     * @return array with two elements:
+     *      question_edit_form great a question form instance that can be tested.
+     *      stdClass the question category.
+     */
+    protected function get_form() {
+        $this->setAdminUser();
+        $this->resetAfterTest();
+
+        $syscontext = context_system::instance();
+        $category = question_make_default_categories(array($syscontext));
+        $fakequestion = new stdClass();
+        $fakequestion->qtype = 'ddmarker';
+        $fakequestion->contextid = $syscontext->id;
+        $fakequestion->createdby = 2;
+        $fakequestion->category = $category->id;
+        $fakequestion->questiontext = 'Test question';
+        $fakequestion->options = new stdClass();
+        $fakequestion->options->answers = array();
+        $fakequestion->formoptions = new stdClass();
+        $fakequestion->formoptions->movecontext = null;
+        $fakequestion->formoptions->repeatelements = true;
+        $fakequestion->inputs = nu