Merge branch 'MDL-68334-master' of git://github.com/lucaboesch/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Wed, 12 Aug 2020 03:05:54 +0000 (11:05 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Wed, 12 Aug 2020 03:05:54 +0000 (11:05 +0800)
31 files changed:
admin/settings/plugins.php
admin/tool/dataprivacy/lib.php
blocks/recentlyaccesseditems/classes/external/recentlyaccesseditems_item_exporter.php
contentbank/classes/content.php
contentbank/classes/contentbank.php
contentbank/classes/contenttype.php
contentbank/tests/content_test.php
contentbank/tests/contentbank_test.php
contentbank/tests/contenttype_test.php
contentbank/tests/fixtures/testable_content.php
contentbank/upload.php
course/modedit.php
lang/en/admin.php
lang/en/contentbank.php
lib/adminlib.php
lib/outputrenderers.php
lib/upgrade.txt
mod/assign/templates/grading_navigation.mustache
mod/assign/templates/grading_navigation_user_selector.mustache
mod/forum/classes/task/send_user_digests.php
mod/forum/tests/maildigest_test.php
search/classes/engine.php
search/classes/manager.php
search/engine/solr/classes/engine.php
search/engine/solr/classes/schema.php
search/engine/solr/settings.php
search/engine/solr/tests/engine_test.php
search/index.php
search/tests/behat/search_information.feature [new file with mode: 0644]
search/tests/fixtures/testable_core_search.php
search/upgrade.txt

index 1942627..cc23ff3 100644 (file)
@@ -549,8 +549,19 @@ if ($hassiteconfig) {
 
     // Search engine selection.
     $temp->add(new admin_setting_heading('searchengineheading', new lang_string('searchengine', 'admin'), ''));
-    $temp->add(new admin_setting_configselect('searchengine',
-                                new lang_string('selectsearchengine', 'admin'), '', 'simpledb', $engines));
+    $searchengineselect = new admin_setting_configselect('searchengine',
+            new lang_string('selectsearchengine', 'admin'), '', 'simpledb', $engines);
+    $searchengineselect->set_validate_function(function(string $value): string {
+        global $CFG;
+
+        // Check nobody's setting the indexing and query-only server to the same one.
+        if ($CFG->searchenginequeryonly === $value) {
+            return get_string('searchenginequeryonlysame', 'admin');
+        } else {
+            return '';
+        }
+    });
+    $temp->add($searchengineselect);
     $temp->add(new admin_setting_heading('searchoptionsheading', new lang_string('searchoptions', 'admin'), ''));
     $temp->add(new admin_setting_configcheckbox('searchindexwhendisabled',
             new lang_string('searchindexwhendisabled', 'admin'), new lang_string('searchindexwhendisabled_desc', 'admin'),
@@ -590,6 +601,43 @@ if ($hassiteconfig) {
         new lang_string('searchhideallcategory_desc', 'admin'),
         0));
 
+    $temp->add(new admin_setting_heading('searchmanagement', new lang_string('searchmanagement', 'admin'),
+            new lang_string('searchmanagement_desc', 'admin')));
+
+    // Get list of search engines including those with alternate settings.
+    $searchenginequeryonlyselect = new admin_setting_configselect('searchenginequeryonly',
+            new lang_string('searchenginequeryonly', 'admin'),
+            new lang_string('searchenginequeryonly_desc', 'admin'), '', function() use($engines) {
+                $options = ['' => new lang_string('searchenginequeryonly_none', 'admin')];
+                foreach ($engines as $name => $display) {
+                    $options[$name] = $display;
+
+                    $classname = '\search_' . $name . '\engine';
+                    $engine = new $classname;
+                    if ($engine->has_alternate_configuration()) {
+                        $options[$name . '-alternate'] =
+                                new lang_string('searchenginealternatesettings', 'admin', $display);
+                    }
+                }
+                return $options;
+            });
+    $searchenginequeryonlyselect->set_validate_function(function(string $value): string {
+        global $CFG;
+
+        // Check nobody's setting the indexing and query-only server to the same one.
+        if ($CFG->searchengine === $value) {
+            return get_string('searchenginequeryonlysame', 'admin');
+        } else {
+            return '';
+        }
+    });
+    $temp->add($searchenginequeryonlyselect);
+    $temp->add(new admin_setting_configcheckbox('searchbannerenable',
+            new lang_string('searchbannerenable', 'admin'), new lang_string('searchbannerenable_desc', 'admin'),
+            0));
+    $temp->add(new admin_setting_confightmleditor('searchbanner',
+            new lang_string('searchbanner', 'admin'), '', ''));
+
     $ADMIN->add('searchplugins', $temp);
     $ADMIN->add('searchplugins', new admin_externalpage('searchareas', new lang_string('searchareas', 'admin'),
         new moodle_url('/admin/searchareas.php')));
index c069301..5b5f28a 100644 (file)
@@ -95,7 +95,7 @@ function tool_dataprivacy_myprofile_navigation(tree $tree, $user, $iscurrentuser
         $showsummary = true;
     }
 
-    if ($showsummary) {
+    if ($showsummary && $iscurrentuser) {
         $summaryurl = new moodle_url('/admin/tool/dataprivacy/summary.php');
         $summarynode = new core_user\output\myprofile\node('privacyandpolicies', 'retentionsummary',
             get_string('dataretentionsummary', 'tool_dataprivacy'), null, $summaryurl);
index f26b43d..934433d 100644 (file)
@@ -49,13 +49,18 @@ class recentlyaccesseditems_item_exporter extends \core\external\exporter {
      * @return array Additional properties with values
      */
     protected function get_other_values(renderer_base $output) {
-        global $OUTPUT;
+        global $CFG;
+        require_once($CFG->libdir.'/modinfolib.php');
 
         return array(
-                'viewurl' => (new moodle_url('/mod/'.$this->data->modname.'/view.php',
-                        array('id' => $this->data->cmid)))->out(false),
-                'courseviewurl' => (new moodle_url('/course/view.php', array('id' => $this->data->courseid)))->out(false),
-                'icon' => $OUTPUT->image_icon('icon', get_string('pluginname', $this->data->modname), $this->data->modname)
+            'viewurl' => (new moodle_url('/mod/'.$this->data->modname.'/view.php',
+                array('id' => $this->data->cmid)))->out(false),
+            'courseviewurl' => (new moodle_url('/course/view.php', array('id' => $this->data->courseid)))->out(false),
+            'icon' => \html_writer::img(
+                get_fast_modinfo($this->data->courseid)->cms[$this->data->cmid]->get_icon_url(),
+                get_string('pluginname', $this->data->modname),
+                ['title' => get_string('pluginname', $this->data->modname), 'class' => 'icon']
+            )
         );
     }
 
@@ -111,4 +116,4 @@ class recentlyaccesseditems_item_exporter extends \core\external\exporter {
             )
         );
     }
-}
\ No newline at end of file
+}
index c9dad88..b8a9d40 100644 (file)
@@ -237,6 +237,42 @@ abstract class content {
         return $this->content->configdata;
     }
 
+    /**
+     * Import a file as a valid content.
+     *
+     * By default, all content has a public file area to interact with the content bank
+     * repository. This method should be overridden by contentypes which does not simply
+     * upload to the public file area.
+     *
+     * If any, the method will return the final stored_file. This way it can be invoked
+     * as parent::import_file in case any plugin want to store the file in the public area
+     * and also parse it.
+     *
+     * @throws file_exception If file operations fail
+     * @param stored_file $file File to store in the content file area.
+     * @return stored_file|null the stored content file or null if the file is discarted.
+     */
+    public function import_file(stored_file $file): ?stored_file {
+        $originalfile = $this->get_file();
+        if ($originalfile) {
+            $originalfile->replace_file_with($file);
+            return $originalfile;
+        } else {
+            $itemid = $this->get_id();
+            $fs = get_file_storage();
+            $filerecord = [
+                'contextid' => $this->get_contextid(),
+                'component' => 'contentbank',
+                'filearea' => 'public',
+                'itemid' => $this->get_id(),
+                'filepath' => '/',
+                'filename' => $file->get_filename(),
+                'timecreated' => time(),
+            ];
+            return $fs->create_file_from_storedfile($filerecord, $file);
+        }
+    }
+
     /**
      * Returns the $file related to this content.
      *
index 326e304..307b949 100644 (file)
@@ -224,6 +224,8 @@ class contentbank {
     /**
      * Create content from a file information.
      *
+     * @throws file_exception If file operations fail
+     * @throws dml_exception if the content creation fails
      * @param \context $context Context where to upload the file and content.
      * @param int $userid Id of the user uploading the file.
      * @param stored_file $file The file to get information from
@@ -243,7 +245,7 @@ class contentbank {
         $record->name = $filename;
         $record->usercreated = $userid;
         $contentype = new $classname($context);
-        $content = $contentype->create_content($record);
+        $content = $contentype->upload_content($file, $record);
         $event = \core\event\contentbank_content_uploaded::create_from_record($content->get_content());
         $event->trigger();
         return $content;
index 6b6a140..cd1fb41 100644 (file)
@@ -27,6 +27,8 @@ namespace core_contentbank;
 use core\event\contentbank_content_created;
 use core\event\contentbank_content_deleted;
 use core\event\contentbank_content_viewed;
+use stored_file;
+use file_exception;
 use moodle_url;
 
 /**
@@ -62,10 +64,11 @@ abstract class contenttype {
     /**
      * Fills content_bank table with appropiate information.
      *
+     * @throws dml_exception A DML specific exception is thrown for any creation error.
      * @param \stdClass $record An optional content record compatible object (default null)
      * @return content  Object with content bank information.
      */
-    public function create_content(\stdClass $record = null): ?content {
+    public function create_content(\stdClass $record = null): content {
         global $USER, $DB;
 
         $entry = new \stdClass();
@@ -79,15 +82,37 @@ abstract class contenttype {
         $entry->configdata = $record->configdata ?? '';
         $entry->instanceid = $record->instanceid ?? 0;
         $entry->id = $DB->insert_record('contentbank_content', $entry);
-        if ($entry->id) {
-            $classname = '\\'.$entry->contenttype.'\\content';
-            $content = new $classname($entry);
-            // Trigger an event for creating the content.
-            $event = contentbank_content_created::create_from_record($content->get_content());
-            $event->trigger();
-            return $content;
+        $classname = '\\'.$entry->contenttype.'\\content';
+        $content = new $classname($entry);
+        // Trigger an event for creating the content.
+        $event = contentbank_content_created::create_from_record($content->get_content());
+        $event->trigger();
+        return $content;
+    }
+
+    /**
+     * Create a new content from an uploaded file.
+     *
+     * @throws file_exception If file operations fail
+     * @throws dml_exception if the content creation fails
+     * @param stored_file $file the uploaded file
+     * @param \stdClass|null $record an optional content record
+     * @return content  Object with content bank information.
+     */
+    public function upload_content(stored_file $file, \stdClass $record = null): content {
+        if (empty($record)) {
+            $record = new \stdClass();
+            $record->name = $file->get_filename();
         }
-        return null;
+        $content = $this->create_content($record);
+        try {
+            $content->import_file($file);
+        } catch (file_exception $e) {
+            $this->delete_content($content);
+            throw $e;
+        }
+
+        return $content;
     }
 
     /**
index 45c70c8..b0bfede 100644 (file)
@@ -189,4 +189,88 @@ class core_contenttype_content_testcase extends \advanced_testcase {
         $this->assertEquals($newcontext->id, $content->get_contextid());
         $this->assertEquals($newcontext->id, $file->get_contextid());
     }
+
+    /**
+     * Tests for 'import_file' behaviour when replacing a file.
+     *
+     * @covers ::import_file
+     */
+    public function test_import_file_replace(): void {
+        global $USER;
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $context = context_system::instance();
+
+        // Add some content to the content bank.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+        $contents = $generator->generate_contentbank_data('contenttype_testable', 3, 0, $context);
+        $content = reset($contents);
+
+        $originalfile = $content->get_file();
+
+        // Create a dummy file.
+        $filerecord = array(
+            'contextid' => $context->id,
+            'component' => 'contentbank',
+            'filearea' => 'draft',
+            'itemid' => $content->get_id(),
+            'filepath' => '/',
+            'filename' => 'example.txt'
+        );
+        $fs = get_file_storage();
+        $file = $fs->create_file_from_string($filerecord, 'Dummy content ');
+
+        $importedfile = $content->import_file($file);
+
+        $this->assertEquals($originalfile->get_filename(), $importedfile->get_filename());
+        $this->assertEquals($originalfile->get_filearea(), $importedfile->get_filearea());
+        $this->assertEquals($originalfile->get_filepath(), $importedfile->get_filepath());
+        $this->assertEquals($originalfile->get_mimetype(), $importedfile->get_mimetype());
+
+        $this->assertEquals($file->get_userid(), $importedfile->get_userid());
+        $this->assertEquals($file->get_contenthash(), $importedfile->get_contenthash());
+    }
+
+    /**
+     * Tests for 'import_file' behaviour when uploading a new file.
+     *
+     * @covers ::import_file
+     */
+    public function test_import_file_upload(): void {
+        global $USER;
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $context = context_system::instance();
+
+        $type = new contenttype($context);
+        $record = (object)[
+            'name' => 'content name',
+            'usercreated' => $USER->id,
+        ];
+        $content = $type->create_content($record);
+
+        // Create a dummy file.
+        $filerecord = array(
+            'contextid' => $context->id,
+            'component' => 'contentbank',
+            'filearea' => 'draft',
+            'itemid' => $content->get_id(),
+            'filepath' => '/',
+            'filename' => 'example.txt'
+        );
+        $fs = get_file_storage();
+        $file = $fs->create_file_from_string($filerecord, 'Dummy content ');
+
+        $importedfile = $content->import_file($file);
+
+        $this->assertEquals($file->get_filename(), $importedfile->get_filename());
+        $this->assertEquals($file->get_userid(), $importedfile->get_userid());
+        $this->assertEquals($file->get_mimetype(), $importedfile->get_mimetype());
+        $this->assertEquals($file->get_contenthash(), $importedfile->get_contenthash());
+        $this->assertEquals('public', $importedfile->get_filearea());
+        $this->assertEquals('/', $importedfile->get_filepath());
+
+        $contentfile = $content->get_file($file);
+        $this->assertEquals($importedfile->get_id(), $contentfile->get_id());
+    }
 }
index d347a72..cd22e80 100644 (file)
@@ -112,14 +112,14 @@ class core_contentbank_testcase extends advanced_testcase {
         $this->resetAfterTest();
 
         $cb = new contentbank();
-        $expectedsupporters = [$extension => $expected];
 
         $systemcontext = context_system::instance();
 
         // All contexts allowed for admins.
         $this->setAdminUser();
         $contextsupporters = $cb->load_context_supported_extensions($systemcontext);
-        $this->assertEquals($expectedsupporters, $contextsupporters);
+        $this->assertArrayHasKey($extension, $contextsupporters);
+        $this->assertEquals($expected, $contextsupporters[$extension]);
     }
 
     /**
@@ -161,7 +161,6 @@ class core_contentbank_testcase extends advanced_testcase {
         $this->resetAfterTest();
 
         $cb = new contentbank();
-        $expectedsupporters = [$extension => $expected];
 
         $course = $this->getDataGenerator()->create_course();
         $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
@@ -170,7 +169,8 @@ class core_contentbank_testcase extends advanced_testcase {
 
         // Teachers has permission in their context to upload supported by H5P content type.
         $contextsupporters = $cb->load_context_supported_extensions($coursecontext);
-        $this->assertEquals($expectedsupporters, $contextsupporters);
+        $this->assertArrayHasKey($extension, $contextsupporters);
+        $this->assertEquals($expected, $contextsupporters[$extension]);
     }
 
     /**
index f3bc67b..9c80d8f 100644 (file)
@@ -27,6 +27,8 @@ namespace core_contentbank;
 
 use stdClass;
 use context_system;
+use context_user;
+use file_exception;
 use contenttype_testable\contenttype as contenttype;
 /**
  * Test for content bank contenttype class.
@@ -183,6 +185,111 @@ class core_contenttype_contenttype_testcase extends \advanced_testcase {
         $this->assertInstanceOf('\\contenttype_testable\\content', $content);
     }
 
+    /**
+     * Tests for behaviour of upload_content() with a file and a record.
+     *
+     * @dataProvider upload_content_provider
+     * @param bool $userecord if a predefined record has to be used.
+     *
+     * @covers ::upload_content
+     */
+    public function test_upload_content(bool $userecord): void {
+        global $USER;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $dummy = [
+            'contextid' => context_user::instance($USER->id)->id,
+            'component' => 'user',
+            'filearea' => 'draft',
+            'itemid' => 1,
+            'filepath' => '/',
+            'filename' => 'file.h5p',
+            'userid' => $USER->id,
+        ];
+        $fs = get_file_storage();
+        $dummyfile = $fs->create_file_from_string($dummy, 'Dummy content');
+
+        // Create content.
+        if ($userecord) {
+            $record = new stdClass();
+            $record->name = 'Test content';
+            $record->configdata = '';
+            $record->contenttype = '';
+            $checkname = $record->name;
+        } else {
+            $record = null;
+            $checkname = $dummyfile->get_filename();
+        }
+
+        $contenttype = new contenttype(context_system::instance());
+        $content = $contenttype->upload_content($dummyfile, $record);
+
+        $this->assertEquals('contenttype_testable', $content->get_content_type());
+        $this->assertEquals($checkname, $content->get_name());
+        $this->assertInstanceOf('\\contenttype_testable\\content', $content);
+
+        $file = $content->get_file();
+        $this->assertEquals($dummyfile->get_filename(), $file->get_filename());
+        $this->assertEquals($dummyfile->get_userid(), $file->get_userid());
+        $this->assertEquals($dummyfile->get_mimetype(), $file->get_mimetype());
+        $this->assertEquals($dummyfile->get_contenthash(), $file->get_contenthash());
+        $this->assertEquals('contentbank', $file->get_component());
+        $this->assertEquals('public', $file->get_filearea());
+        $this->assertEquals('/', $file->get_filepath());
+    }
+
+    /**
+     * Data provider for test_rename_content.
+     *
+     * @return  array
+     */
+    public function upload_content_provider() {
+        return [
+            'With record' => [true],
+            'Without record' => [false],
+        ];
+    }
+
+    /**
+     * Tests for behaviour of upload_content() with a file wrong file.
+     *
+     * @covers ::upload_content
+     */
+    public function test_upload_content_exception(): void {
+        global $USER, $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        // The testing contenttype thows exception if filename is "error.*".
+        $dummy = [
+            'contextid' => context_user::instance($USER->id)->id,
+            'component' => 'user',
+            'filearea' => 'draft',
+            'itemid' => 1,
+            'filepath' => '/',
+            'filename' => 'error.txt',
+            'userid' => $USER->id,
+        ];
+        $fs = get_file_storage();
+        $dummyfile = $fs->create_file_from_string($dummy, 'Dummy content');
+
+        $contenttype = new contenttype(context_system::instance());
+        $cbcontents = $DB->count_records('contentbank_content');
+
+        // We need to capture the exception to check no content is created.
+        try {
+            $content = $contenttype->upload_content($dummyfile);
+            $this->assertTrue(false);
+        } catch (file_exception $e) {
+            $this->assertTrue(true);
+        }
+        $this->assertEquals($cbcontents, $DB->count_records('contentbank_content'));
+        $this->assertEquals(1, $DB->count_records('files', ['contenthash' => $dummyfile->get_contenthash()]));
+    }
+
     /**
      * Test the behaviour of can_delete().
      */
index d379dea..3d3ed4a 100644 (file)
@@ -25,6 +25,9 @@
 
 namespace contenttype_testable;
 
+use file_exception;
+use stored_file;
+
 /**
  * Testable content plugin class.
  *
@@ -33,4 +36,21 @@ namespace contenttype_testable;
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class content extends \core_contentbank\content {
+
+    /**
+     * Import a file as a valid content.
+     *
+     * This method will thow an error if the filename is "error.*"
+     *
+     * @param stored_file $file File to store in the content file area.
+     * @return stored_file|null the stored content file or null if the file is discarted.
+     * @throws file_exception if the filename contains the word "error"
+     */
+    public function import_file(stored_file $file): ?stored_file {
+        $filename = $file->get_filename();
+        if (strrpos($filename, 'error') !== false) {
+            throw new file_exception('yourerrorthanks', 'contenttype_test');
+        }
+        return parent::import_file($file);
+    }
 }
index c4626f0..00cc40c 100644 (file)
@@ -25,6 +25,8 @@
 require('../config.php');
 require_once("$CFG->dirroot/contentbank/files_form.php");
 
+use core\output\notification;
+
 require_login();
 
 $contextid = optional_param('contextid', \context_system::instance()->id, PARAM_INT);
@@ -68,6 +70,8 @@ file_prepare_standard_filemanager($data, 'files', $options, $context, 'contentba
 
 $mform = new contentbank_files_form(null, ['contextid' => $contextid, 'data' => $data, 'options' => $options]);
 
+$error = '';
+
 if ($mform->is_cancelled()) {
     redirect($returnurl);
 } else if ($formdata = $mform->get_data()) {
@@ -79,16 +83,20 @@ if ($mform->is_cancelled()) {
     if (!empty($files)) {
         $file = reset($files);
         $content = $cb->create_content_from_file($context, $USER->id, $file);
-        file_save_draft_area_files($formdata->file, $contextid, 'contentbank', 'public', $content->get_id());
         $viewurl = new \moodle_url('/contentbank/view.php', ['id' => $content->get_id(), 'contextid' => $contextid]);
         redirect($viewurl);
+    } else {
+        $error = get_string('errornofile', 'contentbank');
     }
-    redirect($returnurl);
 }
 
 echo $OUTPUT->header();
 echo $OUTPUT->box_start('generalbox');
 
+if (!empty($error)) {
+    echo $OUTPUT->notification($error, notification::NOTIFY_ERROR);
+}
+
 $mform->display();
 
 echo $OUTPUT->box_end();
index 0388708..4fa0524 100644 (file)
@@ -143,7 +143,12 @@ $mform->set_data($data);
 
 if ($mform->is_cancelled()) {
     if ($return && !empty($cm->id)) {
-        redirect("$CFG->wwwroot/mod/$module->name/view.php?id=$cm->id");
+        $urlparams = [
+            'id' => $cm->id, // We always need the activity id.
+            'forceview' => 1, // Stop file downloads in resources.
+        ];
+        $activityurl = new moodle_url("/mod/$module->name/view.php", $urlparams);
+        redirect($activityurl);
     } else {
         redirect(course_get_url($course, $cw->section, array('sr' => $sectionreturn)));
     }
index 2571bca..7fff4c0 100644 (file)
@@ -1107,6 +1107,8 @@ $string['searchallavailablecourses'] = 'Searchable courses';
 $string['searchallavailablecourses_off'] = 'Search within enrolled courses only';
 $string['searchallavailablecourses_on'] = 'Search within all courses the user can access';
 $string['searchallavailablecourses_desc'] = 'In some situations the search engine may not work when searching across a large number of courses. Set to search only enrolled courses if you need to restrict the number of courses searched.';
+$string['searchalternatesettings'] = 'Query-only alternate settings';
+$string['searchalternatesettings_desc'] = 'If you complete these settings, you can select \'alternate settings\' for this search engine in the query-only search engine option on the \'Manage global search\' page. This is only useful when moving between two search engines of the same type.';
 $string['searchdisplay'] = 'Search results display options';
 $string['searchenablecategories'] = 'Display results in separate categories';
 $string['searchenablecategories_desc'] = 'If enabled, search results will be displayed in separate categories.';
@@ -1118,10 +1120,18 @@ $string['searchallavailablecoursesdesc'] = 'If set to search within enrolled cou
 $string['searchincludeallcourses'] = 'Include all visible courses';
 $string['searchincludeallcourses_desc'] = 'If enabled, search results will include course information (name and summary) of courses which are visible to the user, even if they don\'t have access to the course content.';
 $string['searchalldeleted'] = 'All indexed contents have been deleted';
+$string['searchbannerenable'] = 'Display search information';
+$string['searchbannerenable_desc'] = 'If enabled, the below text will display at the top of the search screen for all users. This can be used to keep users informed while maintenance is being carried out on the search system.';
+$string['searchbanner'] = 'Search information';
 $string['searchareaenabled'] = 'Search area enabled';
 $string['searchareadisabled'] = 'Search area disabled';
 $string['searchdeleteindex'] = 'Delete all indexed contents';
 $string['searchengine'] = 'Search engine';
+$string['searchenginealternatesettings'] = '{$a} (alternate settings)';
+$string['searchenginequeryonly'] = 'Query-only search engine';
+$string['searchenginequeryonly_desc'] = 'This search engine will be used only for making queries, not indexing. By using this feature you can reindex in a different search engine, while user queries continue to work from this one.';
+$string['searchenginequeryonly_none'] = 'None (use main search engine for queries)';
+$string['searchenginequeryonlysame'] = 'The query-only search engine and the main search engine cannot be set to the same value.';
 $string['searchindexactions'] = 'Index actions';
 $string['searchindexdeleted'] = 'Index deleted';
 $string['searchindextime'] = 'Indexing time limit';
@@ -1131,6 +1141,8 @@ $string['searchindexwhendisabled'] = 'Index when disabled';
 $string['searchindexwhendisabled_desc'] = 'Allows the scheduled task to build the search index even when search is disabled. This is useful if you want to build the index before the search facility appears to students.';
 $string['searchinsettings'] = 'Search in settings';
 $string['searchlastrun'] = 'Last run (time, # docs, # records, # ignores)';
+$string['searchmanagement'] = 'Search management';
+$string['searchmanagement_desc'] = 'These options are useful when making changes on sites with very large search indexes that take a long time to rebuild.';
 $string['searchnotavailable'] = 'Search is not available';
 $string['searchpartial'] = '(not yet fully indexed)';
 $string['searchoptions'] = 'Search options';
index 7c63e1e..bac647a 100644 (file)
@@ -39,6 +39,7 @@ $string['eventcontentupdated'] = 'Content updated';
 $string['eventcontentuploaded'] = 'Content uploaded';
 $string['eventcontentviewed'] = 'Content viewed';
 $string['errordeletingcontentfromcategory'] = 'Error deleting content from category {$a}.';
+$string['errornofile'] = 'A compatible file is needed to create a content';
 $string['deletecontent'] = 'Delete content';
 $string['deletecontentconfirm'] = 'Are you sure you want to delete the content <em>\'{$a->name}\'</em> and all associated files? This action cannot be undone.';
 $string['displaydetails'] = 'Display content bank with file details';
index f687698..c6a3219 100644 (file)
@@ -3232,14 +3232,22 @@ class admin_setting_configselect extends admin_setting {
     public $choices;
     /** @var array Array of choices grouped using optgroups */
     public $optgroups;
+    /** @var callable|null Loader function for choices */
+    protected $choiceloader = null;
+    /** @var callable|null Validation function */
+    protected $validatefunction = null;
 
     /**
-     * Constructor
+     * Constructor.
+     *
+     * If you want to lazy-load the choices, pass a callback function that returns a choice
+     * array for the $choices parameter.
+     *
      * @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 string|int $defaultsetting
-     * @param array $choices array of $value=>$label for each selection
+     * @param array|callable|null $choices array of $value=>$label for each selection, or callback
      */
     public function __construct($name, $visiblename, $description, $defaultsetting, $choices) {
         // Look for optgroup and single options.
@@ -3254,10 +3262,26 @@ class admin_setting_configselect extends admin_setting {
                 }
             }
         }
+        if (is_callable($choices)) {
+            $this->choiceloader = $choices;
+        }
 
         parent::__construct($name, $visiblename, $description, $defaultsetting);
     }
 
+    /**
+     * Sets a validate function.
+     *
+     * The callback will be passed one parameter, the new setting value, and should return either
+     * an empty string '' if the value is OK, or an error message if not.
+     *
+     * @param callable|null $validatefunction Validate function or null to clear
+     * @since Moodle 4.0
+     */
+    public function set_validate_function(?callable $validatefunction = null) {
+        $this->validatefunction = $validatefunction;
+    }
+
     /**
      * This function may be used in ancestors for lazy loading of choices
      *
@@ -3267,12 +3291,12 @@ class admin_setting_configselect extends admin_setting {
      * @return bool true if loaded, false if error
      */
     public function load_choices() {
-        /*
-        if (is_array($this->choices)) {
+        if ($this->choiceloader) {
+            if (!is_array($this->choices)) {
+                $this->choices = call_user_func($this->choiceloader);
+            }
             return true;
         }
-        .... load choices here
-        */
         return true;
     }
 
@@ -3323,9 +3347,32 @@ class admin_setting_configselect extends admin_setting {
             return ''; // ignore it
         }
 
+        // Validate the new setting.
+        $error = $this->validate_setting($data);
+        if ($error) {
+            return $error;
+        }
+
         return ($this->config_write($this->name, $data) ? '' : get_string('errorsetting', 'admin'));
     }
 
+    /**
+     * Validate the setting. This uses the callback function if provided; subclasses could override
+     * to carry out validation directly in the class.
+     *
+     * @param string $data New value being set
+     * @return string Empty string if valid, or error message text
+     * @since Moodle 4.0
+     */
+    protected function validate_setting(string $data): string {
+        // If validation function is specified, call it now.
+        if ($this->validatefunction) {
+            return call_user_func($this->validatefunction, $data);
+        } else {
+            return '';
+        }
+    }
+
     /**
      * Returns XHTML select field
      *
@@ -3423,7 +3470,6 @@ class admin_setting_configselect extends admin_setting {
     }
 }
 
-
 /**
  * Select multiple items from list
  *
index 299a0b1..c813362 100644 (file)
@@ -1817,11 +1817,12 @@ class core_renderer extends renderer_base {
      */
     public function blocks_for_region($region) {
         $blockcontents = $this->page->blocks->get_content_for_region($region, $this);
-        $blocks = $this->page->blocks->get_blocks_for_region($region);
         $lastblock = null;
         $zones = array();
-        foreach ($blocks as $block) {
-            $zones[] = $block->title;
+        foreach ($blockcontents as $bc) {
+            if ($bc instanceof block_contents) {
+                $zones[] = $bc->title;
+            }
         }
         $output = '';
 
index 014bcbc..60a675e 100644 (file)
@@ -32,6 +32,10 @@ information provided here is intended especially for developers.
 * The `core_output_load_fontawesome_icon_map` web service has been deprecated and replaced by
   `core_output_load_fontawesome_icon_system_map` which takes the name of the theme to generate the icon system map for.
 * The class coursecat_sortable_records has been removed.
+* Admin setting admin_setting_configselect now supports lazy-loading the options list by supplying
+  a callback function instead of an array of options.
+* Admin setting admin_setting_configselect now supports validating the selection by supplying a
+  callback function.
 
 === 3.9 ===
 * Following function has been deprecated, please use \core\task\manager::run_from_cli().
index ab2d9e5..207533e 100644 (file)
@@ -75,7 +75,6 @@
 {{/duedate}}
 </div>
 
-</span>
 </div>
 
 {{!
@@ -97,7 +96,7 @@
 </div>
 {{#js}}
 require(['mod_assign/grading_navigation', 'core/tooltip'], function(GradingNavigation, ToolTip) {
-    var nav = new GradingNavigation('[data-region="user-selector"]');
-    var tooltip = new ToolTip('[data-region="assignment-tooltip"]');
+    new GradingNavigation('[data-region="user-selector"]');
+    new ToolTip('[data-region="assignment-tooltip"]');
 });
 {{/js}}
index 30378ce..f370b3b 100644 (file)
@@ -44,7 +44,7 @@
     </small>
 </span>
 
-<span data-region="configure-filters" id="filter-configuration-{{uniqid}}" class="card card-large p-2">
+<div data-region="configure-filters" id="filter-configuration-{{uniqid}}" class="card card-large p-2">
     <form>
         <span class="row px-3 py-1">
             <label class="text-right w-25 p-2 m-0" for="filter-general-{{uniqid}}">
@@ -81,7 +81,7 @@
         </span>
         {{/hasmarkingworkflow}}
     </form>
-</span>
+</div>
 
 <a href="#" data-region="user-filters" title="{{#str}}changefilters, mod_assign{{/str}}" aria-expanded="false" aria-controls="filter-configuration-{{uniqid}}">
     <span class="accesshide">
index a625333..54c4491 100644 (file)
@@ -506,7 +506,7 @@ class send_user_digests extends \core\task\adhoc_task {
             $this->log("Adding post {$post->id} in format {$maildigest} without HTML", 2);
         }
 
-        if ($maildigest == 1 && $CFG->forum_usermarksread) {
+        if ($maildigest == 1 && !$CFG->forum_usermarksread) {
             // Create an array of postid's for this user to mark as read.
             $this->markpostsasread[] = $post->id;
         }
index e725f75..efd8019 100644 (file)
@@ -694,4 +694,118 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         $digesttime = usergetmidnight(time(), \core_date::get_server_timezone()) + ($CFG->digestmailtime * 3600);
         $this->assertLessThanOrEqual($digesttime, $task->nextruntime);
     }
+
+    /**
+     * The sending of a digest marks posts as read if automatic message read marking is set.
+     */
+    public function test_cron_digest_marks_posts_read() {
+        global $DB, $CFG;
+
+        $this->resetAfterTest(true);
+
+        // Disable the 'Manual message read marking' option.
+        $CFG->forum_usermarksread = false;
+
+        // Set up a basic user enrolled in a course.
+        $userhelper = $this->helper_setup_user_in_course();
+        $user = $userhelper->user;
+        $course1 = $userhelper->courses->course1;
+        $forum1 = $userhelper->forums->forum1;
+        $posts = [];
+
+        // Set the tested user's default maildigest, trackforums, read tracking settings.
+        $DB->set_field('user', 'maildigest', 1, ['id' => $user->id]);
+        $DB->set_field('user', 'trackforums', 1, ['id' => $user->id]);
+        set_user_preference('forum_markasreadonnotification', 1, $user->id);
+
+        // Set the maildigest preference for forum1 to default.
+        forum_set_user_maildigest($forum1, -1, $user);
+
+        // Add 5 discussions to forum 1.
+        for ($i = 0; $i < 5; $i++) {
+            list($discussion, $post) = $this->helper_post_to_forum($forum1, $user, ['mailnow' => 1]);
+            $posts[] = $post;
+        }
+
+        // There should be unread posts for the forum.
+        $expectedposts = [
+            $forum1->id => (object) [
+                'id' => $forum1->id,
+                'unread' => count($posts),
+            ],
+        ];
+        $this->assertEquals($expectedposts, forum_tp_get_course_unread_posts($user->id, $course1->id));
+
+        // One digest mail should be sent and no other messages.
+        $expect = [
+            (object) [
+                'userid' => $user->id,
+                'messages' => 0,
+                'digests' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_digests_and_assert($user, $posts);
+
+        // Verify that there are no unread posts for any forums.
+        $this->assertEmpty(forum_tp_get_course_unread_posts($user->id, $course1->id));
+    }
+
+    /**
+     * The sending of a digest does not mark posts as read when manual message read marking is set.
+     */
+    public function test_cron_digest_leaves_posts_unread() {
+        global $DB, $CFG;
+
+        $this->resetAfterTest(true);
+
+        // Enable the 'Manual message read marking' option.
+        $CFG->forum_usermarksread = true;
+
+        // Set up a basic user enrolled in a course.
+        $userhelper = $this->helper_setup_user_in_course();
+        $user = $userhelper->user;
+        $course1 = $userhelper->courses->course1;
+        $forum1 = $userhelper->forums->forum1;
+        $posts = [];
+
+        // Set the tested user's default maildigest, trackforums, read tracking settings.
+        $DB->set_field('user', 'maildigest', 1, ['id' => $user->id]);
+        $DB->set_field('user', 'trackforums', 1, ['id' => $user->id]);
+        set_user_preference('forum_markasreadonnotification', 1, $user->id);
+
+        // Set the maildigest preference for forum1 to default.
+        forum_set_user_maildigest($forum1, -1, $user);
+
+        // Add 5 discussions to forum 1.
+        for ($i = 0; $i < 5; $i++) {
+            list($discussion, $post) = $this->helper_post_to_forum($forum1, $user, ['mailnow' => 1]);
+            $posts[] = $post;
+        }
+
+        // There should be unread posts for the forum.
+        $expectedposts = [
+            $forum1->id => (object) [
+                'id' => $forum1->id,
+                'unread' => count($posts),
+            ],
+        ];
+        $this->assertEquals($expectedposts, forum_tp_get_course_unread_posts($user->id, $course1->id));
+
+        // One digest mail should be sent and no other messages.
+        $expect = [
+            (object) [
+                'userid' => $user->id,
+                'messages' => 0,
+                'digests' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_digests_and_assert($user, $posts);
+
+        // Verify that there are still the same unread posts for the forum.
+        $this->assertEquals($expectedposts, forum_tp_get_course_unread_posts($user->id, $course1->id));
+    }
 }
index 5854c02..722e0fb 100644 (file)
@@ -84,9 +84,13 @@ abstract class engine {
      *
      * Search engine availability should be checked separately.
      *
+     * The alternate configuration option is only used to construct a special second copy of the
+     * search engine object, as described in {@see has_alternate_configuration}.
+     *
+     * @param bool $alternateconfiguration If true, use alternate configuration settings
      * @return void
      */
-    public function __construct() {
+    public function __construct(bool $alternateconfiguration = false) {
 
         $classname = get_class($this);
         if (strpos($classname, '\\') === false) {
@@ -102,6 +106,19 @@ abstract class engine {
         } else {
             $this->config = new stdClass();
         }
+
+        // For alternate configuration, automatically replace normal configuration values with
+        // those beginning with 'alternate'.
+        if ($alternateconfiguration) {
+            foreach ((array)$this->config as $key => $value) {
+                if (preg_match('~^alternate(.*)$~', $key, $matches)) {
+                    $this->config->{$matches[1]} = $value;
+                }
+            }
+        }
+
+        // Flag just in case engine needs to know it is using the alternate configuration.
+        $this->config->alternateconfiguration = $alternateconfiguration;
     }
 
     /**
@@ -740,4 +757,24 @@ abstract class engine {
     public function get_batch_max_content(): int {
         return 1024 * 1024;
     }
+
+    /**
+     * Checks if the search engine has an alternate configuration.
+     *
+     * This is used where the same search engine class supports two different configurations,
+     * which are both shown on the settings screen. The alternate configuration is selected by
+     * passing 'true' parameter to the constructor.
+     *
+     * The feature is used when a different connection is in use for indexing vs. querying
+     * the search engine.
+     *
+     * This function should only return true if the engine supports an alternate configuration
+     * and the user has filled in the settings. (We do not need to test they are valid, that will
+     * happen as normal.)
+     *
+     * @return bool True if an alternate configuration is defined
+     */
+    public function has_alternate_configuration(): bool {
+        return false;
+    }
 }
index 458dad7..8a76862 100644 (file)
@@ -190,13 +190,18 @@ class manager {
      * parameter provides a way to skip those checks on pages which are used frequently. It has
      * no effect if an instance has already been constructed in this request.
      *
+     * The $query parameter indicates that the page is used for queries rather than indexing. If
+     * configured, this will cause the query-only search engine to be used instead of the 'normal'
+     * one.
+     *
      * @see \core_search\engine::is_installed
      * @see \core_search\engine::is_server_ready
      * @param bool $fast Set to true when calling on a page that requires high performance
+     * @param bool $query Set true on a page that is used for querying
      * @throws \core_search\engine_exception
      * @return \core_search\manager
      */
-    public static function instance($fast = false) {
+    public static function instance(bool $fast = false, bool $query = false) {
         global $CFG;
 
         // One per request, this should be purged during testing.
@@ -208,7 +213,7 @@ class manager {
             throw new \core_search\engine_exception('enginenotselected', 'search');
         }
 
-        if (!$engine = static::search_engine_instance()) {
+        if (!$engine = static::search_engine_instance($query)) {
             throw new \core_search\engine_exception('enginenotfound', 'search', '', $CFG->searchengine);
         }
 
@@ -287,17 +292,46 @@ class manager {
     /**
      * Returns an instance of the search engine.
      *
+     * @param bool $query If true, gets the query-only search engine (where configured)
      * @return \core_search\engine
      */
-    public static function search_engine_instance() {
+    public static function search_engine_instance(bool $query = false) {
         global $CFG;
 
-        $classname = '\\search_' . $CFG->searchengine . '\\engine';
+        if ($query && $CFG->searchenginequeryonly) {
+            return self::search_engine_instance_from_setting($CFG->searchenginequeryonly);
+        } else {
+            return self::search_engine_instance_from_setting($CFG->searchengine);
+        }
+    }
+
+    /**
+     * Loads a search engine based on the name given in settings, which can optionally
+     * include '-alternate' to indicate that an alternate version should be used.
+     *
+     * @param string $setting
+     * @return engine|null
+     */
+    protected static function search_engine_instance_from_setting(string $setting): ?engine {
+        if (preg_match('~^(.*)-alternate$~', $setting, $matches)) {
+            $enginename = $matches[1];
+            $alternate = true;
+        } else {
+            $enginename = $setting;
+            $alternate = false;
+        }
+
+        $classname = '\\search_' . $enginename . '\\engine';
         if (!class_exists($classname)) {
-            return false;
+            return null;
         }
 
-        return new $classname();
+        if ($alternate) {
+            return new $classname(true);
+        } else {
+            // Use the constructor with no parameters for compatibility.
+            return new $classname();
+        }
     }
 
     /**
index 0d433ab..68912be 100644 (file)
@@ -116,10 +116,11 @@ class engine extends \core_search\engine {
     /**
      * Initialises the search engine configuration.
      *
+     * @param bool $alternateconfiguration If true, use alternate configuration settings
      * @return void
      */
-    public function __construct() {
-        parent::__construct();
+    public function __construct(bool $alternateconfiguration = false) {
+        parent::__construct($alternateconfiguration);
 
         $curlversion = curl_version();
         if (isset($curlversion['version']) && stripos($curlversion['version'], '7.35.') === 0) {
@@ -1256,7 +1257,7 @@ class engine extends \core_search\engine {
 
         // Check that the schema is already set up.
         try {
-            $schema = new \search_solr\schema();
+            $schema = new schema($this);
             $schema->validate_setup();
         } catch (\moodle_exception $e) {
             return $e->getMessage();
@@ -1466,7 +1467,7 @@ class engine extends \core_search\engine {
 
     protected function update_schema($oldversion, $newversion) {
         // Construct schema.
-        $schema = new schema();
+        $schema = new schema($this);
         $cansetup = $schema->can_setup_server();
         if ($cansetup !== true) {
             return $cansetup;
@@ -1564,4 +1565,15 @@ class engine extends \core_search\engine {
             throw new \core_search\engine_exception('error_solr', 'search_solr', '', $e->getMessage());
         }
     }
+
+    /**
+     * Checks if an alternate configuration has been defined.
+     *
+     * @return bool True if alternate configuration is available
+     */
+    public function has_alternate_configuration(): bool {
+        return !empty($this->config->alternateserver_hostname) &&
+                !empty($this->config->alternateindexname) &&
+                !empty($this->config->alternateserver_port);
+    }
 }
index a62d4b9..743166f 100644 (file)
@@ -60,10 +60,11 @@ class schema {
     /**
      * Constructor.
      *
+     * @param engine $engine Optional engine parameter, if not specified then one will be created
      * @throws \moodle_exception
      * @return void
      */
-    public function __construct() {
+    public function __construct(engine $engine = null) {
         if (!$this->config = get_config('search_solr')) {
             throw new \moodle_exception('missingconfig', 'search_solr');
         }
@@ -72,7 +73,7 @@ class schema {
             throw new \moodle_exception('missingconfig', 'search_solr');
         }
 
-        $this->engine = new engine();
+        $this->engine = $engine ?? new engine();
         $this->curl = $this->engine->get_curl_object();
 
         // HTTP headers.
index f09e758..fd566dc 100644 (file)
@@ -57,6 +57,42 @@ if ($ADMIN->fulltree) {
             $settings->add(new admin_setting_configtext('search_solr/maxindexfilekb',
                     new lang_string('maxindexfilekb', 'search_solr'),
                     new lang_string('maxindexfilekb_help', 'search_solr'), '2097152', PARAM_INT));
+
+            // Alternate connection.
+            $settings->add(new admin_setting_heading('search_solr_alternatesettings',
+                    new lang_string('searchalternatesettings', 'admin'),
+                    new lang_string('searchalternatesettings_desc', 'admin')));
+            $settings->add(new admin_setting_configtext('search_solr/alternateserver_hostname',
+                    new lang_string('solrserverhostname', 'search_solr'),
+                    new lang_string('solrserverhostname_desc', 'search_solr'), '127.0.0.1', PARAM_HOST));
+            $settings->add(new admin_setting_configtext('search_solr/alternateindexname',
+                    new lang_string('solrindexname', 'search_solr'), '', '', PARAM_ALPHANUMEXT));
+            $settings->add(new admin_setting_configcheckbox('search_solr/alternatesecure',
+                    new lang_string('solrsecuremode', 'search_solr'), '', 0, 1, 0));
+
+            $secure = get_config('search_solr', 'alternatesecure');
+            $defaultport = !empty($secure) ? 8443 : 8983;
+            $settings->add(new admin_setting_configtext('search_solr/alternateserver_port',
+                    new lang_string('solrhttpconnectionport', 'search_solr'), '', $defaultport, PARAM_INT));
+            $settings->add(new admin_setting_configtext('search_solr/alternateserver_username',
+                    new lang_string('solrauthuser', 'search_solr'), '', '', PARAM_RAW));
+            $settings->add(new admin_setting_configpasswordunmask('search_solr/alternateserver_password',
+                    new lang_string('solrauthpassword', 'search_solr'), '', ''));
+            $settings->add(new admin_setting_configtext('search_solr/alternatessl_cert',
+                    new lang_string('solrsslcert', 'search_solr'),
+                    new lang_string('solrsslcert_desc', 'search_solr'), '', PARAM_RAW));
+            $settings->add(new admin_setting_configtext('search_solr/alternatessl_key',
+                    new lang_string('solrsslkey', 'search_solr'),
+                    new lang_string('solrsslkey_desc', 'search_solr'), '', PARAM_RAW));
+            $settings->add(new admin_setting_configpasswordunmask('search_solr/alternatessl_keypassword',
+                    new lang_string('solrsslkeypassword', 'search_solr'),
+                    new lang_string('solrsslkeypassword_desc', 'search_solr'), ''));
+            $settings->add(new admin_setting_configtext('search_solr/alternatessl_cainfo',
+                    new lang_string('solrsslcainfo', 'search_solr'),
+                    new lang_string('solrsslcainfo_desc', 'search_solr'), '', PARAM_RAW));
+            $settings->add(new admin_setting_configtext('search_solr/alternatessl_capath',
+                    new lang_string('solrsslcapath', 'search_solr'),
+                    new lang_string('solrsslcapath_desc', 'search_solr'), '', PARAM_RAW));
         }
     }
 }
index 0bdae77..0acc303 100644 (file)
@@ -132,7 +132,7 @@ class search_solr_engine_testcase extends advanced_testcase {
         $this->search->delete_index();
 
         // Add moodle fields if they don't exist.
-        $schema = new \search_solr\schema();
+        $schema = new \search_solr\schema($this->engine);
         $schema->setup(false);
     }
 
@@ -159,6 +159,45 @@ class search_solr_engine_testcase extends advanced_testcase {
         $this->assertTrue($this->engine->is_server_ready());
     }
 
+    /**
+     * Tests that the alternate settings are used when configured.
+     */
+    public function test_alternate_settings() {
+        // Index a couple of things.
+        $this->generator->create_record();
+        $this->generator->create_record();
+        $this->search->index();
+
+        // By default settings, alternates are not set.
+        $this->assertFalse($this->engine->has_alternate_configuration());
+
+        // Set up all the config the same as normal.
+        foreach (['server_hostname', 'indexname', 'secure', 'server_port',
+                'server_username', 'server_password'] as $setting) {
+            set_config('alternate' . $setting, get_config('search_solr', $setting), 'search_solr');
+        }
+        // Also mess up the normal config.
+        set_config('indexname', 'not_the_right_index_name', 'search_solr');
+
+        // Construct a new engine using normal settings.
+        $engine = new search_solr\engine();
+
+        // Now alternates are available.
+        $this->assertTrue($engine->has_alternate_configuration());
+
+        // But it won't actually work because of the bogus index name.
+        $this->assertFalse($engine->is_server_ready() === true);
+        $this->assertDebuggingCalled();
+
+        // But if we construct one using alternate settings, it will work as normal.
+        $engine = new search_solr\engine(true);
+        $this->assertTrue($engine->is_server_ready());
+
+        // Including finding the search results.
+        $this->assertCount(2, $engine->execute_query(
+                (object)['q' => 'message'], (object)['everything' => true]));
+    }
+
     /**
      * @dataProvider file_indexing_provider
      */
index 936dfbc..c074716 100644 (file)
@@ -64,7 +64,7 @@ if (\core_search\manager::is_global_search_enabled() === false) {
     exit;
 }
 
-$search = \core_search\manager::instance(true);
+$search = \core_search\manager::instance(true, true);
 
 // Set up custom data for form.
 $customdata = ['searchengine' => $search->get_engine()->get_plugin_name()];
@@ -176,6 +176,11 @@ if ($data) {
     $results = $search->paged_search($data, $page);
 }
 
+// Show search information if configured by system administrator.
+if ($CFG->searchbannerenable && $CFG->searchbanner) {
+    echo $OUTPUT->notification(format_text($CFG->searchbanner, FORMAT_HTML), 'notifywarning');
+}
+
 if ($errorstr = $search->get_engine()->get_query_error()) {
     echo $OUTPUT->notification(get_string('queryerror', 'search', $errorstr), 'notifyproblem');
 } else if (empty($results->totalcount) && !empty($data)) {
diff --git a/search/tests/behat/search_information.feature b/search/tests/behat/search_information.feature
new file mode 100644 (file)
index 0000000..5ba7e02
--- /dev/null
@@ -0,0 +1,36 @@
+@core @core_search
+Feature: Show system information in the search interface
+  In order to let users know if there are current problems with search
+  As an admin
+  I need to be able to show information on search pages
+
+  Background:
+    Given the following config values are set as admin:
+      | enableglobalsearch | 1        |
+      | searchengine       | simpledb |
+    And I log in as "admin"
+
+  @javascript
+  Scenario: Information displays when enabled
+    When the following config values are set as admin:
+      | searchbannerenable | 1                                                                             |
+      | searchbanner       | The search currently only finds frog-related content; we hope to fix it soon. |
+    And I search for "toads" using the header global search box
+    Then I should see "The search currently only finds frog-related content" in the ".notifywarning" "css_element"
+
+  @javascript
+  Scenario: Information does not display when not enabled
+    When the following config values are set as admin:
+      | searchbannerenable | 0                                                                             |
+      | searchbanner       | The search currently only finds frog-related content; we hope to fix it soon. |
+    And I search for "toads" using the header global search box
+    Then I should not see "The search currently only finds frog-related content"
+    And ".notifywarning" "css_element" should not exist
+
+  @javascript
+  Scenario: Information does not display when left blank
+    When the following config values are set as admin:
+      | searchbannerenable | 1 |
+      | searchbanner       |   |
+    And I search for "toads" using the header global search box
+    Then ".notifywarning" "css_element" should not exist
index 62193b4..d53b0c6 100644 (file)
@@ -46,9 +46,10 @@ class testable_core_search extends \core_search\manager {
      * Auto enables global search.
      *
      * @param  \core_search\engine|bool $searchengine
+     * @param bool $ignored Second param just to make this compatible with base class
      * @return testable_core_search
      */
-    public static function instance($searchengine = false) {
+    public static function instance($searchengine = false, bool $ignored = false) {
 
         // One per request, this should be purged during testing.
         if (self::$instance !== null) {
index 3566bc8..d56cdd9 100644 (file)
@@ -8,6 +8,11 @@ information provided here is intended especially for developers.
   should implement add_document_batch() function and return true to supports_add_document_batch().
   There is also an additional parameter returned from add_documents() with the number of batches
   sent, which is used for the log display. Existing engines should continue to work unmodified.
+* Search engines can now implement the optional has_alternate_configuration() function to indicate
+  if they provide two different connection configurations (for use when moving between two search
+  engines of the same type). The constructor should also accept a boolean value (true = alternate);
+  passing this to the base class constructor will automatically switch in the alternate
+  configuration settings, provided they begin with 'alternate'.
 
 === 3.8 ===