���ѧۧݧ�ӧ�� �ާ֧ߧ֧էا֧� - ���֧էѧܧ�ڧ��ӧѧ�� - /home3/cpr76684/public_html/search.tar
���ѧ٧ѧ�
search_test.php 0000644 00000027046 15152003677 0007600 0 ustar 00 <?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/>. /** * Course global search unit tests. * * @package core_user * @copyright 2016 Devang Gaur {@link http://www.devanggaur.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_user\search; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php'); /** * Provides the unit tests for course global search. * * @package core * @copyright 2016 Devang Gaur {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class search_test extends \advanced_testcase { /** * @var string Area id */ protected $userareaid = null; public function setUp(): void { $this->resetAfterTest(true); set_config('enableglobalsearch', true); $this->userareaid = \core_search\manager::generate_areaid('core_user', 'user'); // Set \core_search::instance to the mock_search_engine as we don't require the search engine to be working to test this. $search = \testable_core_search::instance(); } /** * Indexing users contents. * * @return void */ public function test_users_indexing() { global $SITE; // Returns the instance as long as the area is supported. $searcharea = \core_search\manager::get_search_area($this->userareaid); $this->assertInstanceOf('\core_user\search\user', $searcharea); $user1 = self::getDataGenerator()->create_user(); $user2 = self::getDataGenerator()->create_user(); // All records. // Recordset will produce 4 user records: // Guest User, Admin User and two above generated users. $recordset = $searcharea->get_recordset_by_timestamp(0); $this->assertTrue($recordset->valid()); $nrecords = 0; foreach ($recordset as $record) { $this->assertInstanceOf('stdClass', $record); $doc = $searcharea->get_document($record); $this->assertInstanceOf('\core_search\document', $doc); $nrecords++; } // If there would be an error/failure in the foreach above the recordset would be closed on shutdown. $recordset->close(); $this->assertEquals(4, $nrecords); // The +2 is to prevent race conditions. $recordset = $searcharea->get_recordset_by_timestamp(time() + 2); // No new records. $this->assertFalse($recordset->valid()); $recordset->close(); // Context support; first, try an unsupported context type. $coursecontext = \context_course::instance($SITE->id); $this->assertNull($searcharea->get_document_recordset(0, $coursecontext)); // Try a specific user, will only return 1 record (that user). $rs = $searcharea->get_document_recordset(0, \context_user::instance($user1->id)); $this->assertEquals(1, iterator_count($rs)); $rs->close(); } /** * Document contents. * * @return void */ public function test_users_document() { // Returns the instance as long as the area is supported. $searcharea = \core_search\manager::get_search_area($this->userareaid); $this->assertInstanceOf('\core_user\search\user', $searcharea); $user = self::getDataGenerator()->create_user(); $doc = $searcharea->get_document($user); $this->assertInstanceOf('\core_search\document', $doc); $this->assertEquals($user->id, $doc->get('itemid')); $this->assertEquals($this->userareaid . '-' . $user->id, $doc->get('id')); $this->assertEquals(SITEID, $doc->get('courseid')); $this->assertFalse($doc->is_set('userid')); $this->assertEquals(\core_search\manager::NO_OWNER_ID, $doc->get('owneruserid')); $this->assertEquals(content_to_text(fullname($user), false), $searcharea->get_document_display_title($doc)); $this->assertEquals(content_to_text($user->description, $user->descriptionformat), $doc->get('content')); } /** * Document accesses. * * @return void */ public function test_users_access() { global $CFG; // Returns the instance as long as the area is supported. $searcharea = \core_search\manager::get_search_area($this->userareaid); $user1 = self::getDataGenerator()->create_user(); $user2 = self::getDataGenerator()->create_user(); $user3 = self::getDataGenerator()->create_user(); $user4 = self::getDataGenerator()->create_user(); $user5 = self::getDataGenerator()->create_user(); $user5->id = 0; // Visitor (not guest). $deleteduser = self::getDataGenerator()->create_user(array('deleted' => 1)); $unconfirmeduser = self::getDataGenerator()->create_user(array('confirmed' => 0)); $suspendeduser = self::getDataGenerator()->create_user(array('suspended' => 1)); $course1 = self::getDataGenerator()->create_course(); $course2 = self::getDataGenerator()->create_course(); $group1 = $this->getDataGenerator()->create_group(array('courseid' => $course1->id)); $group2 = $this->getDataGenerator()->create_group(array('courseid' => $course1->id)); $this->getDataGenerator()->enrol_user($user1->id, $course1->id, 'teacher'); $this->getDataGenerator()->enrol_user($user2->id, $course1->id, 'student'); $this->getDataGenerator()->enrol_user($user2->id, $course2->id, 'student'); $this->getDataGenerator()->enrol_user($user3->id, $course2->id, 'student'); $this->getDataGenerator()->enrol_user($user4->id, $course2->id, 'student'); $this->getDataGenerator()->enrol_user($suspendeduser->id, $course1->id, 'student'); $this->getDataGenerator()->create_group_member(array('userid' => $user2->id, 'groupid' => $group1->id)); $this->getDataGenerator()->create_group_member(array('userid' => $user3->id, 'groupid' => $group1->id)); $this->getDataGenerator()->create_group_member(array('userid' => $user4->id, 'groupid' => $group2->id)); $this->setAdminUser(); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user1->id)); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user2->id)); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user3->id)); $this->assertEquals(\core_search\manager::ACCESS_DELETED, $searcharea->check_access($deleteduser->id)); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($unconfirmeduser->id)); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($suspendeduser->id)); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access(2)); $this->setUser($user1); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user1->id)); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user2->id)); $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($user3->id)); $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($user4->id)); $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access(1));// Guest user can't be accessed. $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access(2));// Admin user can't be accessed. $this->assertEquals(\core_search\manager::ACCESS_DELETED, $searcharea->check_access(-123)); $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($unconfirmeduser->id)); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($suspendeduser->id)); $this->setUser($user2); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user1->id)); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user2->id)); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user3->id)); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user4->id)); $this->setUser($user3); $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($user1->id)); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user2->id)); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user3->id)); $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($suspendeduser->id)); $this->setGuestUser(); $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($user1->id)); $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($user2->id)); $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($user3->id)); $CFG->forceloginforprofiles = 0; $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user1->id)); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user2->id)); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user3->id)); $this->setUser($user5); $CFG->forceloginforprofiles = 1; $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($user1->id)); $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($user2->id)); $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($user3->id)); $CFG->forceloginforprofiles = 0; $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user1->id)); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user2->id)); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($user3->id)); } /** * Test document icon. */ public function test_get_doc_icon() { $searcharea = \core_search\manager::get_search_area($this->userareaid); $user = self::getDataGenerator()->create_user(); $doc = $searcharea->get_document($user); $result = $searcharea->get_doc_icon($doc); $this->assertEquals('i/user', $result->get_name()); $this->assertEquals('moodle', $result->get_component()); } /** * Test assigned search categories. */ public function test_get_category_names() { $searcharea = \core_search\manager::get_search_area($this->userareaid); $expected = ['core-users']; $this->assertEquals($expected, $searcharea->get_category_names()); } } cli/indexer.php 0000644 00000007323 15152012435 0007465 0 ustar 00 <?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/>. /** * CLI search indexer * * @package search * @copyright 2016 Dan Poltawski <dan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define('CLI_SCRIPT', true); require(__DIR__.'/../../config.php'); require_once($CFG->libdir.'/clilib.php'); // cli only functions list($options, $unrecognized) = cli_get_params(array('help' => false, 'force' => false, 'reindex' => false, 'timelimit' => 0), array('h' => 'help', 'f' => 'force', 'r' => 'reindex', 't' => 'timelimit')); if ($unrecognized) { $unrecognized = implode("\n ", $unrecognized); cli_error(get_string('cliunknowoption', 'admin', $unrecognized)); } if ($options['help']) { $help = "Index search data Options: -h, --help Print out this help -r, --reindex Reindex data -f, --force Allow indexer to run, even if global search is disabled. -t=<n>, --timelimit=<n> Stop after indexing for specified time (in seconds) Examples: \$ sudo -u www-data /usr/bin/php search/cli/indexer.php --reindex \$ sudo -u www-data /usr/bin/php search/cli/indexer.php --timelimit=300 "; echo $help; die; } if ($options['timelimit'] && $options['reindex']) { cli_error('Cannot apply time limit when reindexing'); } if (!\core_search\manager::is_global_search_enabled() && empty($options['force'])) { cli_error('Global search is disabled. Use --force if you want to force an index while disabled'); } if (!$searchengine = \core_search\manager::search_engine_instance()) { cli_error(get_string('engineserverstatus', 'search')); } if (!$searchengine->is_installed()) { cli_error('enginenotinstalled', 'search', $CFG->searchengine); } $serverstatus = $searchengine->is_server_ready(); if ($serverstatus !== true) { cli_error($serverstatus); } $globalsearch = \core_search\manager::instance(); if (empty($options['reindex'])) { if ($options['timelimit']) { $limitinfo = ' (max ' . $options['timelimit'] . ' seconds)'; $limitunderline = preg_replace('~.~', '=', $limitinfo); echo "Running index of site$limitinfo\n"; echo "=====================$limitunderline\n"; $timelimit = (int)$options['timelimit']; } else { echo "Running full index of site\n"; echo "==========================\n"; $timelimit = 0; } $before = time(); $globalsearch->index(false, $timelimit, new text_progress_trace()); // Do specific index requests with the remaining time. if ($timelimit) { $timelimit -= (time() - $before); // Only do index requests if there is a reasonable amount of time left. if ($timelimit > 1) { $globalsearch->process_index_requests($timelimit, new text_progress_trace()); } } else { $globalsearch->process_index_requests(0, new text_progress_trace()); } } else { echo "Running full reindex of site\n"; echo "============================\n"; $globalsearch->index(true, 0, new text_progress_trace()); } // Optimize index at last. $globalsearch->optimize_index(); amd/build/form-search-user-selector.min.js 0000644 00000002210 15152012435 0014515 0 ustar 00 /** * Search user selector module. * * @module core_search/form-search-user-selector * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define("core_search/form-search-user-selector",["jquery","core/ajax","core/templates"],(function($,Ajax,Templates){return{processResults:function(selector,results){var users=[];return $.each(results,(function(index,user){users.push({value:user.id,label:user._label})})),users},transport:function(selector,query,success,failure){var args={query:query},courseid=$(selector).attr("withincourseid");void 0!==courseid&&""!==$("#id_searchwithin").val()?args.courseid=courseid:args.courseid=0,Ajax.call([{methodname:"core_search_get_relevant_users",args:args}])[0].then((function(results){var promises=[];return $.each(results,(function(index,user){promises.push(Templates.render("core_search/form-user-selector-suggestion",user))})),$.when.apply($.when,promises).then((function(){var args=arguments,i=0;$.each(results,(function(index,user){user._label=args[i++]})),success(results)}))})).fail(failure)}}})); //# sourceMappingURL=form-search-user-selector.min.js.map amd/build/form-search-user-selector.min.js.map 0000644 00000010002 15152012435 0015267 0 ustar 00 {"version":3,"file":"form-search-user-selector.min.js","sources":["../src/form-search-user-selector.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Search user selector module.\n *\n * @module core_search/form-search-user-selector\n * @copyright 2017 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\ndefine(['jquery', 'core/ajax', 'core/templates'], function($, Ajax, Templates) {\n\n return /** @alias module:core_search/form-search-user-selector */ {\n\n processResults: function(selector, results) {\n var users = [];\n $.each(results, function(index, user) {\n users.push({\n value: user.id,\n label: user._label\n });\n });\n return users;\n },\n\n transport: function(selector, query, success, failure) {\n var promise;\n\n // Search within specific course if known and if the 'search within' dropdown is set\n // to search within course or activity.\n var args = {query: query};\n var courseid = $(selector).attr('withincourseid');\n if (typeof courseid !== \"undefined\" && $('#id_searchwithin').val() !== '') {\n args.courseid = courseid;\n } else {\n args.courseid = 0;\n }\n\n // Call AJAX request.\n promise = Ajax.call([{methodname: 'core_search_get_relevant_users', args: args}]);\n\n // When AJAX request returns, handle the results.\n promise[0].then(function(results) {\n var promises = [];\n\n // Render label with user name and picture.\n $.each(results, function(index, user) {\n promises.push(Templates.render('core_search/form-user-selector-suggestion', user));\n });\n\n // Apply the label to the results.\n return $.when.apply($.when, promises).then(function() {\n var args = arguments;\n var i = 0;\n $.each(results, function(index, user) {\n user._label = args[i++];\n });\n success(results);\n return;\n });\n\n }).fail(failure);\n }\n\n };\n\n});\n"],"names":["define","$","Ajax","Templates","processResults","selector","results","users","each","index","user","push","value","id","label","_label","transport","query","success","failure","args","courseid","attr","val","call","methodname","then","promises","render","when","apply","arguments","i","fail"],"mappings":";;;;;;;AAuBAA,+CAAO,CAAC,SAAU,YAAa,mBAAmB,SAASC,EAAGC,KAAMC,iBAEE,CAE9DC,eAAgB,SAASC,SAAUC,aAC3BC,MAAQ,UACZN,EAAEO,KAAKF,SAAS,SAASG,MAAOC,MAC5BH,MAAMI,KAAK,CACPC,MAAOF,KAAKG,GACZC,MAAOJ,KAAKK,YAGbR,OAGXS,UAAW,SAASX,SAAUY,MAAOC,QAASC,aAKtCC,KAAO,CAACH,MAAOA,OACfI,SAAWpB,EAAEI,UAAUiB,KAAK,uBACR,IAAbD,UAA4D,KAAhCpB,EAAE,oBAAoBsB,MACzDH,KAAKC,SAAWA,SAEhBD,KAAKC,SAAW,EAIVnB,KAAKsB,KAAK,CAAC,CAACC,WAAY,iCAAkCL,KAAMA,QAGlE,GAAGM,MAAK,SAASpB,aACjBqB,SAAW,UAGf1B,EAAEO,KAAKF,SAAS,SAASG,MAAOC,MAC5BiB,SAAShB,KAAKR,UAAUyB,OAAO,4CAA6ClB,UAIzET,EAAE4B,KAAKC,MAAM7B,EAAE4B,KAAMF,UAAUD,MAAK,eACnCN,KAAOW,UACPC,EAAI,EACR/B,EAAEO,KAAKF,SAAS,SAASG,MAAOC,MAC5BA,KAAKK,OAASK,KAAKY,QAEvBd,QAAQZ,eAIb2B,KAAKd"} amd/src/form-search-user-selector.js 0000644 00000005502 15152012435 0013432 0 ustar 00 // 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/>. /** * Search user selector module. * * @module core_search/form-search-user-selector * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ define(['jquery', 'core/ajax', 'core/templates'], function($, Ajax, Templates) { return /** @alias module:core_search/form-search-user-selector */ { processResults: function(selector, results) { var users = []; $.each(results, function(index, user) { users.push({ value: user.id, label: user._label }); }); return users; }, transport: function(selector, query, success, failure) { var promise; // Search within specific course if known and if the 'search within' dropdown is set // to search within course or activity. var args = {query: query}; var courseid = $(selector).attr('withincourseid'); if (typeof courseid !== "undefined" && $('#id_searchwithin').val() !== '') { args.courseid = courseid; } else { args.courseid = 0; } // Call AJAX request. promise = Ajax.call([{methodname: 'core_search_get_relevant_users', args: args}]); // When AJAX request returns, handle the results. promise[0].then(function(results) { var promises = []; // Render label with user name and picture. $.each(results, function(index, user) { promises.push(Templates.render('core_search/form-user-selector-suggestion', user)); }); // Apply the label to the results. return $.when.apply($.when, promises).then(function() { var args = arguments; var i = 0; $.each(results, function(index, user) { user._label = args[i++]; }); success(results); return; }); }).fail(failure); } }; }); tests/skip_future_documents_iterator_test.php 0000644 00000015501 15152012435 0016010 0 ustar 00 <?php // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Test iterator that skips future documents * * @package core_search * @category test * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die(); /** * Test iterator that skips future documents * * @package core_search * @category test * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class skip_future_documents_iterator_test extends \basic_testcase { /** * Test normal case with all documents in the past. */ public function test_iterator_all_in_past() { $past = strtotime('2017-11-01'); $documents = [ self::make_doc($past, 1), self::make_doc($past + 1, 2), self::make_doc($past + 2, 3) ]; $this->assertEquals('mod_x-frog-1.mod_x-frog-2.mod_x-frog-3.', self::do_iterator($documents)); } /** * Confirm that the iterator does not call its parent iterator current() function too many * times. */ public function test_iterator_performance() { $counter = new test_counting_iterator(); $iterator = new skip_future_documents_iterator($counter); $items = 0; foreach ($iterator as $value) { $this->assertEquals(false, $value); $items++; } $this->assertEquals(3, $items); $this->assertEquals(3, $counter->get_count()); } /** * Test with no documents at all. */ public function test_iterator_empty() { $this->assertEquals('', self::do_iterator([])); } /** * Test if some documents are in the future. */ public function test_iterator_some_in_future() { $past = strtotime('2017-11-01'); $future = time() + 1000; $documents = [ self::make_doc($past, 1), self::make_doc($past + 1, 2), self::make_doc($future, 3) ]; $this->assertEquals('mod_x-frog-1.mod_x-frog-2.', self::do_iterator($documents)); } /** * Test if all documents are in the future. */ public function test_iterator_all_in_future() { $future = time() + 1000; $documents = [ self::make_doc($future, 1), self::make_doc($future + 1, 2), self::make_doc($future + 2, 3) ]; $this->assertEquals('', self::do_iterator($documents)); } /** * Test when some documents return error. */ public function test_iterator_some_false() { $past = strtotime('2017-11-01'); $documents = [ self::make_doc($past, 1), false, self::make_doc($past + 2, 3) ]; $this->assertEquals('mod_x-frog-1.false.mod_x-frog-3.', self::do_iterator($documents)); } /** * Test when all documents return error. */ public function test_iterator_all_false() { $documents = [ false, false, false ]; $this->assertEquals('false.false.false.', self::do_iterator($documents)); } /** * Test iterator with all cases. */ public function test_iterator_past_false_and_future() { $past = strtotime('2017-11-01'); $future = time() + 1000; $documents = [ false, self::make_doc($past, 1), false, self::make_doc($past + 1, 2), false, self::make_doc($future, 3), false ]; $this->assertEquals('false.mod_x-frog-1.false.mod_x-frog-2.false.', self::do_iterator($documents)); } /** * Helper function to create a search document. * * @param int $time Modified time * @param int $index Item id * @return document Search document */ protected static function make_doc($time, $index) { $doc = new document($index, 'mod_x', 'frog'); $doc->set('modified', $time); return $doc; } /** * Puts documents through the iterator and returns result as a string for easy testing. * * @param document[] $documents Array of documents * @return string Documents converted to string */ protected static function do_iterator(array $documents) { $parent = new \ArrayIterator($documents); $iterator = new skip_future_documents_iterator($parent); $result = ''; foreach ($iterator as $rec) { if (!$rec) { $result .= 'false.'; } else { $result .= $rec->get('id') . '.'; } } return $result; } } /** * Fake iterator just for counting how many times current() is called. It returns 'false' 3 times. * * @package core_search * @category test * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_counting_iterator implements \Iterator { /** @var int Current position in iterator */ protected $pos = 0; /** @var int Number of calls to current() function */ protected $count = 0; /** * Returns the current element. * * @return mixed Can return any type. */ #[\ReturnTypeWillChange] public function current() { $this->count++; return false; } /** * Counts iterator usage. * * @return int Number of times current() was called */ public function get_count() { return $this->count; } /** * Goes on to the next element. */ public function next(): void { $this->pos++; } /** * Gets the key (not supported) * * @throws \coding_exception Always */ #[\ReturnTypeWillChange] public function key() { throw new \coding_exception('Unsupported'); } /** * Checks if iterato is valid (still has entries). * * @return bool True if still valid */ public function valid(): bool { return $this->pos < 3; } /** * Rewinds the iterator. */ public function rewind(): void { $this->pos = 0; } } tests/fixtures/testable_core_search.php 0000644 00000010300 15152012435 0014420 0 ustar 00 <?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/>. /** * Core search class adapted to unit test. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/mock_search_engine.php'); /** * Core search class adapted to unit test. * * Note that by default all core search areas are returned when calling get_search_areas_list, * if you want to use the mock search area you can use testable_core_search::add_search_area * although if you want to add mock search areas on top of the core ones you should call * testable_core_search::add_core_search_areas before calling testable_core_search::add_search_area. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class testable_core_search extends \core_search\manager { /** * Attaches the mock engine to search. * * 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, bool $ignored = false) { // One per request, this should be purged during testing. if (self::$instance !== null) { return self::$instance; } set_config('enableglobalsearch', true); // Default to the mock one. if ($searchengine === false) { $searchengine = new \mock_search\engine(); } self::$instance = new testable_core_search($searchengine); return self::$instance; } /** * Changes visibility. * * @return array */ public function get_areas_user_accesses($limitcourseids = false, $limitcontextids = false) { return parent::get_areas_user_accesses($limitcourseids, $limitcontextids); } /** * Adds an enabled search component to the search areas list. * * @param string $areaid * @param \core_search\base $searcharea * @return void */ public function add_search_area($areaid, \core_search\base $searcharea) { self::$enabledsearchareas[$areaid] = $searcharea; self::$allsearchareas[$areaid] = $searcharea; } /** * Loads all core search areas. * * @return void */ public function add_core_search_areas() { self::get_search_areas_list(false); self::get_search_areas_list(true); } /** * Changes visibility. * * @param string $classname * @return bool */ public static function is_search_area($classname) { return parent::is_search_area($classname); } /** * Fakes the current time for PHPunit. Turns off faking time if called with default parameter. * * Note: This should be replaced with core functionality once possible (see MDL-60644). * * @param float $faketime Current time */ public static function fake_current_time($faketime = 0.0) { static::$phpunitfaketime = $faketime; } /** * Makes build_limitcourseids method public for testing. * * @param \stdClass $formdata Submitted search form data. * * @return array|bool */ public function build_limitcourseids(\stdClass $formdata) { $limitcourseids = parent::build_limitcourseids($formdata); return $limitcourseids; } } tests/fixtures/mock_block_area.php 0000644 00000002355 15152012435 0013366 0 ustar 00 <?php // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Test block area. * * @package core_search * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace block_mockblock\search; defined('MOODLE_INTERNAL') || die; /** * Test block area. * * @package core_search * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class area extends \core_search\base_block { public function get_document($record, $options = array()) { throw new \coding_exception('Not implemented'); } } tests/fixtures/mock_search_engine.php 0000644 00000010540 15152012435 0014071 0 ustar 00 <?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/>. namespace mock_search; /** * Search engine for testing purposes. * * @package core_search * @category phpunit * @copyright David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ use core_search\manager; defined('MOODLE_INTERNAL') || die; class engine extends \core_search\engine { /** @var float If set, waits when adding each document (seconds) */ protected $adddelay = 0; /** @var \core_search\document[] Documents added */ protected $added = []; /** @var array Schema updates applied */ protected $schemaupdates = []; public function is_installed() { return true; } public function is_server_ready() { return true; } public function add_document($document, $fileindexing = false) { if ($this->adddelay) { \testable_core_search::fake_current_time(manager::get_current_time() + $this->adddelay); } $this->added[] = $document; return true; } public function execute_query($data, $usercontexts, $limit = 0) { // No need to implement. } public function delete($areaid = null) { return null; } public function to_document(\core_search\base $searcharea, $docdata) { return parent::to_document($searcharea, $docdata); } public function get_course($courseid) { return parent::get_course($courseid); } public function get_search_area($areaid) { return parent::get_search_area($areaid); } public function get_query_total_count() { return 0; } /** * Sets an add delay to simulate time taken indexing. * * @param float $seconds Delay in seconds for each document */ public function set_add_delay($seconds) { $this->adddelay = $seconds; } /** * Gets the list of indexed (added) documents since last time this function * was called. * * @return \core_search\document[] List of documents, in order added. */ public function get_and_clear_added_documents() { $added = $this->added; $this->added = []; return $added; } public function update_schema($oldversion, $newversion) { $this->schemaupdates[] = [$oldversion, $newversion]; } /** * Gets all schema updates applied, as an array. Each entry has an array with two values, * old and new version. * * @return array List of schema updates for comparison */ public function get_and_clear_schema_updates() { $result = $this->schemaupdates; $this->schemaupdates = []; return $result; } /** * Records delete of course index so it can be checked later. * * @param int $oldcourseid Course id * @return bool True to indicate action taken */ public function delete_index_for_course(int $oldcourseid) { $this->deletes[] = ['course', $oldcourseid]; return true; } /** * Records delete of context index so it can be checked later. * * @param int $oldcontextid Context id * @return bool True to indicate action taken */ public function delete_index_for_context(int $oldcontextid) { $this->deletes[] = ['context', $oldcontextid]; return true; } /** * Gets all course/context deletes applied, as an array. Each entry is an array with two * values, the first is either 'course' or 'context' and the second is the id deleted. * * @return array List of deletes for comparison */ public function get_and_clear_deletes() { $deletes = $this->deletes; $this->deletes = []; return $deletes; } } tests/fixtures/mock_search_area.php 0000644 00000011100 15152012435 0013525 0 ustar 00 <?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/>. namespace core_mocksearch\search; /** * Component implementing search for testing purposes. * * @package core_search * @category phpunit * @copyright David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die; class mock_search_area extends \core_search\base { /** @var float If set, waits when doing the indexing query (seconds) */ protected $indexingdelay = 0; /** * Multiple context level so we can test get_areas_user_accesses. * @var int[] */ protected static $levels = [CONTEXT_COURSE, CONTEXT_USER]; /** * To make things easier, base class required config stuff. * * @return bool */ public function is_enabled() { return true; } public function get_recordset_by_timestamp($modifiedfrom = 0) { global $DB; if ($this->indexingdelay) { \testable_core_search::fake_current_time( \core_search\manager::get_current_time() + $this->indexingdelay); } $sql = "SELECT * FROM {temp_mock_search_area} WHERE timemodified >= ? ORDER BY timemodified ASC"; return $DB->get_recordset_sql($sql, array($modifiedfrom)); } /** * A helper function that will turn a record into 'data array', for use with document building. */ public function convert_record_to_doc_array($record) { $docdata = (array)unserialize($record->info); $docdata['areaid'] = $this->get_area_id(); $docdata['itemid'] = $record->id; $docdata['modified'] = $record->timemodified; return $docdata; } public function get_document($record, $options = array()) { global $USER; $info = unserialize($record->info); // Prepare associative array with data from DB. $doc = \core_search\document_factory::instance($record->id, $this->componentname, $this->areaname); $doc->set('title', $info->title); $doc->set('content', $info->content); $doc->set('description1', $info->description1); $doc->set('description2', $info->description2); $doc->set('contextid', $info->contextid); $doc->set('courseid', $info->courseid); $doc->set('userid', $info->userid); $doc->set('owneruserid', $info->owneruserid); $doc->set('modified', $record->timemodified); return $doc; } public function attach_files($document) { global $DB; if (!$record = $DB->get_record('temp_mock_search_area', array('id' => $document->get('itemid')))) { return; } $info = unserialize($record->info); foreach ($info->attachfileids as $fileid) { $document->add_stored_file($fileid); } } public function uses_file_indexing() { return true; } public function check_access($id) { global $DB, $USER; if ($record = $DB->get_record('temp_mock_search_area', array('id' => $id))) { $info = unserialize($record->info); if (in_array($USER->id, $info->denyuserids)) { return \core_search\manager::ACCESS_DENIED; } return \core_search\manager::ACCESS_GRANTED; } return \core_search\manager::ACCESS_DELETED; } public function get_doc_url(\core_search\document $doc) { return new \moodle_url('/index.php'); } public function get_context_url(\core_search\document $doc) { return new \moodle_url('/index.php'); } public function get_visible_name($lazyload = false) { return 'Mock search area'; } /** * Sets a fake delay to simulate time taken doing the indexing query. * * @param float $seconds Delay in seconds for each time indexing query is called */ public function set_indexing_delay($seconds) { $this->indexingdelay = $seconds; } } tests/base_activity_test.php 0000644 00000037710 15152012435 0012312 0 ustar 00 <?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/>. namespace core_search; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/fixtures/testable_core_search.php'); require_once($CFG->dirroot . '/search/tests/fixtures/mock_search_area.php'); /** * Search engine base unit tests. * * @package core_search * @copyright 2017 Matt Porritt <mattp@catalyst-au.net> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class base_activity_test extends \advanced_testcase { /** * @var \core_search::manager */ protected $search = null; /** * @var Instace of core_search_generator. */ protected $generator = null; /** * @var Instace of testable_engine. */ protected $engine = null; /** @var context[] Array of test contexts */ protected $contexts; /** @var stdClass[] Array of test forum objects */ protected $forums; public function setUp(): void { global $DB; $this->resetAfterTest(); set_config('enableglobalsearch', true); // Set \core_search::instance to the mock_search_engine as we don't require the search engine to be working to test this. $search = \testable_core_search::instance(); $this->generator = self::getDataGenerator()->get_plugin_generator('core_search'); $this->generator->setup(); $this->setAdminUser(); // Create course and 2 forums. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $this->contexts['c1'] = \context_course::instance($course->id); $this->forums[1] = $generator->create_module('forum', ['course' => $course->id, 'name' => 'Forum 1', 'intro' => '<p>Intro 1</p>', 'introformat' => FORMAT_HTML]); $this->contexts['f1'] = \context_module::instance($this->forums[1]->cmid); $this->forums[2] = $generator->create_module('forum', ['course' => $course->id, 'name' => 'Forum 2', 'intro' => '<p>Intro 2</p>', 'introformat' => FORMAT_HTML]); $this->contexts['f2'] = \context_module::instance($this->forums[2]->cmid); // Create another 2 courses (in same category and in a new category) with one forum each. $this->contexts['cc1'] = \context_coursecat::instance($course->category); $course2 = $generator->create_course(); $this->contexts['c2'] = \context_course::instance($course2->id); $this->forums[3] = $generator->create_module('forum', ['course' => $course2->id, 'name' => 'Forum 3', 'intro' => '<p>Intro 3</p>', 'introformat' => FORMAT_HTML]); $this->contexts['f3'] = \context_module::instance($this->forums[3]->cmid); $cat2 = $generator->create_category(); $this->contexts['cc2'] = \context_coursecat::instance($cat2->id); $course3 = $generator->create_course(['category' => $cat2->id]); $this->contexts['c3'] = \context_course::instance($course3->id); $this->forums[4] = $generator->create_module('forum', ['course' => $course3->id, 'name' => 'Forum 4', 'intro' => '<p>Intro 4</p>', 'introformat' => FORMAT_HTML]); $this->contexts['f4'] = \context_module::instance($this->forums[4]->cmid); // Hack about with the time modified values. foreach ($this->forums as $index => $forum) { $DB->set_field('forum', 'timemodified', $index, ['id' => $forum->id]); } } public function tearDown(): void { // For unit tests before PHP 7, teardown is called even on skip. So only do our teardown if we did setup. if ($this->generator) { // Moodle DML freaks out if we don't teardown the temp table after each run. $this->generator->teardown(); $this->generator = null; } } /** * Test base activity get search fileareas */ public function test_get_search_fileareas_base() { $builder = $this->getMockBuilder('\core_search\base_activity'); $builder->disableOriginalConstructor(); $stub = $builder->getMockForAbstractClass(); $result = $stub->get_search_fileareas(); $this->assertEquals(array('intro'), $result); } /** * Test base attach files */ public function test_attach_files_base() { $filearea = 'intro'; $component = 'mod_forum'; $module = 'forum'; $course = self::getDataGenerator()->create_course(); $activity = self::getDataGenerator()->create_module('forum', array('course' => $course->id)); $context = \context_module::instance($activity->cmid); $contextid = $context->id; // Create file to add. $fs = get_file_storage(); $filerecord = array( 'contextid' => $contextid, 'component' => $component, 'filearea' => $filearea, 'itemid' => 0, 'filepath' => '/', 'filename' => 'testfile.txt'); $content = 'All the news that\'s fit to print'; $file = $fs->create_file_from_string($filerecord, $content); // Construct the search document. $rec = new \stdClass(); $rec->courseid = $course->id; $area = new \core_mocksearch\search\mock_search_area(); $record = $this->generator->create_record($rec); $document = $area->get_document($record); $document->set('itemid', $activity->id); // Create a mock from the abstract class, // with required methods stubbed. $builder = $this->getMockBuilder('\core_search\base_activity'); $builder->disableOriginalConstructor(); $builder->onlyMethods(array('get_module_name', 'get_component_name')); $stub = $builder->getMockForAbstractClass(); $stub->method('get_module_name')->willReturn($module); $stub->method('get_component_name')->willReturn($component); // Attach file to our test document. $stub->attach_files($document); // Verify file is attached. $files = $document->get_files(); $file = array_values($files)[0]; $this->assertEquals(1, count($files)); $this->assertEquals($content, $file->get_content()); } /** * Tests getting the recordset. */ public function test_get_document_recordset() { global $USER, $DB; // Get all the forums to index (no restriction). $area = new \mod_forum\search\activity(); $results = self::recordset_to_indexed_array($area->get_document_recordset()); // Should return all forums. $this->assertCount(4, $results); // Each result should basically have the contents of the forum table. We'll just check // the key fields for the first one and then the other ones by id only. $this->assertEquals($this->forums[1]->id, $results[0]->id); $this->assertEquals(1, $results[0]->timemodified); $this->assertEquals($this->forums[1]->course, $results[0]->course); $this->assertEquals('Forum 1', $results[0]->name); $this->assertEquals('<p>Intro 1</p>', $results[0]->intro); $this->assertEquals(FORMAT_HTML, $results[0]->introformat); $allids = self::records_to_ids($this->forums); $this->assertEquals($allids, self::records_to_ids($results)); // Repeat with a time restriction. $results = self::recordset_to_indexed_array($area->get_document_recordset(3)); $this->assertEquals([$this->forums[3]->id, $this->forums[4]->id], self::records_to_ids($results)); // Now use context restrictions. First, the whole site (no change). $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, \context_system::instance())); $this->assertEquals($allids, self::records_to_ids($results)); // Course 1 only. $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $this->contexts['c1'])); $this->assertEquals([$this->forums[1]->id, $this->forums[2]->id], self::records_to_ids($results)); // Course 2 only. $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $this->contexts['c2'])); $this->assertEquals([$this->forums[3]->id], self::records_to_ids($results)); // Specific forum only. $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $this->contexts['f4'])); $this->assertEquals([$this->forums[4]->id], self::records_to_ids($results)); // Category 1 context (courses 1 and 2). $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $this->contexts['cc1'])); $this->assertEquals([$this->forums[1]->id, $this->forums[2]->id, $this->forums[3]->id], self::records_to_ids($results)); // Category 2 context (course 3). $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $this->contexts['cc2'])); $this->assertEquals([$this->forums[4]->id], self::records_to_ids($results)); // Combine context restriction (category 1) with timemodified. $results = self::recordset_to_indexed_array($area->get_document_recordset( 2, $this->contexts['cc1'])); $this->assertEquals([$this->forums[2]->id, $this->forums[3]->id], self::records_to_ids($results)); // Find an arbitrary block on the system to get a block context. $blockid = array_values($DB->get_records('block_instances', null, 'id', 'id', 0, 1))[0]->id; $blockcontext = \context_block::instance($blockid); // Block context (cannot return anything, so always null). $this->assertNull($area->get_document_recordset(0, $blockcontext)); // User context (cannot return anything, so always null). $usercontext = \context_user::instance($USER->id); $this->assertNull($area->get_document_recordset(0, $usercontext)); } /** * Utility function to convert recordset to array for testing. * * @param moodle_recordset $rs Recordset to convert * @return array Array indexed by number (0, 1, 2, ...) */ protected static function recordset_to_indexed_array(\moodle_recordset $rs) { $results = []; foreach ($rs as $rec) { $results[] = $rec; } $rs->close(); return $results; } /** * Utility function to convert records to array of IDs. * * @param array $recs Records which should have an 'id' field * @return array Array of ids */ protected static function records_to_ids(array $recs) { $ids = []; foreach ($recs as $rec) { $ids[] = $rec->id; } return $ids; } /** * Tests the get_doc_url function. */ public function test_get_doc_url() { $area = new \mod_forum\search\activity(); $results = self::recordset_to_indexed_array($area->get_document_recordset()); for ($i = 0; $i < 4; $i++) { $this->assertEquals(new \moodle_url('/mod/forum/view.php', ['id' => $this->forums[$i + 1]->cmid]), $area->get_doc_url($area->get_document($results[$i]))); } } /** * Tests the check_access function. */ public function test_check_access() { global $CFG; require_once($CFG->dirroot . '/course/lib.php'); // Create a test user who can access courses 1 and 2 (everything except forum 4). $generator = $this->getDataGenerator(); $user = $generator->create_user(); $generator->enrol_user($user->id, $this->forums[1]->course, 'student'); $generator->enrol_user($user->id, $this->forums[3]->course, 'student'); $this->setUser($user); // Delete forum 2 and set forum 3 hidden. course_delete_module($this->forums[2]->cmid); set_coursemodule_visible($this->forums[3]->cmid, 0); // Call check access on all the first three. $area = new \mod_forum\search\activity(); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $area->check_access( $this->forums[1]->id)); $this->assertEquals(\core_search\manager::ACCESS_DELETED, $area->check_access( $this->forums[2]->id)); $this->assertEquals(\core_search\manager::ACCESS_DENIED, $area->check_access( $this->forums[3]->id)); // Note: Do not check forum 4 which is in a course the user can't access; this will return // ACCESS_GRANTED, but it does not matter because the search engine will not have included // that context in the list to search. (This is because the $cm->uservisible access flag // is only valid if the user is known to be able to access the course.) } /** * Tests the module version of get_contexts_to_reindex, which is supposed to return all the * activity contexts in order of date added. */ public function test_get_contexts_to_reindex() { global $DB; $this->resetAfterTest(); // Set up a course with two URLs and a Page. $generator = $this->getDataGenerator(); $course = $generator->create_course(['fullname' => 'TCourse']); $url1 = $generator->create_module('url', ['course' => $course->id, 'name' => 'TURL1']); $url2 = $generator->create_module('url', ['course' => $course->id, 'name' => 'TURL2']); $page = $generator->create_module('page', ['course' => $course->id, 'name' => 'TPage1']); // Hack the items so they have different added times. $now = time(); $DB->set_field('course_modules', 'added', $now - 3, ['id' => $url2->cmid]); $DB->set_field('course_modules', 'added', $now - 2, ['id' => $url1->cmid]); $DB->set_field('course_modules', 'added', $now - 1, ['id' => $page->cmid]); // Check the URL contexts are in date order. $urlarea = new \mod_url\search\activity(); $contexts = iterator_to_array($urlarea->get_contexts_to_reindex(), false); $this->assertEquals([\context_module::instance($url1->cmid), \context_module::instance($url2->cmid)], $contexts); // Check the Page contexts. $pagearea = new \mod_page\search\activity(); $contexts = iterator_to_array($pagearea->get_contexts_to_reindex(), false); $this->assertEquals([\context_module::instance($page->cmid)], $contexts); // Check another module area that has no instances. $glossaryarea = new \mod_glossary\search\activity(); $contexts = iterator_to_array($glossaryarea->get_contexts_to_reindex(), false); $this->assertEquals([], $contexts); } /** * Test document icon. */ public function test_get_doc_icon() { $baseactivity = $this->getMockBuilder('\core_search\base_activity') ->disableOriginalConstructor() ->onlyMethods(array('get_module_name')) ->getMockForAbstractClass(); $baseactivity->method('get_module_name')->willReturn('test_activity'); $document = $this->getMockBuilder('\core_search\document') ->disableOriginalConstructor() ->getMock(); $result = $baseactivity->get_doc_icon($document); $this->assertEquals('monologo', $result->get_name()); $this->assertEquals('test_activity', $result->get_component()); } } tests/document_icon_test.php 0000644 00000003170 15152012435 0012303 0 ustar 00 <?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/>. namespace core_search; /** * Document icon unit tests. * * @package core_search * @copyright 2018 Dmitrii Metelkin <dmitriim@catalyst-au.net> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class document_icon_test extends \advanced_testcase { /** * Test that default component gets returned correctly. */ public function test_default_component() { $docicon = new \core_search\document_icon('test_name'); $this->assertEquals('test_name', $docicon->get_name()); $this->assertEquals('moodle', $docicon->get_component()); } /** * Test that name and component get returned correctly. */ public function test_can_get_name_and_component() { $docicon = new \core_search\document_icon('test_name', 'test_component'); $this->assertEquals('test_name', $docicon->get_name()); $this->assertEquals('test_component', $docicon->get_component()); } } tests/base_test.php 0000644 00000013521 15152012435 0010370 0 ustar 00 <?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/>. namespace core_search; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/fixtures/testable_core_search.php'); require_once($CFG->dirroot . '/search/tests/fixtures/mock_search_area.php'); /** * Search engine base unit tests. * * @package core_search * @copyright 2017 Matt Porritt <mattp@catalyst-au.net> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class base_test extends \advanced_testcase { /** * @var \core_search::manager */ protected $search = null; /** * @var Instace of core_search_generator. */ protected $generator = null; /** * @var Instace of testable_engine. */ protected $engine = null; public function setUp(): void { $this->resetAfterTest(); set_config('enableglobalsearch', true); // Set \core_search::instance to the mock_search_engine as we don't require the search engine to be working to test this. $search = \testable_core_search::instance(); $this->generator = self::getDataGenerator()->get_plugin_generator('core_search'); $this->generator->setup(); } public function tearDown(): void { // For unit tests before PHP 7, teardown is called even on skip. So only do our teardown if we did setup. if ($this->generator) { // Moodle DML freaks out if we don't teardown the temp table after each run. $this->generator->teardown(); $this->generator = null; } } /** * Test base get search fileareas */ public function test_get_search_fileareas_base() { $builder = $this->getMockBuilder('\core_search\base'); $builder->disableOriginalConstructor(); $stub = $builder->getMockForAbstractClass(); $result = $stub->get_search_fileareas(); $this->assertEquals(array(), $result); } /** * Test base attach files */ public function test_attach_files_base() { $filearea = 'search'; $component = 'mod_test'; // Create file to add. $fs = get_file_storage(); $filerecord = array( 'contextid' => 1, 'component' => $component, 'filearea' => $filearea, 'itemid' => 1, 'filepath' => '/', 'filename' => 'testfile.txt'); $content = 'All the news that\'s fit to print'; $file = $fs->create_file_from_string($filerecord, $content); // Construct the search document. $rec = new \stdClass(); $rec->contextid = 1; $area = new \core_mocksearch\search\mock_search_area(); $record = $this->generator->create_record($rec); $document = $area->get_document($record); // Create a mock from the abstract class, // with required methods stubbed. $builder = $this->getMockBuilder('\core_search\base'); $builder->disableOriginalConstructor(); $builder->onlyMethods(array('get_search_fileareas', 'get_component_name')); $stub = $builder->getMockForAbstractClass(); $stub->method('get_search_fileareas')->willReturn(array($filearea)); $stub->method('get_component_name')->willReturn($component); // Attach file to our test document. $stub->attach_files($document); // Verify file is attached. $files = $document->get_files(); $file = array_values($files)[0]; $this->assertEquals(1, count($files)); $this->assertEquals($content, $file->get_content()); } /** * Tests the base version (stub) of get_contexts_to_reindex. */ public function test_get_contexts_to_reindex() { $area = new \core_mocksearch\search\mock_search_area(); $this->assertEquals([\context_system::instance()], iterator_to_array($area->get_contexts_to_reindex(), false)); } /** * Test default document icon. */ public function test_get_default_doc_icon() { $basearea = $this->getMockBuilder('\core_search\base') ->disableOriginalConstructor() ->getMockForAbstractClass(); $document = $this->getMockBuilder('\core_search\document') ->disableOriginalConstructor() ->getMock(); $result = $basearea->get_doc_icon($document); $this->assertEquals('i/empty', $result->get_name()); $this->assertEquals('moodle', $result->get_component()); } /** * Test base search area category names. */ public function test_get_category_names() { $builder = $this->getMockBuilder('\core_search\base'); $builder->disableOriginalConstructor(); $stub = $builder->getMockForAbstractClass(); $expected = ['core-other']; $this->assertEquals($expected, $stub->get_category_names()); } /** * Test getting all required search area setting names. */ public function test_get_settingnames() { $expected = array('_enabled', '_indexingstart', '_indexingend', '_lastindexrun', '_docsignored', '_docsprocessed', '_recordsprocessed', '_partial'); $this->assertEquals($expected, \core_search\base::get_settingnames()); } } tests/external_test.php 0000644 00000005752 15152012435 0011307 0 ustar 00 <?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/>. /** * External function unit tests. * * @package core_search * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; /** * External function unit tests. * * @package core_search * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class external_test extends \advanced_testcase { public function setUp(): void { $this->resetAfterTest(); } /** * Checks the get_relevant_users function used when selecting users in search filter. */ public function test_get_relevant_users() { // Set up two users to search for and one to do the searching. $generator = $this->getDataGenerator(); $student1 = $generator->create_user(['firstname' => 'Amelia', 'lastname' => 'Aardvark']); $student2 = $generator->create_user(['firstname' => 'Amelia', 'lastname' => 'Beetle']); $student3 = $generator->create_user(['firstname' => 'Zebedee', 'lastname' => 'Boing']); $course = $generator->create_course(); $generator->enrol_user($student1->id, $course->id, 'student'); $generator->enrol_user($student2->id, $course->id, 'student'); $generator->enrol_user($student3->id, $course->id, 'student'); // As student 3, search for the other two. $this->setUser($student3); $result = external::clean_returnvalue( external::get_relevant_users_returns(), external::get_relevant_users('Amelia', 0) ); // Check we got the two expected users back. $this->assertEquals([ $student1->id, $student2->id, ], array_column($result, 'id')); // Check that the result contains all the expected fields. $this->assertEquals($student1->id, $result[0]['id']); $this->assertEquals('Amelia Aardvark', $result[0]['fullname']); $this->assertStringContainsString('/u/f2', $result[0]['profileimageurlsmall']); // Check we aren't leaking information about user email address (for instance). $this->assertArrayNotHasKey('email', $result[0]); // Note: We are not checking search permissions, search by different fields, etc. as these // are covered by the core_user::search unit test. } } tests/document_test.php 0000644 00000025125 15152012435 0011277 0 ustar 00 <?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/>. namespace core_search; use advanced_testcase; use context_course; use core_mocksearch\search\mock_search_area; use mock_search\engine; use testable_core_search; use stdClass; /** * Unit tests for search document. * * @package core_search * @category test * @copyright 2016 Eric Merrill {@link http://www.merrilldigital.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @coversDefaultClass \core_search\document */ class document_test extends \advanced_testcase { /** * Setup to ensure that fixtures are loaded. */ public static function setupBeforeClass(): void { global $CFG; require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php'); require_once($CFG->dirroot . '/search/tests/fixtures/mock_search_area.php'); } /** * @var Instace of core_search_generator. */ protected $generator = null; public function setUp(): void { $this->resetAfterTest(); set_config('enableglobalsearch', true); // Set \core_search::instance to the mock_search_engine as we don't require the search engine to be working to test this. $search = \testable_core_search::instance(); $this->generator = self::getDataGenerator()->get_plugin_generator('core_search'); $this->generator->setup(); } /** * Adding this test here as get_areas_user_accesses process is the same, results just depend on the context level. * * @covers ::export_for_template * @return void */ public function test_search_user_accesses() { global $PAGE; $area = new mock_search_area(); $renderer = $PAGE->get_renderer('core_search'); $engine = new engine(); $course = $this->getDataGenerator()->create_course(['fullname' => 'Course & Title']); $user = $this->getDataGenerator()->create_user(['firstname' => 'User', 'lastname' => 'Escape & Name']); $this->getDataGenerator()->enrol_user($user->id, $course->id, 'teacher'); $this->setAdminUser(); // Make a record to enter in the search area. $record = new stdClass(); $record->title = 'Escape & Title'; $record->content = 'Escape & Content'; $record->description1 = 'Escape & Description1'; $record->description2 = 'Escape & Description2'; $record->userid = $user->id; $record->courseid = $course->id; $record = $this->generator->create_record($record); // Convert to a 'doc data' type format. $docdata = $area->convert_record_to_doc_array($record); // First see that the docuemnt has the right information, unescaped. $doc = $engine->to_document($area, $docdata); $this->assertEquals('Escape & Title', $doc->get('title')); $this->assertEquals('Escape & Content', $doc->get('content')); $this->assertEquals('Escape & Description1', $doc->get('description1')); $this->assertEquals('Escape & Description2', $doc->get('description2')); $this->assertEquals('User Escape & Name', $doc->get('userfullname')); $this->assertEquals('Course & Title', $doc->get('coursefullname')); // Export for template, and see if it is escaped. $export = $doc->export_for_template($renderer); $this->assertEquals('Escape & Title', $export['title']); $this->assertEquals('Escape & Content', $export['content']); $this->assertEquals('Escape & Description1', $export['description1']); $this->assertEquals('Escape & Description2', $export['description2']); $this->assertEquals('User Escape & Name', $export['userfullname']); $this->assertEquals('Course & Title', $export['coursefullname']); } /** * Test we can set and get document icon. * * @covers ::set_doc_icon */ public function test_get_and_set_doc_icon() { $document = $this->getMockBuilder('\core_search\document') ->disableOriginalConstructor() ->getMockForAbstractClass(); $this->assertNull($document->get_doc_icon()); $docicon = new \core_search\document_icon('test_name', 'test_component'); $document->set_doc_icon($docicon); $this->assertEquals($docicon, $document->get_doc_icon()); } public function tearDown(): void { // For unit tests before PHP 7, teardown is called even on skip. So only do our teardown if we did setup. if ($this->generator) { // Moodle DML freaks out if we don't teardown the temp table after each run. $this->generator->teardown(); $this->generator = null; } } /** * Test the document author visibility depending on the user capabilities. * * @covers ::export_for_template * @dataProvider document_author_visibility_provider * @param string $rolename the role name * @param array $capexceptions the capabilities exceptions * @param bool $expected the expected author visibility * @param bool $owndocument if the resulting document belongs to the current user */ public function test_document_author_visibility( string $rolename = 'editingteacher', array $capexceptions = [], bool $expected = true, bool $owndocument = false ) { global $DB, $PAGE; $area = new mock_search_area(); $renderer = $PAGE->get_renderer('core_search'); $engine = new engine(); $course = $this->getDataGenerator()->create_course(['fullname' => 'Course & Title']); $context = context_course::instance($course->id); $roleid = $DB->get_field('role', 'id', ['shortname' => $rolename]); foreach ($capexceptions as $capability) { assign_capability($capability, CAP_PROHIBIT, $roleid, $context->id); } $user = $this->getDataGenerator()->create_user(['firstname' => 'Test', 'lastname' => 'User']); $this->getDataGenerator()->enrol_user($user->id, $course->id, $rolename); $this->setUser($user); if ($owndocument) { $author = $user; } else { $author = $this->getDataGenerator()->create_user(['firstname' => 'User', 'lastname' => 'Escape & Name']); $this->getDataGenerator()->enrol_user($author->id, $course->id, 'student'); } // Make a record to enter in the search area. $record = new stdClass(); $record->title = 'Escape & Title'; $record->content = 'Escape & Content'; $record->description1 = 'Escape & Description1'; $record->description2 = 'Escape & Description2'; $record->userid = $author->id; $record->courseid = $course->id; $record->contextid = $context->id; $record = $this->generator->create_record($record); // Convert to a 'doc data' type format. $docdata = $area->convert_record_to_doc_array($record); // First see that the document has the user information. $doc = $engine->to_document($area, $docdata); $this->assertEquals(fullname($author), $doc->get('userfullname')); // Export for template, and see if it the user information is exported. $export = $doc->export_for_template($renderer); if ($expected) { $authorname = htmlentities(fullname($author), ENT_COMPAT); $this->assertEquals($authorname, $export['userfullname']); } else { $this->assertArrayNotHasKey('userfullname', $export); } } /** * Data provider for test_document_author_visibility(). * * @return array */ public function document_author_visibility_provider(): array { return [ 'Teacher' => [ 'rolename' => 'editingteacher', 'capexceptions' => [], 'expected' => true, 'owndocument' => false, ], 'Non editing teacher' => [ 'rolename' => 'teacher', 'capexceptions' => [], 'expected' => true, 'owndocument' => false, ], 'Student' => [ 'rolename' => 'student', 'capexceptions' => [], 'expected' => true, 'owndocument' => false, ], // Adding capability exceptions. 'Student without view profiles' => [ 'rolename' => 'student', 'capexceptions' => ['moodle/user:viewdetails'], 'expected' => false, 'owndocument' => false, ], 'Student without view participants' => [ 'rolename' => 'student', 'capexceptions' => ['moodle/course:viewparticipants'], 'expected' => false, 'owndocument' => false, ], 'Student without view participants or profiles' => [ 'rolename' => 'student', 'capexceptions' => ['moodle/user:viewdetails', 'moodle/course:viewparticipants'], 'expected' => false, 'owndocument' => false, ], // Users should be able to see its own documents. 'Student author without view profiles' => [ 'rolename' => 'student', 'capexceptions' => ['moodle/user:viewdetails'], 'expected' => true, 'owndocument' => true, ], 'Student author without view participants' => [ 'rolename' => 'student', 'capexceptions' => ['moodle/course:viewparticipants'], 'expected' => true, 'owndocument' => true, ], 'Student author without view participants or profiles' => [ 'rolename' => 'student', 'capexceptions' => ['moodle/user:viewdetails', 'moodle/course:viewparticipants'], 'expected' => true, 'owndocument' => true, ], ]; } } tests/event/events_test.php 0000644 00000005222 15152012435 0012102 0 ustar 00 <?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 search events. * * @package core_search * @category phpunit * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search\event; /** * Unit tests for search events. * * @package core_search * @category phpunit * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class events_test extends \advanced_testcase { /** * test_search_results_viewed * * @return void */ public function test_search_results_viewed() { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $sink = $this->redirectEvents(); // Basic event. \core_search\manager::trigger_search_results_viewed([ 'q' => 'I am a query', 'page' => 0, ]); $events = $sink->get_events(); $event = reset($events); $sink->clear(); $this->assertEquals(\context_system::instance(), $event->get_context()); $urlparams = ['q' => 'I am a query', 'page' => 0]; $this->assertEquals($urlparams, $event->get_url()->params()); \core_search\manager::trigger_search_results_viewed([ 'q' => 'I am a query', 'page' => 2, 'title' => 'I am the title', 'areaids' => array(3,4,5), 'courseids' => array(2,3), 'timestart' => 1445644800, 'timeend' => 1477267200 ]); $events = $sink->get_events(); $event = reset($events); $this->assertEquals(\context_system::instance(), $event->get_context()); $urlparams = ['q' => 'I am a query', 'page' => 2, 'title' => 'I am the title', 'timestart' => 1445644800, 'timeend' => 1477267200]; $this->assertEquals($urlparams, $event->get_url()->params()); } } tests/generator/lib.php 0000644 00000014517 15152012435 0011161 0 ustar 00 <?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/>. /** * Generator for test search area. * * @package core_search * @category phpunit * @copyright 2016 Eric Merrill {@link http://www.merrilldigital.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Mock search area data generator class. * * @package core_search * @category test * @copyright 2016 Eric Merrill * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class core_search_generator extends component_generator_base { /** * Creates the mock search area temp table. */ public function setUp(): void { global $DB; $dbman = $DB->get_manager(); // Make our temp table if we need it. if (!$dbman->table_exists('temp_mock_search_area')) { $table = new \xmldb_table('temp_mock_search_area'); $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); $table->add_field('timemodified', XMLDB_TYPE_INTEGER, 10, null, XMLDB_NOTNULL, null, '0'); $table->add_field('info', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null); $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id')); $dbman->create_temp_table($table); } } /** * Destroys the mock search area temp table. */ public function tearDown(): void { global $DB; $dbman = $DB->get_manager(); // Make our temp table if we need it. if ($dbman->table_exists('temp_mock_search_area')) { $table = new \xmldb_table('temp_mock_search_area'); $dbman->drop_table($table); } } /** * Deletes all records in the search area table. */ public function delete_all() { global $DB; // Delete any records in the search area. $DB->delete_records('temp_mock_search_area'); } /** * Adds a new record to the mock search area based on the provided options. */ public function create_record($options = null) { global $DB, $USER; $record = new \stdClass(); $info = new \stdClass(); if (empty($options->timemodified)) { $record->timemodified = time(); } else { $record->timemodified = $options->timemodified; } if (!isset($options->content)) { $info->content = 'A test message to find.'; } else { $info->content = $options->content; } if (!isset($options->description1)) { $info->description1 = 'Description 1.'; } else { $info->description1 = $options->description1; } if (!isset($options->description2)) { $info->description2 = 'Description 2.'; } else { $info->description2 = $options->description2; } if (!isset($options->title)) { $info->title = 'A basic title'; } else { $info->title = $options->title; } if (!isset($options->contextid)) { $info->contextid = \context_course::instance(SITEID)->id; } else { $info->contextid = $options->contextid; } if (!isset($options->courseid)) { $info->courseid = SITEID; } else { $info->courseid = $options->courseid; } if (!isset($options->userid)) { $info->userid = $USER->id; } else { $info->userid = $options->userid; } if (!isset($options->owneruserid)) { $info->owneruserid = \core_search\manager::NO_OWNER_ID; } else { $info->owneruserid = $options->owneruserid; } // This takes a userid (or array of) that will be denied when check_access() is called. if (!isset($options->denyuserids)) { $info->denyuserids = array(); } else { if (is_array($options->denyuserids)) { $info->denyuserids = $options->denyuserids; } else { $info->denyuserids = array($options->denyuserids); } } // Stored file ids that will be attached when attach_files() is called. if (!isset($options->attachfileids)) { $info->attachfileids = array(); } else { if (is_array($options->attachfileids)) { $info->attachfileids = $options->attachfileids; } else { $info->attachfileids = array($options->attachfileids); } } $record->info = serialize($info); $record->id = $DB->insert_record('temp_mock_search_area', $record); return $record; } /** * Creates a stored file that can be added to mock search area records for indexing. */ public function create_file($options = null) { // Add the searchable file fixture. $syscontext = \context_system::instance(); $filerecord = array( 'contextid' => $syscontext->id, 'component' => 'core', 'filearea' => 'unittest', 'itemid' => 0, 'filepath' => '/', 'filename' => 'searchfile.txt', ); if (isset($options->filename)) { $filerecord['filename'] = $options->filename; } if (isset($options->content)) { $content = $options->content; } else { $content = 'File contents'; } if (isset($options->timemodified)) { $filerecord['timemodified'] = $options->timemodified; } $fs = get_file_storage(); $file = $fs->create_file_from_string($filerecord, $content); return $file; } } tests/top_result_test.php 0000644 00000016244 15152012435 0011663 0 ustar 00 <?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/>. namespace core_search; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/fixtures/testable_core_search.php'); require_once(__DIR__ . '/fixtures/mock_search_area.php'); /** * Test for top results * * @package core_search * @author Nathan Nguyen <nathannguyen@catalyst-au.net> * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class top_result_test extends \advanced_testcase { /** @var stdClass course 1 */ protected $course1; /** @var stdClass course 2 */ protected $course2; /** @var stdClass user 1 */ protected $user1; /** @var stdClass user 2 */ protected $user2; /** @var stdClass user 3 */ protected $user3; /** @var stdClass search engine */ protected $search; /** * Prepare test and users. */ private function prepare_test_courses_and_users(): void { global $DB; $this->setAdminUser(); // Search engine. $this->search = \testable_core_search::instance(new \search_simpledb\engine()); // Set default configurations. set_config('searchallavailablecourses', 1); set_config('searchincludeallcourses', 1); set_config('searchenablecategories', true); set_config('enableglobalsearch', true); set_config('searchmaxtopresults', 3); $teacher = $DB->get_record('role', ['shortname' => 'teacher']); $editingteacher = $DB->get_record('role', ['shortname' => 'editingteacher']); set_config('searchteacherroles', "$teacher->id, $editingteacher->id"); // Generate test data. $generator = $this->getDataGenerator(); // Courses. $this->course1 = $generator->create_course(['fullname' => 'Top course result 1']); // Ensure course 1 is indexed before course 2. $this->run_index(); $this->course2 = $generator->create_course(['fullname' => 'Top course result 2']); // User 1. $urecord1 = new \stdClass(); $urecord1->firstname = "User 1"; $urecord1->lastname = "Test"; $this->user1 = $generator->create_user($urecord1); // User 2. $urecord2 = new \stdClass(); $urecord2->firstname = "User 2"; $urecord2->lastname = "Test"; $this->user2 = $generator->create_user($urecord2); // User 3. $urecord3 = new \stdClass(); $urecord3->firstname = "User 3"; $urecord3->lastname = "Test"; $this->user3 = $generator->create_user($urecord3); } /** * Test course ranking */ public function test_search_course_rank(): void { $this->resetAfterTest(); $this->prepare_test_courses_and_users(); $this->setUser($this->user1); // Search query. $data = new \stdClass(); $data->q = 'Top course result'; $data->cat = 'core-all'; // Course 1 at the first index. $this->run_index(); $docs = $this->search->search_top($data); $this->assertEquals('Top course result 1', $docs[0]->get('title')); $this->assertEquals('Top course result 2', $docs[1]->get('title')); // Enrol user to course 2. $this->getDataGenerator()->enrol_user($this->user1->id, $this->course2->id, 'student'); // Course 2 at the first index. $this->run_index(); $docs = $this->search->search_top($data); $this->assertEquals('Top course result 2', $docs[0]->get('title')); $this->assertEquals('Top course result 1', $docs[1]->get('title')); } /** * Test without teacher indexing */ public function test_search_with_no_course_teacher_indexing(): void { $this->resetAfterTest(); $this->prepare_test_courses_and_users(); set_config('searchteacherroles', ""); $this->getDataGenerator()->enrol_user($this->user1->id, $this->course1->id, 'teacher'); // Search query. $data = new \stdClass(); $data->q = 'Top course result'; $data->cat = 'core-all'; // Only return the course. $this->run_index(); $docs = $this->search->search_top($data); $this->assertCount(2, $docs); $this->assertEquals('Top course result 1', $docs[0]->get('title')); $this->assertEquals('Top course result 2', $docs[1]->get('title')); } /** * Test with teacher indexing */ public function test_search_with_course_teacher_indexing(): void { $this->resetAfterTest(); $this->prepare_test_courses_and_users(); $this->getDataGenerator()->enrol_user($this->user1->id, $this->course1->id, 'teacher'); $this->getDataGenerator()->enrol_user($this->user2->id, $this->course1->id, 'student'); // Search query. $data = new \stdClass(); $data->q = 'Top course result 1'; $data->cat = 'core-all'; // Return the course and the teachers. $this->run_index(); $docs = $this->search->search_top($data); $this->assertEquals('Top course result 1', $docs[0]->get('title')); $this->assertEquals('User 1 Test', $docs[1]->get('title')); } /** * Test with teacher indexing */ public function test_search_with_course_teacher_content_indexing(): void { $this->resetAfterTest(); $this->prepare_test_courses_and_users(); // Create forums as course content. $generator = $this->getDataGenerator(); // Course Teacher. $this->getDataGenerator()->enrol_user($this->user1->id, $this->course1->id, 'teacher'); // Forums. $generator->create_module('forum', ['course' => $this->course1->id, 'name' => 'Forum 1, does not contain the keyword']); $generator->create_module('forum', ['course' => $this->course2->id, 'name' => 'Forum 2, contains keyword Top course result 1']); $this->run_index(); // Search query. $data = new \stdClass(); $data->q = 'Top course result 1'; $data->cat = 'core-all'; // Return the course and the teacher and the forum. $docs = $this->search->search_top($data); $this->assertEquals('Top course result 1', $docs[0]->get('title')); $this->assertEquals('User 1 Test', $docs[1]->get('title')); $this->assertEquals('Forum 2, contains keyword Top course result 1', $docs[2]->get('title')); } /** * Execute indexing */ private function run_index(): void { // Indexing. $this->waitForSecond(); $this->search->index(false, 0); } } tests/manager_test.php 0000644 00000225242 15152012435 0011075 0 ustar 00 <?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/>. namespace core_search; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/fixtures/testable_core_search.php'); require_once(__DIR__ . '/fixtures/mock_search_area.php'); /** * Unit tests for search manager. * * @package core_search * @category phpunit * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class manager_test extends \advanced_testcase { /** * Forum area id. * * @var string */ protected $forumpostareaid = null; /** * Courses area id. * * @var string */ protected $coursesareaid = null; public function setUp(): void { $this->forumpostareaid = \core_search\manager::generate_areaid('mod_forum', 'post'); $this->coursesareaid = \core_search\manager::generate_areaid('core_course', 'course'); } protected function tearDown(): void { // Stop it from faking time in the search manager (if set by test). \testable_core_search::fake_current_time(); parent::tearDown(); } public function test_search_enabled() { $this->resetAfterTest(); // Disabled by default. $this->assertFalse(\core_search\manager::is_global_search_enabled()); set_config('enableglobalsearch', true); $this->assertTrue(\core_search\manager::is_global_search_enabled()); set_config('enableglobalsearch', false); $this->assertFalse(\core_search\manager::is_global_search_enabled()); } /** * Tests the course search url is correct. * * @param bool|null $gsenabled Enable global search (null to leave as the default). * @param bool|null $allcourses Enable searching all courses (null to leave as the default). * @param bool|null $enablearea Enable the course search area (null to leave as the default). * @param string $expected The expected course search url. * @dataProvider data_course_search_url */ public function test_course_search_url(?bool $gsenabled, ?bool $allcourses, ?bool $enablearea, string $expected) { $this->resetAfterTest(); if (!is_null($gsenabled)) { set_config('enableglobalsearch', $gsenabled); } if (!is_null($allcourses)) { set_config('searchincludeallcourses', $allcourses); } if (!is_null($enablearea)) { // Setup the course search area. $areaid = \core_search\manager::generate_areaid('core_course', 'course'); $area = \core_search\manager::get_search_area($areaid); $area->set_enabled($enablearea); } $this->assertEquals(new \moodle_url($expected), \core_search\manager::get_course_search_url()); } /** * Data for the test_course_search_url test. * * @return array[] */ public function data_course_search_url(): array { return [ 'defaults' => [null, null, null, '/course/search.php'], 'enabled' => [true, true, true, '/search/index.php'], 'no all courses, no search area' => [true, false, false, '/course/search.php'], 'no search area' => [true, true, false, '/course/search.php'], 'no all courses' => [true, false, true, '/course/search.php'], 'disabled' => [false, false, false, '/course/search.php'], 'no global search' => [false, true, false, '/course/search.php'], 'no global search, no all courses' => [false, false, true, '/course/search.php'], 'no global search, no search area' => [false, true, false, '/course/search.php'], ]; } /** * Tests that we detect that global search can replace frontpage course search. * * @param bool|null $gsenabled Enable global search (null to leave as the default). * @param bool|null $allcourses Enable searching all courses (null to leave as the default). * @param bool|null $enablearea Enable the course search area (null to leave as the default). * @param bool $expected The expected result. * @dataProvider data_can_replace_course_search */ public function test_can_replace_course_search(?bool $gsenabled, ?bool $allcourses, ?bool $enablearea, bool $expected) { $this->resetAfterTest(); if (!is_null($gsenabled)) { set_config('enableglobalsearch', $gsenabled); } if (!is_null($allcourses)) { set_config('searchincludeallcourses', $allcourses); } if (!is_null($enablearea)) { // Setup the course search area. $areaid = \core_search\manager::generate_areaid('core_course', 'course'); $area = \core_search\manager::get_search_area($areaid); $area->set_enabled($enablearea); } $this->assertEquals($expected, \core_search\manager::can_replace_course_search()); } /** * Data for the test_can_replace_course_search test. * * @return array[] */ public function data_can_replace_course_search(): array { return [ 'defaults' => [null, null, null, false], 'enabled' => [true, true, true, true], 'no all courses, no search area' => [true, false, false, false], 'no search area' => [true, true, false, false], 'no all courses' => [true, false, true, false], 'disabled' => [false, false, false, false], 'no global search' => [false, true, false, false], 'no global search, no all courses' => [false, false, true, false], 'no global search, no search area' => [false, true, false, false], ]; } public function test_search_areas() { global $CFG; $this->resetAfterTest(); set_config('enableglobalsearch', true); $fakeareaid = \core_search\manager::generate_areaid('mod_unexisting', 'chihuaquita'); $searcharea = \core_search\manager::get_search_area($this->forumpostareaid); $this->assertInstanceOf('\core_search\base', $searcharea); $this->assertFalse(\core_search\manager::get_search_area($fakeareaid)); $this->assertArrayHasKey($this->forumpostareaid, \core_search\manager::get_search_areas_list()); $this->assertArrayNotHasKey($fakeareaid, \core_search\manager::get_search_areas_list()); // Enabled by default once global search is enabled. $this->assertArrayHasKey($this->forumpostareaid, \core_search\manager::get_search_areas_list(true)); list($componentname, $varname) = $searcharea->get_config_var_name(); set_config($varname . '_enabled', 0, $componentname); \core_search\manager::clear_static(); $this->assertArrayNotHasKey('mod_forum', \core_search\manager::get_search_areas_list(true)); set_config($varname . '_enabled', 1, $componentname); // Although the result is wrong, we want to check that \core_search\manager::get_search_areas_list returns cached results. $this->assertArrayNotHasKey($this->forumpostareaid, \core_search\manager::get_search_areas_list(true)); // Now we check the real result. \core_search\manager::clear_static(); $this->assertArrayHasKey($this->forumpostareaid, \core_search\manager::get_search_areas_list(true)); } public function test_search_config() { $this->resetAfterTest(); $search = \testable_core_search::instance(); // We should test both plugin types and core subsystems. No core subsystems available yet. $searcharea = $search->get_search_area($this->forumpostareaid); list($componentname, $varname) = $searcharea->get_config_var_name(); // Just with a couple of vars should be enough. $start = time() - 100; $end = time(); set_config($varname . '_indexingstart', $start, $componentname); set_config($varname . '_indexingend', $end, $componentname); $configs = $search->get_areas_config(array($this->forumpostareaid => $searcharea)); $this->assertEquals($start, $configs[$this->forumpostareaid]->indexingstart); $this->assertEquals($end, $configs[$this->forumpostareaid]->indexingend); $this->assertEquals(false, $configs[$this->forumpostareaid]->partial); try { $fakeareaid = \core_search\manager::generate_areaid('mod_unexisting', 'chihuaquita'); $search->reset_config($fakeareaid); $this->fail('An exception should be triggered if the provided search area does not exist.'); } catch (\moodle_exception $ex) { $this->assertStringContainsString($fakeareaid . ' search area is not available.', $ex->getMessage()); } // We clean it all but enabled components. $search->reset_config($this->forumpostareaid); $config = $searcharea->get_config(); $this->assertEquals(1, $config[$varname . '_enabled']); $this->assertEquals(0, $config[$varname . '_indexingstart']); $this->assertEquals(0, $config[$varname . '_indexingend']); $this->assertEquals(0, $config[$varname . '_lastindexrun']); $this->assertEquals(0, $config[$varname . '_partial']); // No caching. $configs = $search->get_areas_config(array($this->forumpostareaid => $searcharea)); $this->assertEquals(0, $configs[$this->forumpostareaid]->indexingstart); $this->assertEquals(0, $configs[$this->forumpostareaid]->indexingend); set_config($varname . '_indexingstart', $start, $componentname); set_config($varname . '_indexingend', $end, $componentname); // All components config should be reset. $search->reset_config(); $this->assertEquals(0, get_config($componentname, $varname . '_indexingstart')); $this->assertEquals(0, get_config($componentname, $varname . '_indexingend')); $this->assertEquals(0, get_config($componentname, $varname . '_lastindexrun')); // No caching. $configs = $search->get_areas_config(array($this->forumpostareaid => $searcharea)); $this->assertEquals(0, $configs[$this->forumpostareaid]->indexingstart); $this->assertEquals(0, $configs[$this->forumpostareaid]->indexingend); } /** * Tests the get_last_indexing_duration method in the base area class. */ public function test_get_last_indexing_duration() { $this->resetAfterTest(); $search = \testable_core_search::instance(); $searcharea = $search->get_search_area($this->forumpostareaid); // When never indexed, the duration is false. $this->assertSame(false, $searcharea->get_last_indexing_duration()); // Set the start/end times. list($componentname, $varname) = $searcharea->get_config_var_name(); $start = time() - 100; $end = time(); set_config($varname . '_indexingstart', $start, $componentname); set_config($varname . '_indexingend', $end, $componentname); // The duration should now be 100. $this->assertSame(100, $searcharea->get_last_indexing_duration()); } /** * Tests that partial indexing works correctly. */ public function test_partial_indexing() { global $USER; $this->resetAfterTest(); $this->setAdminUser(); // Create a course and a forum. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $forum = $generator->create_module('forum', ['course' => $course->id]); // Index everything up to current. Ensure the course is older than current second so it // definitely doesn't get indexed again next time. $this->waitForSecond(); $search = \testable_core_search::instance(); $search->index(false, 0); $searcharea = $search->get_search_area($this->forumpostareaid); list($componentname, $varname) = $searcharea->get_config_var_name(); $this->assertFalse(get_config($componentname, $varname . '_partial')); // Add 3 discussions to the forum. $now = time(); $generator->get_plugin_generator('mod_forum')->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $USER->id, 'timemodified' => $now, 'name' => 'Frog']); $generator->get_plugin_generator('mod_forum')->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $USER->id, 'timemodified' => $now + 1, 'name' => 'Toad']); $generator->get_plugin_generator('mod_forum')->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $USER->id, 'timemodified' => $now + 2, 'name' => 'Zombie']); $generator->get_plugin_generator('mod_forum')->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $USER->id, 'timemodified' => $now + 2, 'name' => 'Werewolf']); time_sleep_until($now + 3); // Clear the count of added documents. $search->get_engine()->get_and_clear_added_documents(); // Make the search engine delay while indexing each document. $search->get_engine()->set_add_delay(1.2); // Use fake time, starting from now. \testable_core_search::fake_current_time(time()); // Index with a limit of 2 seconds - it should index 2 of the documents (after the second // one, it will have taken 2.4 seconds so it will stop). $search->index(false, 2); $added = $search->get_engine()->get_and_clear_added_documents(); $this->assertCount(2, $added); $this->assertEquals('Frog', $added[0]->get('title')); $this->assertEquals('Toad', $added[1]->get('title')); $this->assertEquals(1, get_config($componentname, $varname . '_partial')); // Whilst 2.4 seconds of "time" have elapsed, the indexing duration is // measured in seconds, so should be 2. $this->assertEquals(2, $searcharea->get_last_indexing_duration()); // Add a label. $generator->create_module('label', ['course' => $course->id, 'intro' => 'Vampire']); // Wait to next second (so as to not reindex the label more than once, as it will now // be timed before the indexing run). $this->waitForSecond(); \testable_core_search::fake_current_time(time()); // Next index with 1 second limit should do the label and not the forum - the logic is, // if it spent ages indexing an area last time, do that one last on next run. $search->index(false, 1); $added = $search->get_engine()->get_and_clear_added_documents(); $this->assertCount(1, $added); $this->assertEquals('Vampire', $added[0]->get('title')); // Index again with a 3 second limit - it will redo last post for safety (because of other // things possibly having the same time second), and then do the remaining one. (Note: // because it always does more than one second worth of items, it would actually index 2 // posts even if the limit were less than 2, we are testing it does 3 posts to make sure // the time limiting is actually working with the specified time.) $search->index(false, 3); $added = $search->get_engine()->get_and_clear_added_documents(); $this->assertCount(3, $added); $this->assertEquals('Toad', $added[0]->get('title')); $remainingtitles = [$added[1]->get('title'), $added[2]->get('title')]; sort($remainingtitles); $this->assertEquals(['Werewolf', 'Zombie'], $remainingtitles); $this->assertFalse(get_config($componentname, $varname . '_partial')); // Index again - there should be nothing to index this time. $search->index(false, 2); $added = $search->get_engine()->get_and_clear_added_documents(); $this->assertCount(0, $added); $this->assertFalse(get_config($componentname, $varname . '_partial')); } /** * Tests the progress display while indexing. * * This tests the different logic about displaying progress for slow/fast and * complete/incomplete processing. */ public function test_index_progress() { $this->resetAfterTest(); $generator = $this->getDataGenerator(); // Set up the fake search area. $search = \testable_core_search::instance(); $area = new \core_mocksearch\search\mock_search_area(); $search->add_search_area('whatever', $area); $searchgenerator = $generator->get_plugin_generator('core_search'); $searchgenerator->setUp(); // Add records with specific time modified values. $time = strtotime('2017-11-01 01:00'); for ($i = 0; $i < 8; $i ++) { $searchgenerator->create_record((object)['timemodified' => $time]); $time += 60; } // Simulate slow progress on indexing and initial query. $now = strtotime('2017-11-11 01:00'); \testable_core_search::fake_current_time($now); $area->set_indexing_delay(10.123); $search->get_engine()->set_add_delay(15.789); // Run search indexing and check output. $progress = new \progress_trace_buffer(new \text_progress_trace(), false); $search->index(false, 75, $progress); $out = $progress->get_buffer(); $progress->reset_buffer(); // Check for the standard text. $this->assertStringContainsString('Processing area: Mock search area', $out); $this->assertStringContainsString('Stopping indexing due to time limit', $out); // Check for initial query performance indication. $this->assertStringContainsString('Initial query took 10.1 seconds.', $out); // Check for the two (approximately) every-30-seconds messages. $this->assertStringContainsString('01:00:41: Done to 1/11/17, 01:01', $out); $this->assertStringContainsString('01:01:13: Done to 1/11/17, 01:03', $out); // Check for the 'not complete' indicator showing when it was done until. $this->assertStringContainsString('Processed 5 records containing 5 documents, in 89.1 seconds ' . '(not complete; done to 1/11/17, 01:04)', $out); // Make the initial query delay less than 5 seconds, so it won't appear. Make the documents // quicker, so that the 30-second delay won't be needed. $area->set_indexing_delay(4.9); $search->get_engine()->set_add_delay(1); // Run search indexing (still partial) and check output. $progress = new \progress_trace_buffer(new \text_progress_trace(), false); $search->index(false, 5, $progress); $out = $progress->get_buffer(); $progress->reset_buffer(); $this->assertStringContainsString('Processing area: Mock search area', $out); $this->assertStringContainsString('Stopping indexing due to time limit', $out); $this->assertStringNotContainsString('Initial query took', $out); $this->assertStringNotContainsString(': Done to', $out); $this->assertStringContainsString('Processed 2 records containing 2 documents, in 6.9 seconds ' . '(not complete; done to 1/11/17, 01:05).', $out); // Run the remaining items to complete it. $progress = new \progress_trace_buffer(new \text_progress_trace(), false); $search->index(false, 100, $progress); $out = $progress->get_buffer(); $progress->reset_buffer(); $this->assertStringContainsString('Processing area: Mock search area', $out); $this->assertStringNotContainsString('Stopping indexing due to time limit', $out); $this->assertStringNotContainsString('Initial query took', $out); $this->assertStringNotContainsString(': Done to', $out); $this->assertStringContainsString('Processed 3 records containing 3 documents, in 7.9 seconds.', $out); $searchgenerator->tearDown(); } /** * Tests that documents with modified time in the future are NOT indexed (as this would cause * a problem by preventing it from indexing other documents modified between now and the future * date). */ public function test_future_documents() { $this->resetAfterTest(); // Create a course and a forum. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $forum = $generator->create_module('forum', ['course' => $course->id]); // Index everything up to current. Ensure the course is older than current second so it // definitely doesn't get indexed again next time. $this->waitForSecond(); $search = \testable_core_search::instance(); $search->index(false, 0); $search->get_engine()->get_and_clear_added_documents(); // Add 2 discussions to the forum, one of which happend just now, but the other is // incorrectly set to the future. $now = time(); $userid = get_admin()->id; $generator->get_plugin_generator('mod_forum')->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $userid, 'timemodified' => $now, 'name' => 'Frog']); $generator->get_plugin_generator('mod_forum')->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $userid, 'timemodified' => $now + 100, 'name' => 'Toad']); // Wait for a second so we're not actually on the same second as the forum post (there's a // 1 second overlap between indexing; it would get indexed in both checks below otherwise). $this->waitForSecond(); // Index. $search->index(false); $added = $search->get_engine()->get_and_clear_added_documents(); $this->assertCount(1, $added); $this->assertEquals('Frog', $added[0]->get('title')); // Check latest time - it should be the same as $now, not the + 100. $searcharea = $search->get_search_area($this->forumpostareaid); list($componentname, $varname) = $searcharea->get_config_var_name(); $this->assertEquals($now, get_config($componentname, $varname . '_lastindexrun')); // Index again - there should be nothing to index this time. $search->index(false); $added = $search->get_engine()->get_and_clear_added_documents(); $this->assertCount(0, $added); } /** * Tests that indexing a specified context works correctly. */ public function test_context_indexing() { global $USER; $this->resetAfterTest(); $this->setAdminUser(); // Create a course and two forums and a page. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $now = time(); $forum1 = $generator->create_module('forum', ['course' => $course->id]); $generator->get_plugin_generator('mod_forum')->create_discussion(['course' => $course->id, 'forum' => $forum1->id, 'userid' => $USER->id, 'timemodified' => $now, 'name' => 'Frog']); $this->waitForSecond(); $generator->get_plugin_generator('mod_forum')->create_discussion(['course' => $course->id, 'forum' => $forum1->id, 'userid' => $USER->id, 'timemodified' => $now + 2, 'name' => 'Zombie']); $forum2 = $generator->create_module('forum', ['course' => $course->id]); $this->waitForSecond(); $generator->get_plugin_generator('mod_forum')->create_discussion(['course' => $course->id, 'forum' => $forum2->id, 'userid' => $USER->id, 'timemodified' => $now + 1, 'name' => 'Toad']); $generator->create_module('page', ['course' => $course->id]); $generator->create_module('forum', ['course' => $course->id]); // Index forum 1 only. $search = \testable_core_search::instance(); $buffer = new \progress_trace_buffer(new \text_progress_trace(), false); $result = $search->index_context(\context_module::instance($forum1->cmid), '', 0, $buffer); $this->assertTrue($result->complete); $log = $buffer->get_buffer(); $buffer->reset_buffer(); // Confirm that output only processed 1 forum activity and 2 posts. $this->assertNotFalse(strpos($log, "area: Forum - activity information\n Processed 1 ")); $this->assertNotFalse(strpos($log, "area: Forum - posts\n Processed 2 ")); // Confirm that some areas for different types of context were skipped. $this->assertNotFalse(strpos($log, "area: Users\n Skipping")); $this->assertNotFalse(strpos($log, "area: Courses\n Skipping")); // Confirm that another module area had no results. $this->assertNotFalse(strpos($log, "area: Page\n No documents")); // Index whole course. $result = $search->index_context(\context_course::instance($course->id), '', 0, $buffer); $this->assertTrue($result->complete); $log = $buffer->get_buffer(); $buffer->reset_buffer(); // Confirm that output processed 3 forum activities and 3 posts. $this->assertNotFalse(strpos($log, "area: Forum - activity information\n Processed 3 ")); $this->assertNotFalse(strpos($log, "area: Forum - posts\n Processed 3 ")); // The course area was also included this time. $this->assertNotFalse(strpos($log, "area: Courses\n Processed 1 ")); // Confirm that another module area had results too. $this->assertNotFalse(strpos($log, "area: Page\n Processed 1 ")); // Index whole course, but only forum posts. $result = $search->index_context(\context_course::instance($course->id), 'mod_forum-post', 0, $buffer); $this->assertTrue($result->complete); $log = $buffer->get_buffer(); $buffer->reset_buffer(); // Confirm that output processed 3 posts but not forum activities. $this->assertFalse(strpos($log, "area: Forum - activity information")); $this->assertNotFalse(strpos($log, "area: Forum - posts\n Processed 3 ")); // Set time limit and retry index of whole course, taking 3 tries to complete it. $search->get_engine()->set_add_delay(0.4); $result = $search->index_context(\context_course::instance($course->id), '', 1, $buffer); $log = $buffer->get_buffer(); $buffer->reset_buffer(); $this->assertFalse($result->complete); $this->assertNotFalse(strpos($log, "area: Forum - activity information\n Processed 2 ")); $result = $search->index_context(\context_course::instance($course->id), '', 1, $buffer, $result->startfromarea, $result->startfromtime); $log = $buffer->get_buffer(); $buffer->reset_buffer(); $this->assertNotFalse(strpos($log, "area: Forum - activity information\n Processed 2 ")); $this->assertNotFalse(strpos($log, "area: Forum - posts\n Processed 2 ")); $this->assertFalse($result->complete); $result = $search->index_context(\context_course::instance($course->id), '', 1, $buffer, $result->startfromarea, $result->startfromtime); $log = $buffer->get_buffer(); $buffer->reset_buffer(); $this->assertNotFalse(strpos($log, "area: Forum - posts\n Processed 2 ")); $this->assertTrue($result->complete); } /** * Adding this test here as get_areas_user_accesses process is the same, results just depend on the context level. * * @return void */ public function test_search_user_accesses() { global $DB; $this->resetAfterTest(); $frontpage = $DB->get_record('course', array('id' => SITEID)); $frontpagectx = \context_course::instance($frontpage->id); $course1 = $this->getDataGenerator()->create_course(); $course1ctx = \context_course::instance($course1->id); $course2 = $this->getDataGenerator()->create_course(); $course2ctx = \context_course::instance($course2->id); $course3 = $this->getDataGenerator()->create_course(); $course3ctx = \context_course::instance($course3->id); $teacher = $this->getDataGenerator()->create_user(); $teacherctx = \context_user::instance($teacher->id); $student = $this->getDataGenerator()->create_user(); $studentctx = \context_user::instance($student->id); $noaccess = $this->getDataGenerator()->create_user(); $noaccessctx = \context_user::instance($noaccess->id); $this->getDataGenerator()->enrol_user($teacher->id, $course1->id, 'teacher'); $this->getDataGenerator()->enrol_user($student->id, $course1->id, 'student'); $frontpageforum = $this->getDataGenerator()->create_module('forum', array('course' => $frontpage->id)); $forum1 = $this->getDataGenerator()->create_module('forum', array('course' => $course1->id)); $forum2 = $this->getDataGenerator()->create_module('forum', array('course' => $course1->id)); $forum3 = $this->getDataGenerator()->create_module('forum', array('course' => $course2->id)); $frontpageforumcontext = \context_module::instance($frontpageforum->cmid); $context1 = \context_module::instance($forum1->cmid); $context2 = \context_module::instance($forum2->cmid); $context3 = \context_module::instance($forum3->cmid); $forum4 = $this->getDataGenerator()->create_module('forum', array('course' => $course3->id)); $context4 = \context_module::instance($forum4->cmid); $search = \testable_core_search::instance(); $mockareaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $search->add_core_search_areas(); $search->add_search_area($mockareaid, new \core_mocksearch\search\mock_search_area()); $this->setAdminUser(); $this->assertEquals((object)['everything' => true], $search->get_areas_user_accesses()); $sitectx = \context_course::instance(SITEID); // Can access the frontpage ones. $this->setUser($noaccess); $contexts = $search->get_areas_user_accesses()->usercontexts; $this->assertEquals(array($frontpageforumcontext->id => $frontpageforumcontext->id), $contexts[$this->forumpostareaid]); $this->assertEquals(array($sitectx->id => $sitectx->id), $contexts[$this->coursesareaid]); $mockctxs = array($noaccessctx->id => $noaccessctx->id, $frontpagectx->id => $frontpagectx->id); $this->assertEquals($mockctxs, $contexts[$mockareaid]); $this->setUser($teacher); $contexts = $search->get_areas_user_accesses()->usercontexts; $frontpageandcourse1 = array($frontpageforumcontext->id => $frontpageforumcontext->id, $context1->id => $context1->id, $context2->id => $context2->id); $this->assertEquals($frontpageandcourse1, $contexts[$this->forumpostareaid]); $this->assertEquals(array($sitectx->id => $sitectx->id, $course1ctx->id => $course1ctx->id), $contexts[$this->coursesareaid]); $mockctxs = array($teacherctx->id => $teacherctx->id, $frontpagectx->id => $frontpagectx->id, $course1ctx->id => $course1ctx->id); $this->assertEquals($mockctxs, $contexts[$mockareaid]); $this->setUser($student); $contexts = $search->get_areas_user_accesses()->usercontexts; $this->assertEquals($frontpageandcourse1, $contexts[$this->forumpostareaid]); $this->assertEquals(array($sitectx->id => $sitectx->id, $course1ctx->id => $course1ctx->id), $contexts[$this->coursesareaid]); $mockctxs = array($studentctx->id => $studentctx->id, $frontpagectx->id => $frontpagectx->id, $course1ctx->id => $course1ctx->id); $this->assertEquals($mockctxs, $contexts[$mockareaid]); // Hide the activity. set_coursemodule_visible($forum2->cmid, 0); $contexts = $search->get_areas_user_accesses()->usercontexts; $this->assertEquals(array($frontpageforumcontext->id => $frontpageforumcontext->id, $context1->id => $context1->id), $contexts[$this->forumpostareaid]); // Now test course limited searches. set_coursemodule_visible($forum2->cmid, 1); $this->getDataGenerator()->enrol_user($student->id, $course2->id, 'student'); $contexts = $search->get_areas_user_accesses()->usercontexts; $allcontexts = array($frontpageforumcontext->id => $frontpageforumcontext->id, $context1->id => $context1->id, $context2->id => $context2->id, $context3->id => $context3->id); $this->assertEquals($allcontexts, $contexts[$this->forumpostareaid]); $this->assertEquals(array($sitectx->id => $sitectx->id, $course1ctx->id => $course1ctx->id, $course2ctx->id => $course2ctx->id), $contexts[$this->coursesareaid]); $contexts = $search->get_areas_user_accesses(array($course1->id, $course2->id))->usercontexts; $allcontexts = array($context1->id => $context1->id, $context2->id => $context2->id, $context3->id => $context3->id); $this->assertEquals($allcontexts, $contexts[$this->forumpostareaid]); $this->assertEquals(array($course1ctx->id => $course1ctx->id, $course2ctx->id => $course2ctx->id), $contexts[$this->coursesareaid]); $contexts = $search->get_areas_user_accesses(array($course2->id))->usercontexts; $allcontexts = array($context3->id => $context3->id); $this->assertEquals($allcontexts, $contexts[$this->forumpostareaid]); $this->assertEquals(array($course2ctx->id => $course2ctx->id), $contexts[$this->coursesareaid]); $contexts = $search->get_areas_user_accesses(array($course1->id))->usercontexts; $allcontexts = array($context1->id => $context1->id, $context2->id => $context2->id); $this->assertEquals($allcontexts, $contexts[$this->forumpostareaid]); $this->assertEquals(array($course1ctx->id => $course1ctx->id), $contexts[$this->coursesareaid]); // Test context limited search with no course limit. $contexts = $search->get_areas_user_accesses(false, [$frontpageforumcontext->id, $course2ctx->id])->usercontexts; $this->assertEquals([$frontpageforumcontext->id => $frontpageforumcontext->id], $contexts[$this->forumpostareaid]); $this->assertEquals([$course2ctx->id => $course2ctx->id], $contexts[$this->coursesareaid]); // Test context limited search with course limit. $contexts = $search->get_areas_user_accesses([$course1->id, $course2->id], [$frontpageforumcontext->id, $course2ctx->id])->usercontexts; $this->assertArrayNotHasKey($this->forumpostareaid, $contexts); $this->assertEquals([$course2ctx->id => $course2ctx->id], $contexts[$this->coursesareaid]); // Single context and course. $contexts = $search->get_areas_user_accesses([$course1->id], [$context1->id])->usercontexts; $this->assertEquals([$context1->id => $context1->id], $contexts[$this->forumpostareaid]); $this->assertArrayNotHasKey($this->coursesareaid, $contexts); // Enable "Include all visible courses" feature. set_config('searchincludeallcourses', 1); $contexts = $search->get_areas_user_accesses()->usercontexts; $expected = [ $sitectx->id => $sitectx->id, $course1ctx->id => $course1ctx->id, $course2ctx->id => $course2ctx->id, $course3ctx->id => $course3ctx->id ]; // Check that a student has assess to all courses data when "searchincludeallcourses" is enabled. $this->assertEquals($expected, $contexts[$this->coursesareaid]); // But at the same time doesn't have access to activities in the courses that the student can't access. $this->assertFalse(key_exists($context4->id, $contexts[$this->forumpostareaid])); // For admins, this is still limited only if we specify the things, so it should be same. $this->setAdminUser(); $contexts = $search->get_areas_user_accesses([$course1->id], [$context1->id])->usercontexts; $this->assertEquals([$context1->id => $context1->id], $contexts[$this->forumpostareaid]); $this->assertArrayNotHasKey($this->coursesareaid, $contexts); } /** * Tests the block support in get_search_user_accesses. * * @return void */ public function test_search_user_accesses_blocks() { global $DB; $this->resetAfterTest(); $this->setAdminUser(); // Create course and add HTML block. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(); $context1 = \context_course::instance($course1->id); $page = new \moodle_page(); $page->set_context($context1); $page->set_course($course1); $page->set_pagelayout('standard'); $page->set_pagetype('course-view'); $page->blocks->load_blocks(); $page->blocks->add_block_at_end_of_default_region('html'); // Create another course with HTML blocks only in some weird page or a module page (not // yet supported, so both these blocks will be ignored). $course2 = $generator->create_course(); $context2 = \context_course::instance($course2->id); $page = new \moodle_page(); $page->set_context($context2); $page->set_course($course2); $page->set_pagelayout('standard'); $page->set_pagetype('bogus-page'); $page->blocks->load_blocks(); $page->blocks->add_block_at_end_of_default_region('html'); $forum = $this->getDataGenerator()->create_module('forum', array('course' => $course2->id)); $forumcontext = \context_module::instance($forum->cmid); $page = new \moodle_page(); $page->set_context($forumcontext); $page->set_course($course2); $page->set_pagelayout('standard'); $page->set_pagetype('mod-forum-view'); $page->blocks->load_blocks(); $page->blocks->add_block_at_end_of_default_region('html'); // The third course has 2 HTML blocks. $course3 = $generator->create_course(); $context3 = \context_course::instance($course3->id); $page = new \moodle_page(); $page->set_context($context3); $page->set_course($course3); $page->set_pagelayout('standard'); $page->set_pagetype('course-view'); $page->blocks->load_blocks(); $page->blocks->add_block_at_end_of_default_region('html'); $page->blocks->add_block_at_end_of_default_region('html'); // Student 1 belongs to all 3 courses. $student1 = $generator->create_user(); $generator->enrol_user($student1->id, $course1->id, 'student'); $generator->enrol_user($student1->id, $course2->id, 'student'); $generator->enrol_user($student1->id, $course3->id, 'student'); // Student 2 belongs only to course 2. $student2 = $generator->create_user(); $generator->enrol_user($student2->id, $course2->id, 'student'); // And the third student is only in course 3. $student3 = $generator->create_user(); $generator->enrol_user($student3->id, $course3->id, 'student'); $search = \testable_core_search::instance(); $search->add_core_search_areas(); // Admin gets 'true' result to function regardless of blocks. $this->setAdminUser(); $this->assertEquals((object)['everything' => true], $search->get_areas_user_accesses()); // Student 1 gets all 3 block contexts. $this->setUser($student1); $contexts = $search->get_areas_user_accesses()->usercontexts; $this->assertArrayHasKey('block_html-content', $contexts); $this->assertCount(3, $contexts['block_html-content']); // Student 2 does not get any blocks. $this->setUser($student2); $contexts = $search->get_areas_user_accesses()->usercontexts; $this->assertArrayNotHasKey('block_html-content', $contexts); // Student 3 gets only two of them. $this->setUser($student3); $contexts = $search->get_areas_user_accesses()->usercontexts; $this->assertArrayHasKey('block_html-content', $contexts); $this->assertCount(2, $contexts['block_html-content']); // A course limited search for student 1 is the same as the student 3 search. $this->setUser($student1); $limitedcontexts = $search->get_areas_user_accesses([$course3->id])->usercontexts; $this->assertEquals($contexts['block_html-content'], $limitedcontexts['block_html-content']); // Get block context ids for the blocks that appear. $blockcontextids = $DB->get_fieldset_sql(' SELECT x.id FROM {block_instances} bi JOIN {context} x ON x.instanceid = bi.id AND x.contextlevel = ? WHERE (parentcontextid = ? OR parentcontextid = ?) AND blockname = ? ORDER BY bi.id', [CONTEXT_BLOCK, $context1->id, $context3->id, 'html']); // Context limited search (no course). $contexts = $search->get_areas_user_accesses(false, [$blockcontextids[0], $blockcontextids[2]])->usercontexts; $this->assertCount(2, $contexts['block_html-content']); // Context limited search (with course 3). $contexts = $search->get_areas_user_accesses([$course2->id, $course3->id], [$blockcontextids[0], $blockcontextids[2]])->usercontexts; $this->assertCount(1, $contexts['block_html-content']); } /** * Tests retrieval of users search areas when limiting to a course the user is not enrolled in */ public function test_search_users_accesses_limit_non_enrolled_course() { global $DB; $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); $this->setUser($user); $search = \testable_core_search::instance(); $search->add_core_search_areas(); $course = $this->getDataGenerator()->create_course(); $context = \context_course::instance($course->id); // Limit courses to search to only those the user is enrolled in. set_config('searchallavailablecourses', 0); $usercontexts = $search->get_areas_user_accesses([$course->id])->usercontexts; $this->assertNotEmpty($usercontexts); $this->assertArrayNotHasKey('core_course-course', $usercontexts); // This config ensures the search will also include courses the user can view. set_config('searchallavailablecourses', 1); // Allow "Authenticated user" role to view the course without being enrolled in it. $userrole = $DB->get_record('role', ['shortname' => 'user'], '*', MUST_EXIST); role_change_permission($userrole->id, $context, 'moodle/course:view', CAP_ALLOW); $usercontexts = $search->get_areas_user_accesses([$course->id])->usercontexts; $this->assertNotEmpty($usercontexts); $this->assertArrayHasKey('core_course-course', $usercontexts); $this->assertEquals($context->id, reset($usercontexts['core_course-course'])); } /** * Test get_areas_user_accesses with regard to the 'all available courses' config option. * * @return void */ public function test_search_user_accesses_allavailable() { global $DB, $CFG; $this->resetAfterTest(); // Front page, including a forum. $frontpage = $DB->get_record('course', array('id' => SITEID)); $forumfront = $this->getDataGenerator()->create_module('forum', array('course' => $frontpage->id)); $forumfrontctx = \context_module::instance($forumfront->cmid); // Course 1 does not allow guest access. $course1 = $this->getDataGenerator()->create_course((object)array( 'enrol_guest_status_0' => ENROL_INSTANCE_DISABLED, 'enrol_guest_password_0' => '')); $forum1 = $this->getDataGenerator()->create_module('forum', array('course' => $course1->id)); $forum1ctx = \context_module::instance($forum1->cmid); // Course 2 does not allow guest but is accessible by all users. $course2 = $this->getDataGenerator()->create_course((object)array( 'enrol_guest_status_0' => ENROL_INSTANCE_DISABLED, 'enrol_guest_password_0' => '')); $course2ctx = \context_course::instance($course2->id); $forum2 = $this->getDataGenerator()->create_module('forum', array('course' => $course2->id)); $forum2ctx = \context_module::instance($forum2->cmid); assign_capability('moodle/course:view', CAP_ALLOW, $CFG->defaultuserroleid, $course2ctx->id); // Course 3 allows guest access without password. $course3 = $this->getDataGenerator()->create_course((object)array( 'enrol_guest_status_0' => ENROL_INSTANCE_ENABLED, 'enrol_guest_password_0' => '')); $forum3 = $this->getDataGenerator()->create_module('forum', array('course' => $course2->id)); $forum3ctx = \context_module::instance($forum3->cmid); // Student user is enrolled in course 1. $student = $this->getDataGenerator()->create_user(); $this->getDataGenerator()->enrol_user($student->id, $course1->id, 'student'); // No access user is just a user with no permissions. $noaccess = $this->getDataGenerator()->create_user(); // First test without the all available option. $search = \testable_core_search::instance(); // Admin user can access everything. $this->setAdminUser(); $this->assertEquals((object)['everything' => true], $search->get_areas_user_accesses()); // No-access user can access only the front page forum. $this->setUser($noaccess); $contexts = $search->get_areas_user_accesses()->usercontexts; $this->assertEquals([$forumfrontctx->id], array_keys($contexts[$this->forumpostareaid])); // Student can access the front page forum plus the enrolled one. $this->setUser($student); $contexts = $search->get_areas_user_accesses()->usercontexts; $this->assertEquals([$forum1ctx->id, $forumfrontctx->id], array_keys($contexts[$this->forumpostareaid])); // Now turn on the all available option. set_config('searchallavailablecourses', 1); // Admin user can access everything. $this->setAdminUser(); $this->assertEquals((object)['everything' => true], $search->get_areas_user_accesses()); // No-access user can access the front page forum and course 2, 3. $this->setUser($noaccess); $contexts = $search->get_areas_user_accesses()->usercontexts; $this->assertEquals([$forum2ctx->id, $forum3ctx->id, $forumfrontctx->id], array_keys($contexts[$this->forumpostareaid])); // Student can access the front page forum plus the enrolled one plus courses 2, 3. $this->setUser($student); $contexts = $search->get_areas_user_accesses()->usercontexts; $this->assertEquals([$forum1ctx->id, $forum2ctx->id, $forum3ctx->id, $forumfrontctx->id], array_keys($contexts[$this->forumpostareaid])); } /** * Tests group-related aspects of the get_areas_user_accesses function. */ public function test_search_user_accesses_groups() { global $DB; $this->resetAfterTest(); $this->setAdminUser(); // Create 2 courses each with 2 groups and 2 forums (separate/visible groups). $generator = $this->getDataGenerator(); $course1 = $generator->create_course(); $course2 = $generator->create_course(); $group1 = $generator->create_group(['courseid' => $course1->id]); $group2 = $generator->create_group(['courseid' => $course1->id]); $group3 = $generator->create_group(['courseid' => $course2->id]); $group4 = $generator->create_group(['courseid' => $course2->id]); $forum1s = $generator->create_module('forum', ['course' => $course1->id, 'groupmode' => SEPARATEGROUPS]); $id1s = \context_module::instance($forum1s->cmid)->id; $forum1v = $generator->create_module('forum', ['course' => $course1->id, 'groupmode' => VISIBLEGROUPS]); $id1v = \context_module::instance($forum1v->cmid)->id; $forum2s = $generator->create_module('forum', ['course' => $course2->id, 'groupmode' => SEPARATEGROUPS]); $id2s = \context_module::instance($forum2s->cmid)->id; $forum2n = $generator->create_module('forum', ['course' => $course2->id, 'groupmode' => NOGROUPS]); $id2n = \context_module::instance($forum2n->cmid)->id; // Get search instance. $search = \testable_core_search::instance(); $search->add_core_search_areas(); // User 1 is a manager in one course and a student in the other one. They belong to // all of the groups 1, 2, 3, and 4. $user1 = $generator->create_user(); $generator->enrol_user($user1->id, $course1->id, 'manager'); $generator->enrol_user($user1->id, $course2->id, 'student'); groups_add_member($group1, $user1); groups_add_member($group2, $user1); groups_add_member($group3, $user1); groups_add_member($group4, $user1); $this->setUser($user1); $accessinfo = $search->get_areas_user_accesses(); $contexts = $accessinfo->usercontexts; // Double-check all the forum contexts. $postcontexts = $contexts['mod_forum-post']; sort($postcontexts); $this->assertEquals([$id1s, $id1v, $id2s, $id2n], $postcontexts); // Only the context in the second course (no accessallgroups) is restricted. $restrictedcontexts = $accessinfo->separategroupscontexts; sort($restrictedcontexts); $this->assertEquals([$id2s], $restrictedcontexts); // Only the groups from the second course (no accessallgroups) are included. $groupids = $accessinfo->usergroups; sort($groupids); $this->assertEquals([$group3->id, $group4->id], $groupids); // User 2 is a student in each course and belongs to groups 2 and 4. $user2 = $generator->create_user(); $generator->enrol_user($user2->id, $course1->id, 'student'); $generator->enrol_user($user2->id, $course2->id, 'student'); groups_add_member($group2, $user2); groups_add_member($group4, $user2); $this->setUser($user2); $accessinfo = $search->get_areas_user_accesses(); $contexts = $accessinfo->usercontexts; // Double-check all the forum contexts. $postcontexts = $contexts['mod_forum-post']; sort($postcontexts); $this->assertEquals([$id1s, $id1v, $id2s, $id2n], $postcontexts); // Both separate groups forums are restricted. $restrictedcontexts = $accessinfo->separategroupscontexts; sort($restrictedcontexts); $this->assertEquals([$id1s, $id2s], $restrictedcontexts); // Groups from both courses are included. $groupids = $accessinfo->usergroups; sort($groupids); $this->assertEquals([$group2->id, $group4->id], $groupids); // User 3 is a manager at system level. $user3 = $generator->create_user(); role_assign($DB->get_field('role', 'id', ['shortname' => 'manager'], MUST_EXIST), $user3->id, \context_system::instance()); $this->setUser($user3); $accessinfo = $search->get_areas_user_accesses(); // Nothing is restricted and no groups are relevant. $this->assertEquals([], $accessinfo->separategroupscontexts); $this->assertEquals([], $accessinfo->usergroups); } /** * test_is_search_area * * @return void */ public function test_is_search_area() { $this->assertFalse(\testable_core_search::is_search_area('\asd\asd')); $this->assertFalse(\testable_core_search::is_search_area('\mod_forum\search\posta')); $this->assertFalse(\testable_core_search::is_search_area('\core_search\base_mod')); $this->assertTrue(\testable_core_search::is_search_area('\mod_forum\search\post')); $this->assertTrue(\testable_core_search::is_search_area('\\mod_forum\\search\\post')); $this->assertTrue(\testable_core_search::is_search_area('mod_forum\\search\\post')); } /** * Tests the request_index function used for reindexing certain contexts. This only tests * adding things to the request list, it doesn't test that they are actually indexed by the * scheduled task. */ public function test_request_index() { global $DB; $this->resetAfterTest(); $course1 = $this->getDataGenerator()->create_course(); $course1ctx = \context_course::instance($course1->id); $course2 = $this->getDataGenerator()->create_course(); $course2ctx = \context_course::instance($course2->id); $forum1 = $this->getDataGenerator()->create_module('forum', ['course' => $course1->id]); $forum1ctx = \context_module::instance($forum1->cmid); $forum2 = $this->getDataGenerator()->create_module('forum', ['course' => $course2->id]); $forum2ctx = \context_module::instance($forum2->cmid); // Initially no requests. $this->assertEquals(0, $DB->count_records('search_index_requests')); // Request update for course 1, all areas. \core_search\manager::request_index($course1ctx); // Check all details of entry. $results = array_values($DB->get_records('search_index_requests')); $this->assertCount(1, $results); $this->assertEquals($course1ctx->id, $results[0]->contextid); $this->assertEquals('', $results[0]->searcharea); $now = time(); $this->assertLessThanOrEqual($now, $results[0]->timerequested); $this->assertGreaterThan($now - 10, $results[0]->timerequested); $this->assertEquals('', $results[0]->partialarea); $this->assertEquals(0, $results[0]->partialtime); // Request forum 1, all areas; not added as covered by course 1. \core_search\manager::request_index($forum1ctx); $this->assertEquals(1, $DB->count_records('search_index_requests')); // Request forum 1, specific area; not added as covered by course 1 all areas. \core_search\manager::request_index($forum1ctx, 'forum-post'); $this->assertEquals(1, $DB->count_records('search_index_requests')); // Request course 1 again, specific area; not added as covered by all areas. \core_search\manager::request_index($course1ctx, 'forum-post'); $this->assertEquals(1, $DB->count_records('search_index_requests')); // Request course 1 again, all areas; not needed as covered already. \core_search\manager::request_index($course1ctx); $this->assertEquals(1, $DB->count_records('search_index_requests')); // Request course 2, specific area. \core_search\manager::request_index($course2ctx, 'label-activity'); // Note: I'm ordering by ID for convenience - this is dangerous in real code (see MDL-43447) // but in a unit test it shouldn't matter as nobody is using clustered databases for unit // test. $results = array_values($DB->get_records('search_index_requests', null, 'id')); $this->assertCount(2, $results); $this->assertEquals($course1ctx->id, $results[0]->contextid); $this->assertEquals($course2ctx->id, $results[1]->contextid); $this->assertEquals('label-activity', $results[1]->searcharea); // Request forum 2, same specific area; not added. \core_search\manager::request_index($forum2ctx, 'label-activity'); $this->assertEquals(2, $DB->count_records('search_index_requests')); // Request forum 2, different specific area; added. \core_search\manager::request_index($forum2ctx, 'forum-post'); $this->assertEquals(3, $DB->count_records('search_index_requests')); // Request forum 2, all areas; also added. (Note: This could obviously remove the previous // one, but for simplicity, I didn't make it do that; also it could perhaps cause problems // if we had already begun processing the previous entry.) \core_search\manager::request_index($forum2ctx); $this->assertEquals(4, $DB->count_records('search_index_requests')); // Clear queue and do tests relating to priority. $DB->delete_records('search_index_requests'); // Request forum 1, specific area, priority 100. \core_search\manager::request_index($forum1ctx, 'forum-post', 100); $results = array_values($DB->get_records('search_index_requests', null, 'id')); $this->assertCount(1, $results); $this->assertEquals(100, $results[0]->indexpriority); // Request forum 1, same area, lower priority; no change. \core_search\manager::request_index($forum1ctx, 'forum-post', 99); $results = array_values($DB->get_records('search_index_requests', null, 'id')); $this->assertCount(1, $results); $this->assertEquals(100, $results[0]->indexpriority); // Request forum 1, same area, higher priority; priority stored changes. \core_search\manager::request_index($forum1ctx, 'forum-post', 101); $results = array_values($DB->get_records('search_index_requests', null, 'id')); $this->assertCount(1, $results); $this->assertEquals(101, $results[0]->indexpriority); // Request forum 1, all areas, lower priority; adds second entry. \core_search\manager::request_index($forum1ctx, '', 100); $results = array_values($DB->get_records('search_index_requests', null, 'id')); $this->assertCount(2, $results); $this->assertEquals(100, $results[1]->indexpriority); // Request course 1, all areas, lower priority; adds third entry. \core_search\manager::request_index($course1ctx, '', 99); $results = array_values($DB->get_records('search_index_requests', null, 'id')); $this->assertCount(3, $results); $this->assertEquals(99, $results[2]->indexpriority); } /** * Tests the process_index_requests function. */ public function test_process_index_requests() { global $DB; $this->resetAfterTest(); $search = \testable_core_search::instance(); // When there are no index requests, nothing gets logged. $progress = new \progress_trace_buffer(new \text_progress_trace(), false); $search->process_index_requests(0.0, $progress); $out = $progress->get_buffer(); $progress->reset_buffer(); $this->assertEquals('', $out); // Set up the course with 3 forums. $generator = $this->getDataGenerator(); $course = $generator->create_course(['fullname' => 'TCourse']); $forum1 = $generator->create_module('forum', ['course' => $course->id, 'name' => 'TForum1']); $forum2 = $generator->create_module('forum', ['course' => $course->id, 'name' => 'TForum2']); $forum3 = $generator->create_module('forum', ['course' => $course->id, 'name' => 'TForum3']); // Hack the forums so they have different creation times. $now = time(); $DB->set_field('forum', 'timemodified', $now - 30, ['id' => $forum1->id]); $DB->set_field('forum', 'timemodified', $now - 20, ['id' => $forum2->id]); $DB->set_field('forum', 'timemodified', $now - 10, ['id' => $forum3->id]); $forum2time = $now - 20; // Make 2 index requests. \testable_core_search::fake_current_time($now - 3); $search::request_index(\context_course::instance($course->id), 'mod_label-activity'); \testable_core_search::fake_current_time($now - 2); $search::request_index(\context_module::instance($forum1->cmid)); // Run with no time limit. $search->process_index_requests(0.0, $progress); $out = $progress->get_buffer(); $progress->reset_buffer(); // Check that it's done both areas. $this->assertStringContainsString( 'Indexing requested context: Course: TCourse (search area: mod_label-activity)', $out); $this->assertStringContainsString( 'Completed requested context: Course: TCourse (search area: mod_label-activity)', $out); $this->assertStringContainsString('Indexing requested context: Forum: TForum1', $out); $this->assertStringContainsString('Completed requested context: Forum: TForum1', $out); // Check the requests database table is now empty. $this->assertEquals(0, $DB->count_records('search_index_requests')); // Request indexing the course a couple of times. \testable_core_search::fake_current_time($now - 3); $search::request_index(\context_course::instance($course->id), 'mod_forum-activity'); \testable_core_search::fake_current_time($now - 2); $search::request_index(\context_course::instance($course->id), 'mod_forum-post'); // Do the processing again with a time limit and indexing delay. The time limit is too // small; because of the way the logic works, this means it will index 2 activities. $search->get_engine()->set_add_delay(0.2); $search->process_index_requests(0.1, $progress); $out = $progress->get_buffer(); $progress->reset_buffer(); // Confirm the right wrapper information was logged. $this->assertStringContainsString( 'Indexing requested context: Course: TCourse (search area: mod_forum-activity)', $out); $this->assertStringContainsString('Stopping indexing due to time limit', $out); $this->assertStringContainsString( 'Ending requested context: Course: TCourse (search area: mod_forum-activity)', $out); // Check the database table has been updated with progress. $records = array_values($DB->get_records('search_index_requests', null, 'searcharea')); $this->assertEquals('mod_forum-activity', $records[0]->partialarea); $this->assertEquals($forum2time, $records[0]->partialtime); // Run again and confirm it now finishes. $search->process_index_requests(2.0, $progress); $out = $progress->get_buffer(); $progress->reset_buffer(); $this->assertStringContainsString( 'Completed requested context: Course: TCourse (search area: mod_forum-activity)', $out); $this->assertStringContainsString( 'Completed requested context: Course: TCourse (search area: mod_forum-post)', $out); // Confirm table is now empty. $this->assertEquals(0, $DB->count_records('search_index_requests')); // Make 2 requests - first one is low priority. \testable_core_search::fake_current_time($now - 3); $search::request_index(\context_module::instance($forum1->cmid), 'mod_forum-activity', \core_search\manager::INDEX_PRIORITY_REINDEXING); \testable_core_search::fake_current_time($now - 2); $search::request_index(\context_module::instance($forum2->cmid), 'mod_forum-activity'); // Process with short time limit and confirm it does the second one first. $search->process_index_requests(0.1, $progress); $out = $progress->get_buffer(); $progress->reset_buffer(); $this->assertStringContainsString( 'Completed requested context: Forum: TForum2 (search area: mod_forum-activity)', $out); $search->process_index_requests(0.1, $progress); $out = $progress->get_buffer(); $progress->reset_buffer(); $this->assertStringContainsString( 'Completed requested context: Forum: TForum1 (search area: mod_forum-activity)', $out); // Make a request for a course context... $course = $generator->create_course(); $context = \context_course::instance($course->id); $search::request_index($context); // ...but then delete it (note: delete_course spews output, so we throw it away). ob_start(); delete_course($course); ob_end_clean(); // Process requests - it should only note the deleted context. $search->process_index_requests(10, $progress); $out = $progress->get_buffer(); $progress->reset_buffer(); $this->assertStringContainsString('Skipped deleted context: ' . $context->id, $out); // Confirm request table is now empty. $this->assertEquals(0, $DB->count_records('search_index_requests')); } /** * Test search area categories. */ public function test_get_search_area_categories() { $categories = \core_search\manager::get_search_area_categories(); $this->assertTrue(is_array($categories)); $this->assertTrue(count($categories) >= 4); // We always should have 4 core categories. $this->assertArrayHasKey('core-all', $categories); $this->assertArrayHasKey('core-course-content', $categories); $this->assertArrayHasKey('core-courses', $categories); $this->assertArrayHasKey('core-users', $categories); foreach ($categories as $category) { $this->assertInstanceOf('\core_search\area_category', $category); } } /** * Test that we can find out if search area categories functionality is enabled. */ public function test_is_search_area_categories_enabled() { $this->resetAfterTest(); $this->assertFalse(\core_search\manager::is_search_area_categories_enabled()); set_config('searchenablecategories', 1); $this->assertTrue(\core_search\manager::is_search_area_categories_enabled()); set_config('searchenablecategories', 0); $this->assertFalse(\core_search\manager::is_search_area_categories_enabled()); } /** * Test that we can find out if hiding all results category is enabled. */ public function test_should_hide_all_results_category() { $this->resetAfterTest(); $this->assertEquals(0, \core_search\manager::should_hide_all_results_category()); set_config('searchhideallcategory', 1); $this->assertEquals(1, \core_search\manager::should_hide_all_results_category()); set_config('searchhideallcategory', 0); $this->assertEquals(0, \core_search\manager::should_hide_all_results_category()); } /** * Test that we can get default search category name. */ public function test_get_default_area_category_name() { $this->resetAfterTest(); $expected = 'core-all'; $this->assertEquals($expected, \core_search\manager::get_default_area_category_name()); set_config('searchhideallcategory', 1); $expected = 'core-course-content'; $this->assertEquals($expected, \core_search\manager::get_default_area_category_name()); set_config('searchhideallcategory', 0); $expected = 'core-all'; $this->assertEquals($expected, \core_search\manager::get_default_area_category_name()); } /** * Test that we can get correct search area category by its name. */ public function test_get_search_area_category_by_name() { $this->resetAfterTest(); $testcategory = \core_search\manager::get_search_area_category_by_name('test_random_name'); $this->assertEquals('core-all', $testcategory->get_name()); $testcategory = \core_search\manager::get_search_area_category_by_name('core-courses'); $this->assertEquals('core-courses', $testcategory->get_name()); set_config('searchhideallcategory', 1); $testcategory = \core_search\manager::get_search_area_category_by_name('test_random_name'); $this->assertEquals('core-course-content', $testcategory->get_name()); } /** * Test that we can check that "Include all visible courses" feature is enabled. */ public function test_include_all_courses_enabled() { $this->resetAfterTest(); $this->assertFalse(\core_search\manager::include_all_courses()); set_config('searchincludeallcourses', 1); $this->assertTrue(\core_search\manager::include_all_courses()); } /** * Test that we can correctly build a list of courses for a course filter for the search results. */ public function test_build_limitcourseids() { global $USER; $this->resetAfterTest(); $this->setAdminUser(); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $course3 = $this->getDataGenerator()->create_course(); $course4 = $this->getDataGenerator()->create_course(); $this->getDataGenerator()->enrol_user($USER->id, $course1->id); $this->getDataGenerator()->enrol_user($USER->id, $course3->id); $search = \testable_core_search::instance(); $formdata = new \stdClass(); $formdata->courseids = []; $formdata->mycoursesonly = false; $limitcourseids = $search->build_limitcourseids($formdata); $this->assertEquals(false, $limitcourseids); $formdata->courseids = []; $formdata->mycoursesonly = true; $limitcourseids = $search->build_limitcourseids($formdata); $this->assertEquals([$course1->id, $course3->id], $limitcourseids); $formdata->courseids = [$course1->id, $course2->id, $course4->id]; $formdata->mycoursesonly = false; $limitcourseids = $search->build_limitcourseids($formdata); $this->assertEquals([$course1->id, $course2->id, $course4->id], $limitcourseids); $formdata->courseids = [$course1->id, $course2->id, $course4->id]; $formdata->mycoursesonly = true; $limitcourseids = $search->build_limitcourseids($formdata); $this->assertEquals([$course1->id], $limitcourseids); } /** * Test data for test_parse_areaid test fucntion. * * @return array */ public function parse_search_area_id_data_provider() { return [ ['mod_book-chapter', ['mod_book', 'search_chapter']], ['mod_customcert-activity', ['mod_customcert', 'search_activity']], ['core_course-mycourse', ['core_search', 'core_course_mycourse']], ]; } /** * Test that manager class can parse area id correctly. * @dataProvider parse_search_area_id_data_provider * * @param string $areaid Area id to parse. * @param array $expected Expected result of parsing. */ public function test_parse_search_area_id($areaid, $expected) { $this->assertEquals($expected, \core_search\manager::parse_areaid($areaid)); } /** * Test that manager class will throw an exception when parsing an invalid area id. */ public function test_parse_invalid_search_area_id() { $this->expectException('coding_exception'); $this->expectExceptionMessage('Trying to parse invalid search area id invalid_area'); \core_search\manager::parse_areaid('invalid_area'); } /** * Test getting a coding exception when trying to lean up existing search area. */ public function test_cleaning_up_existing_search_area() { $expectedmessage = "Area mod_assign-activity exists. Please use appropriate search area class to manipulate the data."; $this->expectException('coding_exception'); $this->expectExceptionMessage($expectedmessage); \core_search\manager::clean_up_non_existing_area('mod_assign-activity'); } /** * Test clean up of non existing search area. */ public function test_clean_up_non_existing_search_area() { global $DB; $this->resetAfterTest(); $areaid = 'core_course-mycourse'; $plugin = 'core_search'; // Get all settings to DB and make sure they are there. foreach (\core_search\base::get_settingnames() as $settingname) { $record = new \stdClass(); $record->plugin = $plugin; $record->name = 'core_course_mycourse'. $settingname; $record->value = 'test'; $DB->insert_record('config_plugins', $record); $this->assertTrue($DB->record_exists('config_plugins', ['plugin' => $plugin, 'name' => $record->name])); } // Clean up the search area. \core_search\manager::clean_up_non_existing_area($areaid); // Check that records are not in DB after we ran clean up. foreach (\core_search\base::get_settingnames() as $settingname) { $plugin = 'core_search'; $name = 'core_course_mycourse'. $settingname; $this->assertFalse($DB->record_exists('config_plugins', ['plugin' => $plugin, 'name' => $name])); } } /** * Tests the context_deleted, course_deleting_start, and course_deleting_finish methods. */ public function test_context_deletion() { $this->resetAfterTest(); // Create one course with 4 activities, and another with one. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(); $page1 = $generator->create_module('page', ['course' => $course1]); $context1 = \context_module::instance($page1->cmid); $page2 = $generator->create_module('page', ['course' => $course1]); $page3 = $generator->create_module('page', ['course' => $course1]); $context3 = \context_module::instance($page3->cmid); $page4 = $generator->create_module('page', ['course' => $course1]); $course2 = $generator->create_course(); $page5 = $generator->create_module('page', ['course' => $course2]); $context5 = \context_module::instance($page5->cmid); // Also create a user. $user = $generator->create_user(); $usercontext = \context_user::instance($user->id); $search = \testable_core_search::instance(); // Delete two of the pages individually. course_delete_module($page1->cmid); course_delete_module($page3->cmid); // Delete the course with another two. delete_course($course1->id, false); // Delete the user. delete_user($user); // Delete the page from the other course. course_delete_module($page5->cmid); // It should have deleted the contexts and the course, but not the contexts in the course. $expected = [ ['context', $context1->id], ['context', $context3->id], ['course', $course1->id], ['context', $usercontext->id], ['context', $context5->id] ]; $this->assertEquals($expected, $search->get_engine()->get_and_clear_deletes()); } } tests/behat/search_information.feature 0000644 00000003352 15152012435 0014221 0 ustar 00 @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 tests/behat/setup_search_engine.feature 0000644 00000002010 15152012435 0014347 0 ustar 00 @core @core_search Feature: Plugins > Search > Search setup contains Setup search engine only if the target section actually exists In order to set up the selected search engine As an admin I need to be able to click the link 'Setup search engine' but only if the target section actually exists Scenario: Selected search engine has an admin section Given the following config values are set as admin: | enableglobalsearch | 1 | | searchengine | solr | And I log in as "admin" When I navigate to "Plugins > Search" in site administration Then "Setup search engine" "link" should exist Scenario: Selected search engine does not have an admin section Given the following config values are set as admin: | enableglobalsearch | 1 | | searchengine | simpledb | And I log in as "admin" When I navigate to "Plugins > Search" in site administration Then I should see "Setup search engine" And "Setup search engine" "link" should not exist tests/behat/search_by_user.feature 0000644 00000005476 15152012435 0013355 0 ustar 00 @core @core_search Feature: Select users when searching for user-created content In order to search for content by specific users As a user I need to be able to add users to the select list in the search form Background: Given solr is installed And the following config values are set as admin: | enableglobalsearch | 1 | | searchengine | solr | And the following "courses" exist: | shortname | fullname | | C1 | Frogs | | C2 | Zombies | And the following "activities" exist: | activity | name | intro | course | idnumber | | page | PageName1 | PageDesc1 | C1 | PAGE1 | And the following "users" exist: | username | firstname | lastname | | s1 | Anne | Other | | s2 | Anne | Additional | | t | Anne | Ditin | And the following "course enrolments" exist: | user | course | role | | s1 | C1 | student | | s2 | C2 | student | | t | C1 | teacher | @javascript Scenario: As administrator, search for users from home page Given I log in as "admin" And global search expects the query "frogs" and will return: | type | idnumber | | activity | PAGE1 | And I search for "frogs" using the header global search box And I expand all fieldsets When I expand the "Users" autocomplete # Alphabetical last name order. Then "Anne Additional" "text" should appear before "Anne Ditin" "text" in the "Users" "autocomplete" And "Anne Ditin" "text" should appear before "Anne Other" "text" in the "Users" "autocomplete" @javascript Scenario: As administrator, search for users within course Given I log in as "admin" And I am on "Frogs" course homepage And global search expects the query "frogs" and will return: | type | idnumber | | activity | PAGE1 | And I search for "frogs" using the header global search box And I expand all fieldsets And I select "Course: Frogs" from the "Search within" singleselect When I expand the "Users" autocomplete # Users in selected course appear first. Then "Anne Additional" "text" should appear after "Anne Other" "text" in the "Users" "autocomplete" @javascript Scenario: As student, cannot see users on other courses Given I log in as "s1" And I am on "Frogs" course homepage And global search expects the query "frogs" and will return: | type | idnumber | | activity | PAGE1 | And I search for "frogs" using the header global search box And I expand all fieldsets When I expand the "Users" autocomplete Then "Anne Ditin" "text" should appear before "Anne Other" "text" in the "Users" "autocomplete" And "Anne Additional" "text" should not exist tests/behat/search_query.feature 0000644 00000013274 15152012435 0013045 0 ustar 00 @core @core_search Feature: Use global search interface In order to search for things As a user I need to be able to type search queries and see results Background: Given the following config values are set as admin: | enableglobalsearch | 1 | | searchengine | simpledb | And the following "courses" exist: | shortname | fullname | | F1 | Amphibians | And the following "activities" exist: | activity | name | intro | course | idnumber | | page | PageName1 frogs amphibians | PageDesc1 | F1 | PAGE1 | | forum | ForumName1 toads amphibians | ForumDesc1 | F1 | FORUM1 | And I update the global search index And I log in as "admin" @javascript Scenario: Search from header search box with one result When I search for "frogs" using the header global search box Then I should see "PageName1" And I should see "PageDesc1" # Check the link works. And I follow "PageName1" And I should see "PageName1" in the ".breadcrumb" "css_element" @javascript Scenario: Search from search page with two results When I search for "zombies" using the header global search box Then I should see "No results" And I set the field "id_q" to "amphibians" # You cannot press "Search" because there's a fieldset with the same name that gets in the way. And I press "id_submitbutton" And I should see "ForumName1" And I should see "ForumDesc1" And I should see "PageName1" And I should see "PageDesc1" # Check the link works. And I follow "ForumName1" And I should see "ForumName1" in the ".breadcrumb" "css_element" @javascript Scenario: Search from search page with quotes Given I search for "zombies" using the header global search box And I should see "No results" When I set the field "id_q" to "\"amphibians\"" # You cannot press "Search" because there's a fieldset with the same name that gets in the way. And I press "id_submitbutton" Then I should see "ForumName1" And I should see "ForumDesc1" And I should see "PageName1" And I should see "PageDesc1" # Check the link works. And I follow "ForumName1" And I should see "ForumName1" in the ".breadcrumb" "css_element" @javascript Scenario: Search starting from site context (no within option) When I search for "frogs" using the header global search box And I expand all fieldsets Then I should not see "Search within" And I should see "Courses" in the "region-main" "region" @javascript Scenario: Search starting from course context (within option lists course) When I am on "Amphibians" course homepage And I search for "frogs" using the header global search box And I expand all fieldsets Then I should see "Search within" And I select "Everywhere you can access" from the "Search within" singleselect And I should see "Courses" in the "region-main" "region" And I select "Course: Amphibians" from the "Search within" singleselect And I should not see "Courses" in the "region-main" "region" @javascript Scenario: Search starting from forum context (within option lists course and forum) Given I am on the "ForumName1 toads amphibians" "Forum activity" page When I search for "frogs" using the header global search box Then I expand all fieldsets And I should see "Search within" And I select "Everywhere you can access" from the "Search within" singleselect And I should see "Courses" in the "region-main" "region" And I select "Course: Amphibians" from the "Search within" singleselect And I should not see "Courses" in the "region-main" "region" And I select "Forum: ForumName1" from the "Search within" singleselect And I should not see "Courses" in the "region-main" "region" @javascript Scenario: Check that groups option in search form appears when intended # Switch to mocked Solr search because simpledb doesn't support groups. Given solr is installed And the following config values are set as admin: | searchengine | solr | And the following "groups" exist: | name | course | idnumber | | A Group | F1 | G1 | | B Group | F1 | G2 | And the following "activities" exist: | activity | name | intro | course | idnumber | groupmode | | forum | ForumSG | ForumDesc1 | F1 | FORUM2 | 1 | When I am on the ForumSG "Forum activity" page And global search expects the query "frogs" and will return: | type | idnumber | | activity | PAGE1 | And I search for "frogs" using the header global search box And I expand all fieldsets Then I should not see "All groups" in the "region-main" "region" And I select "Course: Amphibians" from the "Search within" singleselect And I should see "All groups" in the "region-main" "region" And I set the field "Groups" to "A Group" And I select "Forum: ForumSG" from the "Search within" singleselect And I should see "A Group" in the "region-main" "region" And I am on the "ForumName1 toads amphibians" "Forum activity" page And global search expects the query "frogs" and will return: | type | idnumber | | activity | PAGE1 | And I search for "frogs" using the header global search box And I expand all fieldsets Then I should not see "All groups" in the "region-main" "region" And I select "Course: Amphibians" from the "Search within" singleselect And I should see "All groups" in the "region-main" "region" And I select "Forum: ForumName1" from the "Search within" singleselect And I should not see "All groups" in the "region-main" "region" tests/behat/behat_search.php 0000644 00000014211 15152012435 0012107 0 ustar 00 <?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/>. /** * Behat search-related step definitions. * * @package core_search * @category test * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ // NOTE: no MOODLE_INTERNAL used, this file may be required by behat before including /config.php. require_once(__DIR__ . '/../../../lib/behat/behat_base.php'); use Behat\Gherkin\Node\TableNode as TableNode; use Moodle\BehatExtension\Exception\SkippedException; /** * Behat search-related step definitions. * * @package core_search * @category test * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_search extends behat_base { /** * Create event when starting on the front page. * * @Given /^I search for "(?P<query>[^"]*)" using the header global search box$/ * @param string $query Query to search for */ public function i_search_for_using_the_header_global_search_box($query) { // Click the search icon. $this->execute("behat_general::i_click_on", [get_string('togglesearch', 'core'), 'button']); // Set the field. $this->execute('behat_forms::i_set_the_field_to', ['q', $query]); // Submit the form. $this->execute("behat_general::i_click_on_in_the", [get_string('search', 'core'), 'button', '#usernavigation', 'css_element']); } /** * Sets results which will be returned for the next search. It will only return links to * activities at present. * * @Given /^global search expects the query "(?P<query>[^"]*)" and will return:$/ * @param string $query Expected query value (just used to check the query passed to the engine) * @param TableNode $data Data rows */ public function global_search_expects_the_query_and_will_return($query, TableNode $data) { global $DB; $outdata = new stdClass(); $outdata->query = $query; $outdata->results = []; foreach ($data->getHash() as $rowdata) { // Check and get the data from the user-entered row. $input = [ 'type' => '', 'idnumber' => '', 'title' => '', 'content' => '', 'modified' => '' ]; foreach ($rowdata as $key => $value) { if (!array_key_exists($key, $input)) { throw new Exception('Field ' . $key . '" does not exist'); } $input[$key] = $value; } foreach (['idnumber', 'type'] as $requiredfield) { if (!$input[$requiredfield]) { throw new Exception('Must specify required field: ' . $requiredfield); } } // Check type (we only support activity at present, this could be extended to allow // faking other types of search results such as a user, course, or forum post). if ($input['type'] !== 'activity') { throw new Exception('Unsupported type: ' . $input['type']); } // Find the specified activity. $idnumber = $input['idnumber']; $cmid = $DB->get_field('course_modules', 'id', ['idnumber' => $idnumber], IGNORE_MISSING); if (!$cmid) { throw new Exception('Cannot find activity with idnumber: ' . $idnumber); } list ($course, $cm) = get_course_and_cm_from_cmid($cmid); $rec = $DB->get_record($cm->modname, ['id' => $cm->instance], '*', MUST_EXIST); $context = \context_module::instance($cm->id); // Set up the internal fields used in creating the search document. $out = new stdClass(); $out->itemid = $cm->instance; $out->componentname = 'mod_' . $cm->modname; $out->areaname = 'activity'; $out->fields = new stdClass(); $out->fields->contextid = $context->id; $out->fields->courseid = $course->id; if ($input['title']) { $out->fields->title = $input['title']; } else { $out->fields->title = $cm->name; } if ($input['content']) { $out->fields->content = $input['content']; } else { $out->fields->content = content_to_text($rec->intro, $rec->introformat); } if ($input['modified']) { $out->fields->modified = strtotime($input['modified']); } else { $out->fields->modified = $cm->added; } $out->extrafields = new stdClass(); $out->extrafields->coursefullname = $course->fullname; $outdata->results[] = $out; } set_config('behat_fakeresult', json_encode($outdata), 'core_search'); } /** * Updates the global search index to take account of any added activities. * * @Given /^I update the global search index$/ * @throws moodle_exception */ public function i_update_the_global_search_index() { \core_search\manager::instance()->index(false); } /** * This step looks to see if Solr is installed or skip the rest of the scenario otherwise * * @Given /^solr is installed/ */ public function solr_is_installed() { if (!function_exists('solr_get_version')) { throw new SkippedException('Skipping this scenario because Solr is not installed.'); } } } tests/area_category_test.php 0000644 00000007567 15152012435 0012300 0 ustar 00 <?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/>. namespace core_search; /** * Area category unit tests. * * @package core_search * @copyright 2018 Dmitrii Metelkin <dmitriim@catalyst-au.net> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class area_category_test extends \advanced_testcase { /** * A helper function to get a mocked search area. * @param string $areaid * * @return \PHPUnit\Framework\MockObject\MockObject */ protected function get_mocked_area($areaid) { $builder = $this->getMockBuilder('\core_search\base'); $builder->disableOriginalConstructor(); $builder->onlyMethods(array('get_area_id')); $area = $builder->getMockForAbstractClass(); $area->method('get_area_id')->willReturn($areaid); return $area; } /** * A helper function to get a list of search areas. * * @return array */ protected function get_areas() { $areas = []; $areas[] = $this->get_mocked_area('area1'); $areas[] = 'String'; $areas[] = 1; $areas[] = '12'; $areas[] = true; $areas[] = false; $areas[] = null; $areas[] = [$this->get_mocked_area('area2')]; $areas[] = $this; $areas[] = new \stdClass(); $areas[] = $this->get_mocked_area('area3'); $areas[] = $this->get_mocked_area('area4'); return $areas; } /** * Test default values. */ public function test_default_values() { $category = new \core_search\area_category('test_name', 'test_visiblename'); $this->assertEquals('test_name', $category->get_name()); $this->assertEquals('test_visiblename', $category->get_visiblename()); $this->assertEquals(0, $category->get_order()); $this->assertEquals([], $category->get_areas()); } /** * Test that all get functions work as expected. */ public function test_getters() { $category = new \core_search\area_category('test_name', 'test_visiblename', 4, $this->get_areas()); $this->assertEquals('test_name', $category->get_name()); $this->assertEquals('test_visiblename', $category->get_visiblename()); $this->assertEquals(4, $category->get_order()); $this->assertTrue(is_array($category->get_areas())); $this->assertCount(3, $category->get_areas()); $this->assertTrue(key_exists('area1', $category->get_areas())); $this->assertTrue(key_exists('area3', $category->get_areas())); $this->assertTrue(key_exists('area4', $category->get_areas())); } /** * Test that a list of areas could be set correctly. */ public function test_list_of_areas_could_be_set() { $category = new \core_search\area_category('test_name', 'test_visiblename'); $this->assertEquals([], $category->get_areas()); $category->set_areas($this->get_areas()); $this->assertTrue(is_array($category->get_areas())); $this->assertCount(3, $category->get_areas()); $this->assertTrue(key_exists('area1', $category->get_areas())); $this->assertTrue(key_exists('area3', $category->get_areas())); $this->assertTrue(key_exists('area4', $category->get_areas())); } } tests/engine_test.php 0000644 00000012323 15152012435 0010722 0 ustar 00 <?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/>. namespace core_search; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/fixtures/testable_core_search.php'); require_once(__DIR__ . '/fixtures/mock_search_area.php'); /** * Search engine base unit tests. * * @package core_search * @category phpunit * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class engine_test extends \advanced_testcase { public function setUp(): void { $this->resetAfterTest(); set_config('enableglobalsearch', true); // Set \core_search::instance to the mock_search_engine as we don't require the search engine to be working to test this. $search = \testable_core_search::instance(); } /** * Engine basic info. * * @return void */ public function test_engine_info() { $engine = new \mock_search\engine(); $this->assertEquals('mock_search', $engine->get_plugin_name()); // Resolves to the default one. $this->assertEquals('\\core_search\\document', $engine->get_document_classname()); } /** * Test engine caches. * * @return void */ public function test_engine_caches() { global $DB; $engine = new \mock_search\engine(); $course1 = self::getDataGenerator()->create_course(); $this->assertEquals($course1->id, $engine->get_course($course1->id)->id); $dbreads = $DB->perf_get_reads(); $engine->get_course($course1->id); $this->assertEquals($dbreads, $DB->perf_get_reads()); $fakearea1 = \core_search\manager::generate_areaid('plugintype_unexisting', 'fakearea'); $fakearea2 = \core_search\manager::generate_areaid('mod_unexisting', 'morefake'); $this->assertFalse($engine->get_search_area($fakearea1)); $this->assertFalse($engine->get_search_area($fakearea2)); $this->assertFalse($engine->get_search_area($fakearea2)); $areaid = \core_search\manager::generate_areaid('mod_forum', 'post'); $this->assertInstanceOf('\\mod_forum\\search\\post', $engine->get_search_area($areaid)); $dbreads = $DB->perf_get_reads(); $this->assertInstanceOf('\\mod_forum\\search\\post', $engine->get_search_area($areaid)); $this->assertEquals($dbreads, $DB->perf_get_reads()); } /** * Tests the core functions related to schema updates. */ public function test_engine_schema_modification() { // Apply a schema update starting from no version. $engine = new \mock_search\engine(); $engine->check_latest_schema(); $updates = $engine->get_and_clear_schema_updates(); $this->assertCount(1, $updates); $this->assertEquals(0, $updates[0][0]); $this->assertEquals(\core_search\document::SCHEMA_VERSION, $updates[0][1]); // Store older version and check that. $engine->record_applied_schema_version(1066101400); $engine = new \mock_search\engine(); $engine->check_latest_schema(); $updates = $engine->get_and_clear_schema_updates(); $this->assertCount(1, $updates); $this->assertEquals(1066101400, $updates[0][0]); $this->assertEquals(\core_search\document::SCHEMA_VERSION, $updates[0][1]); // Store current version and check no updates. $engine->record_applied_schema_version(\core_search\document::SCHEMA_VERSION); $engine = new \mock_search\engine(); $engine->check_latest_schema(); $updates = $engine->get_and_clear_schema_updates(); $this->assertCount(0, $updates); } /** * Tests the get_supported_orders stub function. */ public function test_get_supported_orders() { $engine = new \mock_search\engine(); $orders = $engine->get_supported_orders(\context_system::instance()); $this->assertCount(1, $orders); $this->assertArrayHasKey('relevance', $orders); } /** * Test that search engine sets an icon before render a document. */ public function test_engine_sets_doc_icon() { $generator = self::getDataGenerator()->get_plugin_generator('core_search'); $generator->setup(); $area = new \core_mocksearch\search\mock_search_area(); $engine = new \mock_search\engine(); $record = $generator->create_record(); $docdata = $area->get_document($record)->export_for_engine(); $doc = $engine->to_document($area, $docdata); $this->assertNotNull($doc->get_doc_icon()); $generator->teardown(); } } tests/base_block_test.php 0000644 00000050007 15152012435 0011542 0 ustar 00 <?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/>. namespace core_search; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/fixtures/testable_core_search.php'); require_once(__DIR__ . '/fixtures/mock_block_area.php'); /** * Unit tests for the base_block class. * * @package core_search * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class base_block_test extends \advanced_testcase { /** * Tests getting the name out of the class name. */ public function test_get_block_name() { $area = new \block_mockblock\search\area(); $this->assertEquals('mockblock', $area->get_block_name()); } /** * Tests getting the recordset. */ public function test_get_document_recordset() { global $DB, $USER; $this->resetAfterTest(); $this->setAdminUser(); // Create course and activity module. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $coursecontext = \context_course::instance($course->id); $page = $generator->create_module('page', ['course' => $course->id]); $pagecontext = \context_module::instance($page->cmid); // Create another 2 courses (in same category and in a new category). $cat1context = \context_coursecat::instance($course->category); $course2 = $generator->create_course(); $course2context = \context_course::instance($course2->id); $cat2 = $generator->create_category(); $cat2context = \context_coursecat::instance($cat2->id); $course3 = $generator->create_course(['category' => $cat2->id]); $course3context = \context_course::instance($course3->id); // Add blocks by hacking table (because it's not a real block type). // 1. Block on course page. $configdata = base64_encode(serialize((object) ['example' => 'content'])); $instance = (object)['blockname' => 'mockblock', 'parentcontextid' => $coursecontext->id, 'showinsubcontexts' => 0, 'pagetypepattern' => 'course-view-*', 'defaultweight' => 0, 'timecreated' => 1, 'timemodified' => 1, 'configdata' => $configdata]; $block1id = $DB->insert_record('block_instances', $instance); $block1context = \context_block::instance($block1id); // 2. Block on activity page. $instance->parentcontextid = $pagecontext->id; $instance->pagetypepattern = 'mod-page-view'; $instance->timemodified = 2; $block2id = $DB->insert_record('block_instances', $instance); \context_block::instance($block2id); // 3. Block on site context. $sitecourse = get_site(); $sitecontext = \context_course::instance($sitecourse->id); $instance->parentcontextid = $sitecontext->id; $instance->pagetypepattern = 'site-index'; $instance->timemodified = 3; $block3id = $DB->insert_record('block_instances', $instance); $block3context = \context_block::instance($block3id); // 4. Block on course page but no data. $instance->parentcontextid = $coursecontext->id; $instance->pagetypepattern = 'course-view-*'; unset($instance->configdata); $instance->timemodified = 4; $block4id = $DB->insert_record('block_instances', $instance); \context_block::instance($block4id); // 5. Block on course page but not this block. $instance->blockname = 'mockotherblock'; $instance->configdata = $configdata; $instance->timemodified = 5; $block5id = $DB->insert_record('block_instances', $instance); \context_block::instance($block5id); // 6. Block on course page with '*' page type. $instance->blockname = 'mockblock'; $instance->pagetypepattern = '*'; $instance->timemodified = 6; $block6id = $DB->insert_record('block_instances', $instance); \context_block::instance($block6id); // 7. Block on course page with 'course-*' page type. $instance->pagetypepattern = 'course-*'; $instance->timemodified = 7; $block7id = $DB->insert_record('block_instances', $instance); \context_block::instance($block7id); // 8. Block on course 2. $instance->parentcontextid = $course2context->id; $instance->timemodified = 8; $block8id = $DB->insert_record('block_instances', $instance); \context_block::instance($block8id); // 9. Block on course 3. $instance->parentcontextid = $course3context->id; $instance->timemodified = 9; $block9id = $DB->insert_record('block_instances', $instance); \context_block::instance($block9id); // Get all the blocks. $area = new \block_mockblock\search\area(); $results = self::recordset_to_indexed_array($area->get_document_recordset()); // Only blocks 1, 3, 6, 7, 8, 9 should be returned. Check all the fields for the first two. $this->assertCount(6, $results); $this->assertEquals($block1id, $results[0]->id); $this->assertEquals(1, $results[0]->timemodified); $this->assertEquals(1, $results[0]->timecreated); $this->assertEquals($configdata, $results[0]->configdata); $this->assertEquals($course->id, $results[0]->courseid); $this->assertEquals($block1context->id, $results[0]->contextid); $this->assertEquals($block3id, $results[1]->id); $this->assertEquals(3, $results[1]->timemodified); $this->assertEquals(1, $results[1]->timecreated); $this->assertEquals($configdata, $results[1]->configdata); $this->assertEquals($sitecourse->id, $results[1]->courseid); $this->assertEquals($block3context->id, $results[1]->contextid); // For the later ones, just check it got the right ones! $this->assertEquals($block6id, $results[2]->id); $this->assertEquals($block7id, $results[3]->id); $this->assertEquals($block8id, $results[4]->id); $this->assertEquals($block9id, $results[5]->id); // Repeat with a time restriction. $results = self::recordset_to_indexed_array($area->get_document_recordset(2)); // Only block 3, 6, 7, 8, and 9 are returned. $this->assertEquals([$block3id, $block6id, $block7id, $block8id, $block9id], self::records_to_ids($results)); // Now use context restrictions. First, the whole site (no change). $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, \context_system::instance())); $this->assertEquals([$block1id, $block3id, $block6id, $block7id, $block8id, $block9id], self::records_to_ids($results)); // Course page only (leave out the one on site page and other courses). $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $coursecontext)); $this->assertEquals([$block1id, $block6id, $block7id], self::records_to_ids($results)); // Other course page only. $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $course2context)); $this->assertEquals([$block8id], self::records_to_ids($results)); // Activity module only (no results). $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $pagecontext)); $this->assertCount(0, $results); // Specific block context. $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $block3context)); $this->assertEquals([$block3id], self::records_to_ids($results)); // User context (no results). $usercontext = \context_user::instance($USER->id); $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $usercontext)); $this->assertCount(0, $results); // Category 1 context (courses 1 and 2). $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $cat1context)); $this->assertEquals([$block1id, $block6id, $block7id, $block8id], self::records_to_ids($results)); // Category 2 context (course 3). $results = self::recordset_to_indexed_array($area->get_document_recordset( 0, $cat2context)); $this->assertEquals([$block9id], self::records_to_ids($results)); // Combine context restriction (category 1) with timemodified. $results = self::recordset_to_indexed_array($area->get_document_recordset( 7, $cat1context)); $this->assertEquals([$block7id, $block8id], self::records_to_ids($results)); } /** * Utility function to convert recordset to array for testing. * * @param moodle_recordset $rs Recordset to convert * @return array Array indexed by number (0, 1, 2, ...) */ protected static function recordset_to_indexed_array(\moodle_recordset $rs) { $results = []; foreach ($rs as $rec) { $results[] = $rec; } $rs->close(); return $results; } /** * Utility function to convert records to array of IDs. * * @param array $recs Records which should have an 'id' field * @return array Array of ids */ protected static function records_to_ids(array $recs) { $ids = []; foreach ($recs as $rec) { $ids[] = $rec->id; } return $ids; } /** * Tests the get_doc_url function. */ public function test_get_doc_url() { global $DB; $this->resetAfterTest(); // Create course and activity module. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $coursecontext = \context_course::instance($course->id); $page = $generator->create_module('page', ['course' => $course->id]); $pagecontext = \context_module::instance($page->cmid); // Create block on course page. $configdata = base64_encode(serialize(new \stdClass())); $instance = (object)['blockname' => 'mockblock', 'parentcontextid' => $coursecontext->id, 'showinsubcontexts' => 0, 'pagetypepattern' => 'course-view-*', 'defaultweight' => 0, 'timecreated' => 1, 'timemodified' => 1, 'configdata' => $configdata]; $blockid = $DB->insert_record('block_instances', $instance); // Get document URL. $area = new \block_mockblock\search\area(); $doc = $this->get_doc($course->id, $blockid); $expected = new \moodle_url('/course/view.php', ['id' => $course->id], 'inst' . $blockid); $this->assertEquals($expected, $area->get_doc_url($doc)); $this->assertEquals($expected, $area->get_context_url($doc)); // Repeat with block on site page. $sitecourse = get_site(); $sitecontext = \context_course::instance($sitecourse->id); $instance->pagetypepattern = 'site-index'; $instance->parentcontextid = $sitecontext->id; $block2id = $DB->insert_record('block_instances', $instance); // Get document URL. $doc2 = $this->get_doc($course->id, $block2id); $expected = new \moodle_url('/', ['redirect' => 0], 'inst' . $block2id); $this->assertEquals($expected, $area->get_doc_url($doc2)); $this->assertEquals($expected, $area->get_context_url($doc2)); // Repeat with block on module page (this cannot happen yet because the search query will // only include course context blocks, but let's check it works for the future). $instance->pagetypepattern = 'mod-page-view'; $instance->parentcontextid = $pagecontext->id; $block3id = $DB->insert_record('block_instances', $instance); // Get and check document URL, ignoring debugging message for unsupported page type. $debugmessage = 'Unexpected module-level page type for block ' . $block3id . ': mod-page-view'; $doc3 = $this->get_doc($course->id, $block3id); $this->assertDebuggingCalledCount(2, [$debugmessage, $debugmessage]); $expected = new \moodle_url('/mod/page/view.php', ['id' => $page->cmid], 'inst' . $block3id); $this->assertEquals($expected, $area->get_doc_url($doc3)); $this->assertDebuggingCalled($debugmessage); $this->assertEquals($expected, $area->get_context_url($doc3)); $this->assertDebuggingCalled($debugmessage); // Repeat with another block on course page but '*' pages. $instance->pagetypepattern = '*'; $instance->parentcontextid = $coursecontext->id; $block4id = $DB->insert_record('block_instances', $instance); // Get document URL. $doc = $this->get_doc($course->id, $block4id); $expected = new \moodle_url('/course/view.php', ['id' => $course->id], 'inst' . $block4id); $this->assertEquals($expected, $area->get_doc_url($doc)); $this->assertEquals($expected, $area->get_context_url($doc)); // And same thing but 'course-*' pages. $instance->pagetypepattern = 'course-*'; $block5id = $DB->insert_record('block_instances', $instance); // Get document URL. $doc = $this->get_doc($course->id, $block5id); $expected = new \moodle_url('/course/view.php', ['id' => $course->id], 'inst' . $block5id); $this->assertEquals($expected, $area->get_doc_url($doc)); $this->assertEquals($expected, $area->get_context_url($doc)); } /** * Tests the check_access function. */ public function test_check_access() { global $DB; $this->resetAfterTest(); // Create course and activity module. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $coursecontext = \context_course::instance($course->id); $page = $generator->create_module('page', ['course' => $course->id]); $pagecontext = \context_module::instance($page->cmid); // Create block on course page. $configdata = base64_encode(serialize(new \stdClass())); $instance = (object)['blockname' => 'mockblock', 'parentcontextid' => $coursecontext->id, 'showinsubcontexts' => 0, 'pagetypepattern' => 'course-view-*', 'defaultweight' => 0, 'timecreated' => 1, 'timemodified' => 1, 'configdata' => $configdata]; $blockid = $DB->insert_record('block_instances', $instance); // Check access for block that exists. $area = new \block_mockblock\search\area(); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $area->check_access($blockid)); // Check access for nonexistent block. $this->assertEquals(\core_search\manager::ACCESS_DELETED, $area->check_access($blockid + 1)); // Check if block is not in a course context any longer. $DB->set_field('block_instances', 'parentcontextid', $pagecontext->id, ['id' => $blockid]); \core_search\base_block::clear_static(); $this->assertEquals(\core_search\manager::ACCESS_DELETED, $area->check_access($blockid)); // Or what if it is in a course context but has supported vs. unsupported page type. $DB->set_field('block_instances', 'parentcontextid', $coursecontext->id, ['id' => $blockid]); $DB->set_field('block_instances', 'pagetypepattern', 'course-*', ['id' => $blockid]); \core_search\base_block::clear_static(); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $area->check_access($blockid)); $DB->set_field('block_instances', 'pagetypepattern', '*', ['id' => $blockid]); \core_search\base_block::clear_static(); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $area->check_access($blockid)); $DB->set_field('block_instances', 'pagetypepattern', 'course-view-frogs', ['id' => $blockid]); \core_search\base_block::clear_static(); $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $area->check_access($blockid)); $DB->set_field('block_instances', 'pagetypepattern', 'anythingelse', ['id' => $blockid]); \core_search\base_block::clear_static(); $this->assertEquals(\core_search\manager::ACCESS_DELETED, $area->check_access($blockid)); } /** * Tests the block version of get_contexts_to_reindex, which is supposed to return all the * block contexts in order of date added. */ public function test_get_contexts_to_reindex() { global $DB; $this->resetAfterTest(); // Create course and activity module. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $coursecontext = \context_course::instance($course->id); $page = $generator->create_module('page', ['course' => $course->id]); $pagecontext = \context_module::instance($page->cmid); // Create blocks on course page, with time modified non-sequential. $configdata = base64_encode(serialize(new \stdClass())); $instance = (object)['blockname' => 'mockblock', 'parentcontextid' => $coursecontext->id, 'showinsubcontexts' => 0, 'pagetypepattern' => 'course-view-*', 'defaultweight' => 0, 'timecreated' => 1, 'timemodified' => 100, 'configdata' => $configdata]; $blockid1 = $DB->insert_record('block_instances', $instance); $context1 = \context_block::instance($blockid1); $instance->timemodified = 120; $blockid2 = $DB->insert_record('block_instances', $instance); $context2 = \context_block::instance($blockid2); $instance->timemodified = 110; $blockid3 = $DB->insert_record('block_instances', $instance); $context3 = \context_block::instance($blockid3); // Create another block on the activity page (not included). $instance->parentcontextid = $pagecontext->id; $blockid4 = $DB->insert_record('block_instances', $instance); \context_block::instance($blockid4); // Check list of contexts. $area = new \block_mockblock\search\area(); $contexts = iterator_to_array($area->get_contexts_to_reindex(), false); $expected = [ $context2, $context3, $context1 ]; $this->assertEquals($expected, $contexts); } /** * Gets a search document object from the fake search area. * * @param int $courseid Course id in document * @param int $blockinstanceid Block instance id in document * @return \core_search\document Document object */ protected function get_doc($courseid, $blockinstanceid) { $engine = \testable_core_search::instance()->get_engine(); $area = new \block_mockblock\search\area(); $docdata = ['id' => $blockinstanceid, 'courseid' => $courseid, 'areaid' => $area->get_area_id(), 'itemid' => 0]; return $engine->to_document($area, $docdata); } /** * Test document icon. */ public function test_get_doc_icon() { $baseblock = $this->getMockBuilder('\core_search\base_block') ->disableOriginalConstructor() ->getMockForAbstractClass(); $document = $this->getMockBuilder('\core_search\document') ->disableOriginalConstructor() ->getMock(); $result = $baseblock->get_doc_icon($document); $this->assertEquals('e/anchor', $result->get_name()); $this->assertEquals('moodle', $result->get_component()); } } templates/form-user-selector-suggestion.mustache 0000644 00000002474 15152012435 0016224 0 ustar 00 {{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see <http://www.gnu.org/licenses/>. }} {{! @template core_search/form-user-selector-suggestion Moodle template for the list of valid options in an autocomplate form element. Classes required for JS: * none Data attributes required for JS: * none Context variables required for this template: * profileimageurlsmall Url to a small profile image. * fullname Users full name Example context (json): { "id": "1", "fullname": "Kermit Thefrog", "profileimageurlsmall": "https://example.org/img" } }} <span> <img height="12" src="{{profileimageurlsmall}}" alt="" role="presentation"> <span>{{fullname}}</span> </span> templates/result.mustache 0000644 00000006047 15152012435 0011620 0 ustar 00 {{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see <http://www.gnu.org/licenses/>. }} {{! @template core_search/result Template which shows a search result. Classes required for JS: * none Data attributes required for JS: * none Context variables required for this template: * courseurl * coursefullname * title * docurl * contexturl Optional context variables for this template: * content * userurl * userfullname * description1 * description2 * filename * multiplefiles * filenames Example context (json): { "courseurl": "https://example.com/course/view.php?id=2", "coursefullname": "Example course", "title": "Example title", "docurl": "https://example.com/mod/example/view.php?id=4", "content": "I am content", "contexturl": "https://example.com/mod/example/view.php?id=2", "userurl": "https://example.com/user/profile.php?id=3", "userfullname": "Example User Full Name", "multiplefiles": true, "filenames": [ "file1.txt", "file2.txt" ] } }} <div class="result"> <h4 class="result-title"> {{#icon}}<img class="icon" alt="" src="{{{icon}}}">{{/icon}}<a href="{{{docurl}}}">{{{title}}}</a> </h4> {{#content}} <div class="result-content">{{{content}}}</div> {{/content}} {{#description1}} <div class="result-content">{{{description1}}}</div> {{/description1}} {{#description2}} <div class="result-content">{{{description2}}}</div> {{/description2}} {{#filename}} <div class="result-content-filename"> {{#str}}matchingfile, search, {{{filename}}}{{/str}} </div> {{/filename}} {{#multiplefiles}} <div class="result-content-filenames"> {{#str}}matchingfiles, search{{/str}}<br> <ul class="list"> {{#filenames}} <li><span class="filename">{{.}}</span></li> {{/filenames}} </ul> </div> {{/multiplefiles}} <div class="result-context-info"> <a href="{{{contexturl}}}">{{#str}}viewresultincontext, search{{/str}}</a> - <a href="{{{courseurl}}}">{{#str}}incourse, search, {{{coursefullname}}}{{/str}}</a> {{#userfullname}} - <a href="{{{userurl}}}">{{#str}}byname, moodle, {{{userfullname}}}{{/str}}</a> {{/userfullname}} </div> </div> templates/index_requests.mustache 0000644 00000007030 15152012435 0013335 0 ustar 00 {{! This file is part of Moodle - http://moodle.org/ Moodle is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Moodle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Moodle. If not, see <http://www.gnu.org/licenses/>. }} {{! @template core_search/index_requests Template to provide admin information about the queue of index requests. Classes required for JS: * none Data attributes required for JS: * none Context variables required for this template: * topten * count Optional context variables for this template: * ellipsis Example context (json): { "topten": [ { "id": 42, "timerequested": 123456789, "contextid": 123, "contextlink": "<a href='...'>Forum: Tutor group forum</a>", "searcharea": "mod_forum-activity", "areaname": "Forum activities", "partialarea": "mod_forum-activity", "partialareaname": "Forum activities", "partialtime": 123400000, "indexpriority": 100 } ], "total": 1, "ellipsis": true } }} {{#total}} <div> <h3> {{#str}} queueheading, search, {{total}} {{/str}} </h3> <table class="generaltable"> <thead> <tr> <th scope="col">{{#str}} context, role {{/str}}</th> <th scope="col">{{#str}} searcharea, search {{/str}}</th> <th scope="col">{{#str}} time {{/str}}</th> <th scope="col">{{#str}} progress, search {{/str}}</th> <th scope="col">{{#str}} priority, search {{/str}}</th> </tr> </thead> <tbody> {{#topten}} <tr> <td> {{{contextlink}}} </td> <td> {{#searcharea}} {{areaname}} {{/searcharea}} </td> <td>{{#userdate}} {{timerequested}}, {{#str}} strftimedatetimeshort, core_langconfig {{/str}} {{/userdate}}</td> <td> {{#partialarea}} {{partialareaname}}: {{/partialarea}} {{#partialtime}} {{#userdate}} {{partialtime}}, {{#str}} strftimedatetimeshort, core_langconfig {{/str}} {{/userdate}} {{/partialtime}} </td> <td> {{#priorityname}} {{priorityname}} {{/priorityname}} {{^priorityname}} {{indexpriority}} {{/priorityname}} </td> </tr> {{/topten}} {{#ellipsis}} <tr> <td colspan="5">...</td> </tr> {{/ellipsis}} </tbody> </table> </div> {{/total}} engine/simpledb/db/uninstall.php 0000644 00000003617 15152012435 0012724 0 ustar 00 <?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/>. /** * Uninstall code. * * @package search_simpledb * @copyright 2016 Dan Poltawski <dan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die; /** * Plugin uninstall code. * * @package search_simpledb * @copyright 2016 Dan Poltawski <dan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ function xmldb_search_simpledb_uninstall() { global $DB; switch ($DB->get_dbfamily()) { case 'postgres': $DB->execute("DROP INDEX {search_simpledb_title}"); $DB->execute("DROP INDEX {search_simpledb_content}"); $DB->execute("DROP INDEX {search_simpledb_description1}"); $DB->execute("DROP INDEX {search_simpledb_description2}"); break; case 'mysql': if ($DB->is_fulltext_search_supported()) { $DB->execute("ALTER TABLE {search_simpledb_index} DROP INDEX {search_simpledb_index_index}"); } break; case 'mssql': if ($DB->is_fulltext_search_supported()) { $DB->execute("DROP FULLTEXT CATALOG {search_simpledb_catalog}"); } break; } } engine/simpledb/db/upgrade.php 0000644 00000004646 15152012435 0012345 0 ustar 00 <?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/>. /** * Calculated question type upgrade code. * * @package search_simpledb * @copyright 2022 Renaud Lemaire {@link http://www.cblue.be} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * Upgrade code for the simpledb search engine. * @param int $oldversion the version we are upgrading from. */ function xmldb_search_simpledb_upgrade($oldversion = 0) { global $DB; $dbman = $DB->get_manager(); if ($oldversion < 2022050400) { $table = new xmldb_table('search_simpledb_index'); // Define index areaid (not unique) to be added to search_simpledb_index. $index = new xmldb_index('contextid', XMLDB_INDEX_NOTUNIQUE, ['contextid']); // Conditionally launch add index contextid. if (!$dbman->index_exists($table, $index)) { $dbman->add_index($table, $index); } // Define index courseid (not unique) to be added to search_simpledb_index. $index = new xmldb_index('courseid', XMLDB_INDEX_NOTUNIQUE, ['courseid']); // Conditionally launch add index courseid. if (!$dbman->index_exists($table, $index)) { $dbman->add_index($table, $index); } // Define index areaid (not unique) to be added to search_simpledb_index. $index = new xmldb_index('areaid', XMLDB_INDEX_NOTUNIQUE, ['areaid']); // Conditionally launch add index areaid. if (!$dbman->index_exists($table, $index)) { $dbman->add_index($table, $index); } // Simpledb savepoint reached. upgrade_plugin_savepoint(true, 2022050400, 'search', 'simpledb'); } // Automatically generated Moodle v4.1.0 release upgrade line. // Put any upgrade step following this. return true; } engine/simpledb/db/install.php 0000644 00000006416 15152012435 0012361 0 ustar 00 <?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/>. /** * Post installation and migration code. * * @package search_simpledb * @copyright 2016 Dan Poltawski <dan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die; /** * Post installation code. * * @package search_simpledb * @copyright 2016 Dan Poltawski <dan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ function xmldb_search_simpledb_install() { global $DB; switch ($DB->get_dbfamily()) { case 'postgres': // There are a few other ways of doing this which avoid the need for individual indexes. $DB->execute("CREATE INDEX {search_simpledb_title} ON {search_simpledb_index} " . "USING gin(to_tsvector('simple', title))"); $DB->execute("CREATE INDEX {search_simpledb_content} ON {search_simpledb_index} " . "USING gin(to_tsvector('simple', content))"); $DB->execute("CREATE INDEX {search_simpledb_description1} ON {search_simpledb_index} " . "USING gin(to_tsvector('simple', description1))"); $DB->execute("CREATE INDEX {search_simpledb_description2} ON {search_simpledb_index} " . "USING gin(to_tsvector('simple', description2))"); break; case 'mysql': if ($DB->is_fulltext_search_supported()) { $DB->execute("CREATE FULLTEXT INDEX {search_simpledb_index_index} ON {search_simpledb_index} (title, content, description1, description2)"); } break; case 'mssql': if ($DB->is_fulltext_search_supported()) { $catalogname = $DB->get_prefix() . 'search_simpledb_catalog'; if (!$DB->record_exists_sql('SELECT * FROM sys.fulltext_catalogs WHERE name = ?', array($catalogname))) { $DB->execute("CREATE FULLTEXT CATALOG {search_simpledb_catalog} WITH ACCENT_SENSITIVITY=OFF"); } if (defined('PHPUNIT_UTIL') and PHPUNIT_UTIL) { // We want manual tracking for phpunit because the fulltext index does get auto populated fast enough. $changetracking = 'MANUAL'; } else { $changetracking = 'AUTO'; } $DB->execute("CREATE FULLTEXT INDEX ON {search_simpledb_index} (title, content, description1, description2) KEY INDEX {searsimpinde_id_pk} ON {search_simpledb_catalog} WITH CHANGE_TRACKING $changetracking"); } break; } } engine/simpledb/db/install.xml 0000644 00000004171 15152012435 0012366 0 ustar 00 <?xml version="1.0" encoding="UTF-8" ?> <XMLDB PATH="search/engine/simpledb/db" VERSION="20220504" COMMENT="XMLDB file for Moodle search/engine/simpledb" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../../lib/xmldb/xmldb.xsd" > <TABLES> <TABLE NAME="search_simpledb_index" COMMENT="search_simpledb table containing the index data."> <FIELDS> <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/> <FIELD NAME="docid" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/> <FIELD NAME="itemid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/> <FIELD NAME="title" TYPE="text" NOTNULL="false" SEQUENCE="false"/> <FIELD NAME="content" TYPE="text" NOTNULL="false" SEQUENCE="false"/> <FIELD NAME="contextid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/> <FIELD NAME="areaid" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/> <FIELD NAME="type" TYPE="int" LENGTH="1" NOTNULL="true" SEQUENCE="false"/> <FIELD NAME="courseid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/> <FIELD NAME="owneruserid" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/> <FIELD NAME="modified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/> <FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/> <FIELD NAME="description1" TYPE="text" NOTNULL="false" SEQUENCE="false"/> <FIELD NAME="description2" TYPE="text" NOTNULL="false" SEQUENCE="false"/> </FIELDS> <KEYS> <KEY NAME="primary" TYPE="primary" FIELDS="id"/> <KEY NAME="docid" TYPE="unique" FIELDS="docid"/> </KEYS> <INDEXES> <INDEX NAME="owneruserid-contextid" UNIQUE="false" FIELDS="owneruserid, contextid" COMMENT="Query filters if no extra search filters are applied"/> <INDEX NAME="contextid" UNIQUE="false" FIELDS="contextid"/> <INDEX NAME="courseid" UNIQUE="false" FIELDS="courseid"/> <INDEX NAME="areaid" UNIQUE="false" FIELDS="areaid"/> </INDEXES> </TABLE> </TABLES> </XMLDB> engine/simpledb/tests/privacy/provider_test.php 0000644 00000030135 15152012435 0016031 0 ustar 00 <?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 privacy. * * @package search_simpledb * @copyright 2018 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace search_simpledb\privacy; use search_simpledb\privacy\provider; use core_privacy\local\request\transform; use core_privacy\local\request\writer; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php'); require_once($CFG->dirroot . '/search/tests/fixtures/mock_search_area.php'); /** * Unit tests for privacy. * * @package search_simpledb * @copyright 2018 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider_test extends \core_privacy\tests\provider_testcase { public function setUp(): void { global $DB; if ($this->requires_manual_index_update()) { // We need to update fulltext index manually, which requires an alter table statement. $this->preventResetByRollback(); } $this->resetAfterTest(); set_config('enableglobalsearch', true); // Inject search_simpledb engine into the testable core search as we need to add the mock // search component to it. $this->engine = new \search_simpledb\engine(); $this->search = \testable_core_search::instance($this->engine); $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $this->search->add_search_area($areaid, new \core_mocksearch\search\mock_search_area()); $this->generator = self::getDataGenerator()->get_plugin_generator('core_search'); $this->generator->setup(); $this->c1 = $this->getDataGenerator()->create_course(); $this->c2 = $this->getDataGenerator()->create_course(); $this->c1context = \context_course::instance($this->c1->id); $this->c2context = \context_course::instance($this->c2->id); $this->u1 = $this->getDataGenerator()->create_user(); $this->u2 = $this->getDataGenerator()->create_user(); $this->getDataGenerator()->enrol_user($this->u1->id, $this->c1->id, 'student'); $this->getDataGenerator()->enrol_user($this->u1->id, $this->c2->id, 'student'); $this->getDataGenerator()->enrol_user($this->u2->id, $this->c1->id, 'student'); $this->getDataGenerator()->enrol_user($this->u2->id, $this->c2->id, 'student'); $record = (object)[ 'userid' => $this->u1->id, 'contextid' => $this->c1context->id, 'title' => 'vi', 'content' => 'va', 'description1' => 'san', 'description2' => 'jose' ]; $this->generator->create_record($record); $this->generator->create_record((object)['userid' => $this->u1->id, 'contextid' => $this->c2context->id]); $this->generator->create_record((object)['userid' => $this->u2->id, 'contextid' => $this->c2context->id]); $this->generator->create_record((object)['userid' => $this->u2->id, 'contextid' => $this->c1context->id]); $this->generator->create_record((object)['owneruserid' => $this->u1->id, 'contextid' => $this->c1context->id]); $this->generator->create_record((object)['owneruserid' => $this->u1->id, 'contextid' => $this->c2context->id]); $this->generator->create_record((object)['owneruserid' => $this->u2->id, 'contextid' => $this->c1context->id]); $this->generator->create_record((object)['owneruserid' => $this->u2->id, 'contextid' => $this->c2context->id]); $this->search->index(); $this->setAdminUser(); } /** * tearDown * * @return void */ public function tearDown(): void { // Call parent tearDown() first. parent::tearDown(); // For unit tests before PHP 7, teardown is called even on skip. So only do our teardown if we did setup. if ($this->generator) { // Moodle DML freaks out if we don't teardown the temp table after each run. $this->generator->teardown(); $this->generator = null; } } /** * Test fetching contexts for a given user ID. */ public function test_get_contexts_for_userid() { // Ensure both contexts are found for both users. $expected = [$this->c1context->id, $this->c2context->id]; sort($expected); // User 1. $contextlist = provider::get_contexts_for_userid($this->u1->id); $this->assertCount(2, $contextlist); $actual = $contextlist->get_contextids(); sort($actual); $this->assertEquals($expected, $actual); // User 2. $contextlist = provider::get_contexts_for_userid($this->u2->id); $this->assertCount(2, $contextlist); $actual = $contextlist->get_contextids(); sort($actual); $this->assertEquals($expected, $actual); } /** * Test fetching user IDs for a given context. */ public function test_get_users_in_context() { $component = 'search_simpledb'; // Ensure both users are found for both contexts. $expected = [$this->u1->id, $this->u2->id]; sort($expected); // User 1. $userlist = new \core_privacy\local\request\userlist($this->c1context, $component); provider::get_users_in_context($userlist); $this->assertCount(2, $userlist); $actual = $userlist->get_userids(); sort($actual); $this->assertEquals($expected, $actual); // User 2. $userlist = new \core_privacy\local\request\userlist($this->c2context, $component); provider::get_users_in_context($userlist); $this->assertCount(2, $userlist); $actual = $userlist->get_userids(); sort($actual); $this->assertEquals($expected, $actual); } /** * Test export user data. * * @return null */ public function test_export_user_data() { global $DB; $contextlist = new \core_privacy\local\request\approved_contextlist($this->u1, 'search_simpledb', [$this->c1context->id]); provider::export_user_data($contextlist); $writer = \core_privacy\local\request\writer::with_context($this->c1context); $this->assertTrue($writer->has_any_data()); $u1c1 = $DB->get_record('search_simpledb_index', ['userid' => $this->u1->id, 'contextid' => $this->c1context->id]); $data = $writer->get_data([get_string('search', 'search'), $u1c1->docid]); $this->assertEquals($this->c1context->get_context_name(true, true), $data->context); $this->assertEquals('vi', $data->title); $this->assertEquals('va', $data->content); $this->assertEquals('san', $data->description1); $this->assertEquals('jose', $data->description2); } /** * Test delete search for context. * * @return null */ public function test_delete_data_for_all_users() { global $DB; $this->assertEquals(8, $DB->count_records('search_simpledb_index')); provider::delete_data_for_all_users_in_context($this->c1context); $this->assertEquals(0, $DB->count_records('search_simpledb_index', ['contextid' => $this->c1context->id])); $this->assertEquals(4, $DB->count_records('search_simpledb_index')); $u2context = \context_user::instance($this->u2->id); provider::delete_data_for_all_users_in_context($u2context); $this->assertEquals(0, $DB->count_records('search_simpledb_index', ['contextid' => $u2context->id])); $this->assertEquals(2, $DB->count_records('search_simpledb_index')); } /** * Test delete search for user. * * @return null */ public function test_delete_data_for_user() { global $DB; $contextlist = new \core_privacy\local\request\approved_contextlist($this->u1, 'search_simpledb', [$this->c1context->id]); provider::delete_data_for_user($contextlist); $select = 'contextid = :contextid AND (owneruserid = :owneruserid OR userid = :userid)'; $params = ['contextid' => $this->c1context->id, 'owneruserid' => $this->u1->id, 'userid' => $this->u1->id]; $this->assertEquals(0, $DB->count_records_select('search_simpledb_index', $select, $params)); $this->assertEquals(2, $DB->count_records('search_simpledb_index', ['contextid' => $this->c1context->id])); $this->assertEquals(6, $DB->count_records('search_simpledb_index')); $contextlist = new \core_privacy\local\request\approved_contextlist($this->u2, 'search_simpledb', [$this->c2context->id]); provider::delete_data_for_user($contextlist); $select = 'contextid = :contextid AND (owneruserid = :owneruserid OR userid = :userid)'; $params = ['contextid' => $this->c2context->id, 'owneruserid' => $this->u2->id, 'userid' => $this->u2->id]; $this->assertEquals(0, $DB->count_records_select('search_simpledb_index', $select, $params)); $this->assertEquals(2, $DB->count_records('search_simpledb_index', ['contextid' => $this->c2context->id])); $this->assertEquals(4, $DB->count_records('search_simpledb_index')); } /** * Test deleting data for an approved userlist. */ public function test_delete_data_for_users() { global $DB; $component = 'search_simpledb'; $select = 'contextid = :contextid AND (owneruserid = :owneruserid OR userid = :userid)'; // Ensure expected amount of data for both users exists in each context. $this->assertEquals(4, $DB->count_records('search_simpledb_index', ['contextid' => $this->c1context->id])); $this->assertEquals(4, $DB->count_records('search_simpledb_index', ['contextid' => $this->c2context->id])); // Delete user 1's data in context 1. $approveduserids = [$this->u1->id]; $approvedlist = new \core_privacy\local\request\approved_userlist($this->c1context, $component, $approveduserids); provider::delete_data_for_users($approvedlist); $params = ['contextid' => $this->c1context->id, 'owneruserid' => $this->u1->id, 'userid' => $this->u1->id]; $this->assertEquals(0, $DB->count_records_select('search_simpledb_index', $select, $params)); // Ensure user 2's data in context 1 is retained. $params = ['contextid' => $this->c1context->id, 'owneruserid' => $this->u2->id, 'userid' => $this->u2->id]; $this->assertEquals(2, $DB->count_records_select('search_simpledb_index', $select, $params)); // Ensure both users' data in context 2 is retained. $params = ['contextid' => $this->c2context->id, 'owneruserid' => $this->u1->id, 'userid' => $this->u1->id]; $this->assertEquals(2, $DB->count_records_select('search_simpledb_index', $select, $params)); $params = ['contextid' => $this->c2context->id, 'owneruserid' => $this->u2->id, 'userid' => $this->u2->id]; $this->assertEquals(2, $DB->count_records_select('search_simpledb_index', $select, $params)); } /** * Mssql with fulltext support requires manual updates. * * @return bool */ private function requires_manual_index_update() { global $DB; return ($DB->get_dbfamily() === 'mssql' && $DB->is_fulltext_search_supported()); } } engine/simpledb/tests/engine_test.php 0000644 00000034731 15152012435 0013775 0 ustar 00 <?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/>. namespace search_simpledb; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php'); require_once($CFG->dirroot . '/search/tests/fixtures/mock_search_area.php'); /** * Simple search engine base unit tests. * * @package search_simpledb * @category test * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class engine_test extends \advanced_testcase { /** * @var \core_search::manager */ protected $search = null; /** * @var \ */ protected $engine = null; /** * @var core_search_generator */ protected $generator = null; /** * Initial stuff. * * @return void */ public function setUp(): void { $this->resetAfterTest(); if ($this->requires_manual_index_update()) { // We need to update fulltext index manually, which requires an alter table statement. $this->preventResetByRollback(); } set_config('enableglobalsearch', true); // Inject search_simpledb engine into the testable core search as we need to add the mock // search component to it. $this->engine = new \search_simpledb\engine(); $this->search = \testable_core_search::instance($this->engine); $this->generator = self::getDataGenerator()->get_plugin_generator('core_search'); $this->generator->setup(); $this->setAdminUser(); } /** * tearDown * * @return void */ public function tearDown(): void { // For unit tests before PHP 7, teardown is called even on skip. So only do our teardown if we did setup. if ($this->generator) { // Moodle DML freaks out if we don't teardown the temp table after each run. $this->generator->teardown(); $this->generator = null; } } /** * Test indexing process. * * @return void */ public function test_index() { global $DB; $this->add_mock_search_area(); $record = new \stdClass(); $record->timemodified = time() - 1; $this->generator->create_record($record); // Data gets into the search engine. $this->assertTrue($this->search->index()); // Not anymore as everything was already added. sleep(1); $this->assertFalse($this->search->index()); $this->generator->create_record(); // Indexing again once there is new data. $this->assertTrue($this->search->index()); } /** * Test search filters. * * @return void */ public function test_search() { global $USER, $DB; $this->add_mock_search_area(); $this->generator->create_record(); $record = new \stdClass(); $record->title = "Special title"; $this->generator->create_record($record); $this->search->index(); $this->update_index(); $querydata = new \stdClass(); $querydata->q = 'message'; $results = $this->search->search($querydata); $this->assertCount(2, $results); // Based on core_mocksearch\search\indexer. $this->assertEquals($USER->id, $results[0]->get('userid')); $this->assertEquals(\context_course::instance(SITEID)->id, $results[0]->get('contextid')); // Do a test to make sure we aren't searching non-query fields, like areaid. $querydata->q = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $this->assertCount(0, $this->search->search($querydata)); $querydata->q = 'message'; sleep(1); $beforeadding = time(); sleep(1); $this->generator->create_record(); $this->search->index(); $this->update_index(); // Timestart. $querydata->timestart = $beforeadding; $this->assertCount(1, $this->search->search($querydata)); // Timeend. unset($querydata->timestart); $querydata->timeend = $beforeadding; $this->assertCount(2, $this->search->search($querydata)); // Title. unset($querydata->timeend); $querydata->title = 'Special title'; $this->assertCount(1, $this->search->search($querydata)); // Course IDs. unset($querydata->title); $querydata->courseids = array(SITEID + 1); $this->assertCount(0, $this->search->search($querydata)); $querydata->courseids = array(SITEID); $this->assertCount(3, $this->search->search($querydata)); // Now try some area-id combinations. unset($querydata->courseids); $forumpostareaid = \core_search\manager::generate_areaid('mod_forum', 'post'); $mockareaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $querydata->areaids = array($forumpostareaid); $this->assertCount(0, $this->search->search($querydata)); $querydata->areaids = array($forumpostareaid, $mockareaid); $this->assertCount(3, $this->search->search($querydata)); $querydata->areaids = array($mockareaid); $this->assertCount(3, $this->search->search($querydata)); $querydata->areaids = array(); $this->assertCount(3, $this->search->search($querydata)); // Check that index contents get updated. $this->generator->delete_all(); $this->search->index(true); $this->update_index(); unset($querydata->title); $querydata->q = ''; $this->assertCount(0, $this->search->search($querydata)); } /** * Test delete function * * @return void */ public function test_delete() { $this->add_mock_search_area(); $this->generator->create_record(); $this->generator->create_record(); $this->search->index(); $this->update_index(); $querydata = new \stdClass(); $querydata->q = 'message'; $this->assertCount(2, $this->search->search($querydata)); $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $this->search->delete_index($areaid); $this->update_index(); $this->assertCount(0, $this->search->search($querydata)); } /** * Test user is allowed. * * @return void */ public function test_alloweduserid() { $this->add_mock_search_area(); $area = new \core_mocksearch\search\mock_search_area(); $record = $this->generator->create_record(); // Get the doc and insert the default doc. $doc = $area->get_document($record); $this->engine->add_document($doc); $users = array(); $users[] = $this->getDataGenerator()->create_user(); $users[] = $this->getDataGenerator()->create_user(); $users[] = $this->getDataGenerator()->create_user(); // Add a record that only user 100 can see. $originalid = $doc->get('id'); // Now add a custom doc for each user. foreach ($users as $user) { $doc = $area->get_document($record); $doc->set('id', $originalid.'-'.$user->id); $doc->set('owneruserid', $user->id); $this->engine->add_document($doc); } $this->update_index(); $this->engine->area_index_complete($area->get_area_id()); $querydata = new \stdClass(); $querydata->q = 'message'; $querydata->title = $doc->get('title'); // We are going to go through each user and see if they get the original and the owned doc. foreach ($users as $user) { $this->setUser($user); $results = $this->search->search($querydata); $this->assertCount(2, $results); $owned = 0; $notowned = 0; // We don't know what order we will get the results in, so we are doing this. foreach ($results as $result) { $owneruserid = $result->get('owneruserid'); if (empty($owneruserid)) { $notowned++; $this->assertEquals(0, $owneruserid); $this->assertEquals($originalid, $result->get('id')); } else { $owned++; $this->assertEquals($user->id, $owneruserid); $this->assertEquals($originalid.'-'.$user->id, $result->get('id')); } } $this->assertEquals(1, $owned); $this->assertEquals(1, $notowned); } // Now test a user with no owned results. $otheruser = $this->getDataGenerator()->create_user(); $this->setUser($otheruser); $results = $this->search->search($querydata); $this->assertCount(1, $results); $this->assertEquals(0, $results[0]->get('owneruserid')); $this->assertEquals($originalid, $results[0]->get('id')); } public function test_delete_by_id() { $this->add_mock_search_area(); $this->generator->create_record(); $this->generator->create_record(); $this->search->index(); $this->update_index(); $querydata = new \stdClass(); // Then search to make sure they are there. $querydata->q = 'message'; $results = $this->search->search($querydata); $this->assertCount(2, $results); $first = reset($results); $deleteid = $first->get('id'); $this->engine->delete_by_id($deleteid); $this->update_index(); // Check that we don't get a result for it anymore. $results = $this->search->search($querydata); $this->assertCount(1, $results); $result = reset($results); $this->assertNotEquals($deleteid, $result->get('id')); } /** * Tries out deleting data for a context or a course. * * @throws moodle_exception */ public function test_deleted_contexts_and_courses() { // Create some courses and activities. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(['fullname' => 'C1', 'summary' => 'xyzzy']); $course1page1 = $generator->create_module('page', ['course' => $course1, 'name' => 'C1P1', 'content' => 'xyzzy']); $generator->create_module('page', ['course' => $course1, 'name' => 'C1P2', 'content' => 'xyzzy']); $course2 = $generator->create_course(['fullname' => 'C2', 'summary' => 'xyzzy']); $course2page = $generator->create_module('page', ['course' => $course2, 'name' => 'C2P', 'content' => 'xyzzy']); $course2pagecontext = \context_module::instance($course2page->cmid); $this->search->index(); // By default we have all data in the index. $this->assert_raw_index_contents('xyzzy', ['C1', 'C1P1', 'C1P2', 'C2', 'C2P']); // Say we delete the course2pagecontext... $this->engine->delete_index_for_context($course2pagecontext->id); $this->assert_raw_index_contents('xyzzy', ['C1', 'C1P1', 'C1P2', 'C2']); // Now delete the second course... $this->engine->delete_index_for_course($course2->id); $this->assert_raw_index_contents('xyzzy', ['C1', 'C1P1', 'C1P2']); // Finally let's delete using Moodle functions to check that works. Single context first. course_delete_module($course1page1->cmid); $this->assert_raw_index_contents('xyzzy', ['C1', 'C1P2']); delete_course($course1, false); $this->assert_raw_index_contents('xyzzy', []); } /** * Check the contents of the index. * * @param string $searchword Word to match within the content field * @param string[] $expected Array of expected result titles, in alphabetical order * @throws dml_exception */ protected function assert_raw_index_contents(string $searchword, array $expected) { global $DB; $results = $DB->get_records_select('search_simpledb_index', $DB->sql_like('content', '?'), ['%' . $searchword . '%'], "id, {$DB->sql_order_by_text('title')}"); $titles = array_map(function($x) { return $x->title; }, $results); sort($titles); $this->assertEquals($expected, $titles); } /** * Adds a mock search area to the search system. */ protected function add_mock_search_area() { $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $this->search->add_search_area($areaid, new \core_mocksearch\search\mock_search_area()); } /** * Updates mssql fulltext index if necessary. * * @return bool */ private function update_index() { global $DB; if (!$this->requires_manual_index_update()) { return; } $DB->execute("ALTER FULLTEXT INDEX ON {search_simpledb_index} START UPDATE POPULATION"); $catalogname = $DB->get_prefix() . 'search_simpledb_catalog'; $retries = 0; do { // 0.2 seconds. usleep(200000); $record = $DB->get_record_sql("SELECT FULLTEXTCATALOGPROPERTY(cat.name, 'PopulateStatus') AS [PopulateStatus] FROM sys.fulltext_catalogs AS cat WHERE cat.name = ?", array($catalogname)); $retries++; } while ($retries < 100 && $record->populatestatus != '0'); if ($retries === 100) { // No update after 20 seconds... $this->fail('Sorry, your SQL server fulltext search index is too slow.'); } } /** * Mssql with fulltext support requires manual updates. * * @return bool */ private function requires_manual_index_update() { global $DB; return ($DB->get_dbfamily() === 'mssql' && $DB->is_fulltext_search_supported()); } } engine/simpledb/version.php 0000644 00000002002 15152012435 0011776 0 ustar 00 <?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/>. /** * Version info. * * @package search_simpledb * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->version = 2022112800; $plugin->requires = 2022111800; $plugin->component = 'search_simpledb'; engine/simpledb/classes/privacy/provider.php 0000644 00000021213 15152012435 0015262 0 ustar 00 <?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/>. /** * Privacy class for requesting user data. * * @package search_simpledb * @copyright 2018 David Monllao * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace search_simpledb\privacy; defined('MOODLE_INTERNAL') || die(); use core_privacy\local\metadata\collection; use core_privacy\local\request\contextlist; use core_privacy\local\request\approved_contextlist; use core_privacy\local\request\approved_userlist; use core_privacy\local\request\transform; use core_privacy\local\request\userlist; use core_privacy\local\request\writer; /** * Provider for the search_simpledb plugin. * * @copyright 2018 David Monllao * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\core_userlist_provider, \core_privacy\local\request\plugin\provider { /** * Returns meta data about this system. * * @param collection $collection The initialised collection to add items to. * @return collection A listing of user data stored through this system. */ public static function get_metadata(collection $collection) : collection { $collection->add_database_table( 'search_simpledb_index', [ 'docid' => 'privacy:metadata:index:docid', 'itemid' => 'privacy:metadata:index:itemid', 'title' => 'privacy:metadata:index:title', 'content' => 'privacy:metadata:index:content', 'contextid' => 'privacy:metadata:index:contextid', 'areaid' => 'privacy:metadata:index:areaid', 'type' => 'privacy:metadata:index:type', 'courseid' => 'privacy:metadata:index:courseid', 'owneruserid' => 'privacy:metadata:index:owneruserid', 'modified' => 'privacy:metadata:index:modified', 'userid' => 'privacy:metadata:index:userid', 'description1' => 'privacy:metadata:index:description1', 'description2' => 'privacy:metadata:index:description2', ], 'privacy:metadata:index' ); return $collection; } /** * Get the list of contexts that contain user information for the specified user. * * @param int $userid The user to search. * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin. */ public static function get_contexts_for_userid(int $userid) : contextlist { $contextlist = new \core_privacy\local\request\contextlist(); $params = ['userid' => $userid, 'owneruserid' => $userid]; $sql = "SELECT DISTINCT contextid FROM {search_simpledb_index} WHERE (userid = :userid OR owneruserid = :owneruserid)"; $contextlist->add_from_sql($sql, $params); return $contextlist; } /** * Get the list of users who have data within a context. * * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination. */ public static function get_users_in_context(userlist $userlist) { $context = $userlist->get_context(); $params = [ 'contextid' => $context->id, ]; $sql = "SELECT ssi.userid FROM {search_simpledb_index} ssi WHERE ssi.contextid = :contextid"; $userlist->add_from_sql('userid', $sql, $params); $sql = "SELECT ssi.owneruserid AS userid FROM {search_simpledb_index} ssi WHERE ssi.contextid = :contextid"; $userlist->add_from_sql('userid', $sql, $params); } /** * Export all user data for the specified user, in the specified contexts. * * @param approved_contextlist $contextlist The approved contexts to export information for. */ public static function export_user_data(approved_contextlist $contextlist) { global $DB; // Plugin search_simpledb uses the default document object (core_search\document) which uses FORMAT_PLAIN. $textformat = FORMAT_PLAIN; $userid = $contextlist->get_user()->id; $ctxfields = \context_helper::get_preload_record_columns_sql('ctx'); list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); $sql = "SELECT ssi.*, $ctxfields FROM {search_simpledb_index} ssi JOIN {context} ctx ON ctx.id = ssi.contextid WHERE ssi.contextid $contextsql AND (ssi.userid = :userid OR ssi.owneruserid = :owneruserid)"; $params = ['userid' => $userid, 'owneruserid' => $userid] + $contextparams; $records = $DB->get_recordset_sql($sql, $params); foreach ($records as $record) { \context_helper::preload_from_record($record); $context = \context::instance_by_id($record->contextid); $document = (object)[ 'title' => format_string($record->title, true, ['context' => $context]), 'content' => format_text($record->content, $textformat, ['context' => $context]), 'description1' => format_text($record->description1, $textformat, ['context' => $context]), 'description2' => format_text($record->description2, $textformat, ['context' => $context]), 'context' => $context->get_context_name(true, true), 'modified' => transform::datetime($record->modified), ]; $path = [get_string('search', 'search'), $record->docid]; writer::with_context($context)->export_data($path, $document); } $records->close(); } /** * Delete all data for all users in the specified context. * * @param context $context The specific context to delete data for. */ public static function delete_data_for_all_users_in_context(\context $context) { global $DB; $DB->delete_records('search_simpledb_index', ['contextid' => $context->id]); if ($context->contextlevel == CONTEXT_USER) { $select = "userid = :userid OR owneruserid = :owneruserid"; $params = ['userid' => $context->instanceid, 'owneruserid' => $context->instanceid]; $DB->delete_records_select('search_simpledb_index', $select, $params); } } /** * Delete all user data for the specified user, in the specified contexts. * * @param approved_contextlist $contextlist The approved contexts and user information to delete information for. */ public static function delete_data_for_user(approved_contextlist $contextlist) { global $DB; $userid = $contextlist->get_user()->id; list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); $select = "contextid $contextsql AND (userid = :userid OR owneruserid = :owneruserid)"; $params = ['userid' => $userid, 'owneruserid' => $userid] + $contextparams; $DB->delete_records_select('search_simpledb_index', $select, $params); } /** * Delete multiple users within a single context. * * @param approved_userlist $userlist The approved context and user information to delete information for. */ public static function delete_data_for_users(approved_userlist $userlist) { global $DB; $context = $userlist->get_context(); list($usersql, $userparams) = $DB->get_in_or_equal($userlist->get_userids(), SQL_PARAMS_NAMED); list($ownersql, $ownerparams) = $DB->get_in_or_equal($userlist->get_userids(), SQL_PARAMS_NAMED); $select = "contextid = :contextid AND (userid {$usersql} OR owneruserid {$ownersql})"; $params = ['contextid' => $context->id] + $userparams + $ownerparams; $DB->delete_records_select('search_simpledb_index', $select, $params); } } engine/simpledb/classes/engine.php 0000644 00000034021 15152012435 0013221 0 ustar 00 <?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/>. /** * Simple moodle database engine. * * @package search_simpledb * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace search_simpledb; defined('MOODLE_INTERNAL') || die(); /** * Simple moodle database engine. * * @package search_simpledb * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class engine extends \core_search\engine { /** * Total number of available results. * * @var null|int */ protected $totalresults = null; /** * Prepares a SQL query, applies filters and executes it returning its results. * * @throws \core_search\engine_exception * @param stdClass $filters Containing query and filters. * @param array $usercontexts Contexts where the user has access. True if the user can access all contexts. * @param int $limit The maximum number of results to return. * @return \core_search\document[] Results or false if no results */ public function execute_query($filters, $usercontexts, $limit = 0) { global $DB, $USER; $serverstatus = $this->is_server_ready(); if ($serverstatus !== true) { throw new \core_search\engine_exception('engineserverstatus', 'search'); } if (empty($limit)) { $limit = \core_search\manager::MAX_RESULTS; } $params = array(); // To store all conditions we will add to where. $ands = array(); // Get results only available for the current user. $ands[] = '(owneruserid = ? OR owneruserid = ?)'; $params = array_merge($params, array(\core_search\manager::NO_OWNER_ID, $USER->id)); // Restrict it to the context where the user can access, we want this one cached. // If the user can access all contexts $usercontexts value is just true, we don't need to filter // in that case. if ($usercontexts && is_array($usercontexts)) { // Join all area contexts into a single array and implode. $allcontexts = array(); foreach ($usercontexts as $areaid => $areacontexts) { if (!empty($filters->areaids) && !in_array($areaid, $filters->areaids)) { // Skip unused areas. continue; } foreach ($areacontexts as $contextid) { // Ensure they are unique. $allcontexts[$contextid] = $contextid; } } if (empty($allcontexts)) { // This means there are no valid contexts for them, so they get no results. return array(); } list($contextsql, $contextparams) = $DB->get_in_or_equal($allcontexts); $ands[] = 'contextid ' . $contextsql; $params = array_merge($params, $contextparams); } // Course id filter. if (!empty($filters->courseids)) { list($conditionsql, $conditionparams) = $DB->get_in_or_equal($filters->courseids); $ands[] = 'courseid ' . $conditionsql; $params = array_merge($params, $conditionparams); } // Area id filter. if (!empty($filters->areaids)) { list($conditionsql, $conditionparams) = $DB->get_in_or_equal($filters->areaids); $ands[] = 'areaid ' . $conditionsql; $params = array_merge($params, $conditionparams); } if (!empty($filters->title)) { $ands[] = $DB->sql_like('title', '?', false, false); $params[] = $filters->title; } if (!empty($filters->timestart)) { $ands[] = 'modified >= ?'; $params[] = $filters->timestart; } if (!empty($filters->timeend)) { $ands[] = 'modified <= ?'; $params[] = $filters->timeend; } // And finally the main query after applying all AND filters. if (!empty($filters->q)) { switch ($DB->get_dbfamily()) { case 'postgres': $ands[] = "(" . "to_tsvector('simple', title) @@ plainto_tsquery('simple', ?) OR ". "to_tsvector('simple', content) @@ plainto_tsquery('simple', ?) OR ". "to_tsvector('simple', description1) @@ plainto_tsquery('simple', ?) OR ". "to_tsvector('simple', description2) @@ plainto_tsquery('simple', ?)". ")"; $params[] = $filters->q; $params[] = $filters->q; $params[] = $filters->q; $params[] = $filters->q; break; case 'mysql': if ($DB->is_fulltext_search_supported()) { $ands[] = "MATCH (title, content, description1, description2) AGAINST (?)"; $params[] = $filters->q; // Sorry for the hack, but it does not seem that we will have a solution for // this soon (https://bugs.mysql.com/bug.php?id=78485). if ($filters->q === '*') { return array(); } } else { // Clumsy version for mysql versions with no fulltext support. list($queryand, $queryparams) = $this->get_simple_query($filters->q); $ands[] = $queryand; $params = array_merge($params, $queryparams); } break; case 'mssql': if ($DB->is_fulltext_search_supported()) { $ands[] = "CONTAINS ((title, content, description1, description2), ?)"; // Special treatment for double quotes: // - Puntuation is ignored so we can get rid of them. // - Phrases should be enclosed in double quotation marks. $params[] = '"' . str_replace('"', '', $filters->q) . '"'; } else { // Clumsy version for mysql versions with no fulltext support. list($queryand, $queryparams) = $this->get_simple_query($filters->q); $ands[] = $queryand; $params = array_merge($params, $queryparams); } break; default: list($queryand, $queryparams) = $this->get_simple_query($filters->q); $ands[] = $queryand; $params = array_merge($params, $queryparams); break; } } // It is limited to $limit, no need to use recordsets. $documents = $DB->get_records_select('search_simpledb_index', implode(' AND ', $ands), $params, 'docid', '*', 0, $limit); // Hopefully database cached results as this applies the same filters than above. $this->totalresults = $DB->count_records_select('search_simpledb_index', implode(' AND ', $ands), $params); $numgranted = 0; // Iterate through the results checking its availability and whether they are available for the user or not. $docs = array(); foreach ($documents as $docdata) { if ($docdata->owneruserid != \core_search\manager::NO_OWNER_ID && $docdata->owneruserid != $USER->id) { // If owneruserid is set, no other user should be able to access this record. continue; } if (!$searcharea = $this->get_search_area($docdata->areaid)) { $this->totalresults--; continue; } // Switch id back to the document id. $docdata->id = $docdata->docid; unset($docdata->docid); $access = $searcharea->check_access($docdata->itemid); switch ($access) { case \core_search\manager::ACCESS_DELETED: $this->delete_by_id($docdata->id); $this->totalresults--; break; case \core_search\manager::ACCESS_DENIED: $this->totalresults--; break; case \core_search\manager::ACCESS_GRANTED: $numgranted++; $docs[] = $this->to_document($searcharea, (array)$docdata); break; } // This should never happen. if ($numgranted >= $limit) { $docs = array_slice($docs, 0, $limit, true); break; } } return $docs; } /** * Adds a document to the search engine. * * This does not commit to the search engine. * * @param \core_search\document $document * @param bool $fileindexing True if file indexing is to be used * @return bool False if the file was skipped or failed, true on success */ public function add_document($document, $fileindexing = false) { global $DB; $doc = (object)$document->export_for_engine(); // Moodle's ids using DML are always autoincremented. $doc->docid = $doc->id; unset($doc->id); $id = $DB->get_field('search_simpledb_index', 'id', array('docid' => $doc->docid)); try { if ($id) { $doc->id = $id; $DB->update_record('search_simpledb_index', $doc); } else { $DB->insert_record('search_simpledb_index', $doc); } } catch (\dml_exception $ex) { debugging('dml error while trying to insert document with id ' . $doc->docid . ': ' . $ex->getMessage(), DEBUG_DEVELOPER); return false; } return true; } /** * Deletes the specified document. * * @param string $id The document id to delete * @return void */ public function delete_by_id($id) { global $DB; $DB->delete_records('search_simpledb_index', array('docid' => $id)); } /** * Delete all area's documents. * * @param string $areaid * @return void */ public function delete($areaid = null) { global $DB; if ($areaid) { $DB->delete_records('search_simpledb_index', array('areaid' => $areaid)); } else { $DB->delete_records('search_simpledb_index'); } } /** * Checks that the required table was installed. * * @return true|string Returns true if all good or an error string. */ public function is_server_ready() { global $DB; if (!$DB->get_manager()->table_exists('search_simpledb_index')) { return 'search_simpledb_index table does not exist'; } return true; } /** * It is always installed. * * @return true */ public function is_installed() { return true; } /** * Returns the total results. * * Including skipped results. * * @return int */ public function get_query_total_count() { if (!is_null($this->totalresults)) { // This is a just in case as we count total results in execute_query. return \core_search\manager::MAX_RESULTS; } return $this->totalresults; } /** * Returns the default query for db engines. * * @param string $q The query string * @return array SQL string and params list */ protected function get_simple_query($q) { global $DB; $sql = '(' . $DB->sql_like('title', '?', false, false) . ' OR ' . $DB->sql_like('content', '?', false, false) . ' OR ' . $DB->sql_like('description1', '?', false, false) . ' OR ' . $DB->sql_like('description2', '?', false, false) . ')'; // Remove quotes from the query. $q = str_replace('"', '', $q); $params = [ '%' . $q . '%', '%' . $q . '%', '%' . $q . '%', '%' . $q . '%' ]; return array($sql, $params); } /** * Simpledb supports deleting the index for a context. * * @param int $oldcontextid Context that has been deleted * @return bool True to indicate that any data was actually deleted * @throws \core_search\engine_exception */ public function delete_index_for_context(int $oldcontextid) { global $DB; try { $DB->delete_records('search_simpledb_index', ['contextid' => $oldcontextid]); } catch (\dml_exception $e) { throw new \core_search\engine_exception('dbupdatefailed'); } return true; } /** * Simpledb supports deleting the index for a course. * * @param int $oldcourseid * @return bool True to indicate that any data was actually deleted * @throws \core_search\engine_exception */ public function delete_index_for_course(int $oldcourseid) { global $DB; try { $DB->delete_records('search_simpledb_index', ['courseid' => $oldcourseid]); } catch (\dml_exception $e) { throw new \core_search\engine_exception('dbupdatefailed'); } return true; } } engine/simpledb/lang/en/search_simpledb.php 0000644 00000003667 15152012435 0015002 0 ustar 00 <?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/>. /** * Strings for component 'search_simpledb'. * * @package search_simpledb * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['pluginname'] = 'Simple search'; $string['privacy:metadata:index'] = 'Indexed contents'; $string['privacy:metadata:index:docid'] = 'Document ID (unique)'; $string['privacy:metadata:index:itemid'] = 'Item identifier (in search area scope)'; $string['privacy:metadata:index:title'] = 'Title'; $string['privacy:metadata:index:content'] = 'Contents'; $string['privacy:metadata:index:contextid'] = 'Document context ID'; $string['privacy:metadata:index:areaid'] = 'Search area ID'; $string['privacy:metadata:index:type'] = 'Document type'; $string['privacy:metadata:index:courseid'] = 'Course ID'; $string['privacy:metadata:index:owneruserid'] = 'Document owner user ID'; $string['privacy:metadata:index:modified'] = 'Last modification time'; $string['privacy:metadata:index:userid'] = 'Document user ID'; $string['privacy:metadata:index:description1'] = 'Extra description field'; $string['privacy:metadata:index:description2'] = 'Extra description field'; $string['searchinfo'] = 'Search queries'; $string['searchinfo_help'] = 'Enter the search query.'; engine/solr/setup_schema.php 0000644 00000004004 15152012435 0012155 0 ustar 00 <?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/>. /** * Adds moodle fields to solr schema. * * Schema REST API write actions are only available from Solr 4.4 onwards. * * The schema should be managed and mutable to allow this script * to add new fields to the schema. * * @link https://cwiki.apache.org/confluence/display/solr/Managed+Schema+Definition+in+SolrConfig * @package search_solr * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once(__DIR__ . '/../../../config.php'); require_once($CFG->libdir.'/adminlib.php'); require_login(null, false); require_capability('moodle/site:config', context_system::instance()); $returnurl = new moodle_url('/admin/settings.php', array('section' => 'manageglobalsearch')); $schema = new \search_solr\schema(); $status = $schema->can_setup_server(); if ($status !== true) { $PAGE->set_context(context_system::instance()); $PAGE->set_url(new moodle_url('/search/engine/solr/setup_schema.php')); echo $OUTPUT->header(); echo $OUTPUT->notification($status, \core\output\notification::NOTIFY_ERROR); echo $OUTPUT->box($OUTPUT->action_link($returnurl, get_string('continue')), 'generalbox centerpara'); echo $OUTPUT->footer(); exit(1); } $schema->setup(); redirect($returnurl, get_string('setupok', 'search_solr'), 4); engine/solr/tests/fixtures/testable_engine.php 0000644 00000003020 15152012435 0015635 0 ustar 00 <?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/>. namespace search_solr; /** * Search engine for testing purposes. * * @package search_solr * @category phpunit * @copyright 2016 Eric Merrill {@link http://www.merrilldigital.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die; class testable_engine extends \search_solr\engine { /** * Function that lets us update the internally cached config object of the engine. */ public function test_set_config($name, $value) { $this->config->$name = $value; } /** * Gets the search client (this function is usually protected) for testing. * * @return \SolrClient Solr client object * @throws \core_search\engine_exception */ public function get_search_client_public(): \SolrClient { return parent::get_search_client(); } } engine/solr/tests/privacy/provider_test.php 0000644 00000003322 15152012435 0015207 0 ustar 00 <?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/>. /** * Privacy provider tests. * * @package search_solr * @copyright 2018 David Monllao * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace search_solr\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy provider tests class. * * @copyright 2018 David Monllao * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider_test extends \core_privacy\tests\provider_testcase { /** * Verify that a collection of metadata is returned for this component and that it just links to an external location. */ public function test_get_metadata() { $collection = new \core_privacy\local\metadata\collection('search_solr'); $collection = \search_solr\privacy\provider::get_metadata($collection); $this->assertNotEmpty($collection); $items = $collection->get_collection(); $this->assertEquals(1, count($items)); $this->assertInstanceOf(\core_privacy\local\metadata\types\external_location::class, $items[0]); } } engine/solr/tests/engine_test.php 0000644 00000173674 15152012435 0013167 0 ustar 00 <?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/>. namespace search_solr; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php'); require_once($CFG->dirroot . '/search/tests/fixtures/mock_search_area.php'); require_once($CFG->dirroot . '/search/engine/solr/tests/fixtures/testable_engine.php'); /** * Solr search engine base unit tests. * * Required params: * - define('TEST_SEARCH_SOLR_HOSTNAME', '127.0.0.1'); * - define('TEST_SEARCH_SOLR_PORT', '8983'); * - define('TEST_SEARCH_SOLR_INDEXNAME', 'unittest'); * * Optional params: * - define('TEST_SEARCH_SOLR_USERNAME', ''); * - define('TEST_SEARCH_SOLR_PASSWORD', ''); * - define('TEST_SEARCH_SOLR_SSLCERT', ''); * - define('TEST_SEARCH_SOLR_SSLKEY', ''); * - define('TEST_SEARCH_SOLR_KEYPASSWORD', ''); * - define('TEST_SEARCH_SOLR_CAINFOCERT', ''); * * @package search_solr * @category test * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * * @runTestsInSeparateProcesses */ class engine_test extends \advanced_testcase { /** * @var \core_search\manager */ protected $search = null; /** * @var Instace of core_search_generator. */ protected $generator = null; /** * @var Instace of testable_engine. */ protected $engine = null; public function setUp(): void { $this->resetAfterTest(); set_config('enableglobalsearch', true); set_config('searchengine', 'solr'); if (!function_exists('solr_get_version')) { $this->markTestSkipped('Solr extension is not loaded.'); } if (!defined('TEST_SEARCH_SOLR_HOSTNAME') || !defined('TEST_SEARCH_SOLR_INDEXNAME') || !defined('TEST_SEARCH_SOLR_PORT')) { $this->markTestSkipped('Solr extension test server not set.'); } set_config('server_hostname', TEST_SEARCH_SOLR_HOSTNAME, 'search_solr'); set_config('server_port', TEST_SEARCH_SOLR_PORT, 'search_solr'); set_config('indexname', TEST_SEARCH_SOLR_INDEXNAME, 'search_solr'); if (defined('TEST_SEARCH_SOLR_USERNAME')) { set_config('server_username', TEST_SEARCH_SOLR_USERNAME, 'search_solr'); } if (defined('TEST_SEARCH_SOLR_PASSWORD')) { set_config('server_password', TEST_SEARCH_SOLR_PASSWORD, 'search_solr'); } if (defined('TEST_SEARCH_SOLR_SSLCERT')) { set_config('secure', true, 'search_solr'); set_config('ssl_cert', TEST_SEARCH_SOLR_SSLCERT, 'search_solr'); } if (defined('TEST_SEARCH_SOLR_SSLKEY')) { set_config('ssl_key', TEST_SEARCH_SOLR_SSLKEY, 'search_solr'); } if (defined('TEST_SEARCH_SOLR_KEYPASSWORD')) { set_config('ssl_keypassword', TEST_SEARCH_SOLR_KEYPASSWORD, 'search_solr'); } if (defined('TEST_SEARCH_SOLR_CAINFOCERT')) { set_config('ssl_cainfo', TEST_SEARCH_SOLR_CAINFOCERT, 'search_solr'); } set_config('fileindexing', 1, 'search_solr'); // We are only test indexing small string files, so setting this as low as we can. set_config('maxindexfilekb', 1, 'search_solr'); $this->generator = self::getDataGenerator()->get_plugin_generator('core_search'); $this->generator->setup(); // Inject search solr engine into the testable core search as we need to add the mock // search component to it. $this->engine = new \search_solr\testable_engine(); $this->search = \testable_core_search::instance($this->engine); $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $this->search->add_search_area($areaid, new \core_mocksearch\search\mock_search_area()); $this->setAdminUser(); // Cleanup before doing anything on it as the index it is out of this test control. $this->search->delete_index(); // Add moodle fields if they don't exist. $schema = new \search_solr\schema($this->engine); $schema->setup(false); } public function tearDown(): void { // For unit tests before PHP 7, teardown is called even on skip. So only do our teardown if we did setup. if ($this->generator) { // Moodle DML freaks out if we don't teardown the temp table after each run. $this->generator->teardown(); $this->generator = null; } } /** * Simple data provider to allow tests to be run with file indexing on and off. */ public function file_indexing_provider() { return array( 'file-indexing-on' => array(1), 'file-indexing-off' => array(0) ); } public function test_connection() { $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 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 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 */ public function test_index($fileindexing) { global $DB; $this->engine->test_set_config('fileindexing', $fileindexing); $record = new \stdClass(); $record->timemodified = time() - 1; $this->generator->create_record($record); // Data gets into the search engine. $this->assertTrue($this->search->index()); // Not anymore as everything was already added. sleep(1); $this->assertFalse($this->search->index()); $this->generator->create_record(); // Indexing again once there is new data. $this->assertTrue($this->search->index()); } /** * Better keep this not very strict about which or how many results are returned as may depend on solr engine config. * * @dataProvider file_indexing_provider * * @return void */ public function test_search($fileindexing) { global $USER, $DB; $this->engine->test_set_config('fileindexing', $fileindexing); $this->generator->create_record(); $record = new \stdClass(); $record->title = "Special title"; $this->generator->create_record($record); $this->search->index(); $querydata = new \stdClass(); $querydata->q = 'message'; $results = $this->search->search($querydata); $this->assertCount(2, $results); // Based on core_mocksearch\search\indexer. $this->assertEquals($USER->id, $results[0]->get('userid')); $this->assertEquals(\context_course::instance(SITEID)->id, $results[0]->get('contextid')); // Do a test to make sure we aren't searching non-query fields, like areaid. $querydata->q = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $this->assertCount(0, $this->search->search($querydata)); $querydata->q = 'message'; sleep(1); $beforeadding = time(); sleep(1); $this->generator->create_record(); $this->search->index(); // Timestart. $querydata->timestart = $beforeadding; $this->assertCount(1, $this->search->search($querydata)); // Timeend. unset($querydata->timestart); $querydata->timeend = $beforeadding; $this->assertCount(2, $this->search->search($querydata)); // Title. unset($querydata->timeend); $querydata->title = 'Special title'; $this->assertCount(1, $this->search->search($querydata)); // Course IDs. unset($querydata->title); $querydata->courseids = array(SITEID + 1); $this->assertCount(0, $this->search->search($querydata)); $querydata->courseids = array(SITEID); $this->assertCount(3, $this->search->search($querydata)); // Now try some area-id combinations. unset($querydata->courseids); $forumpostareaid = \core_search\manager::generate_areaid('mod_forum', 'post'); $mockareaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $querydata->areaids = array($forumpostareaid); $this->assertCount(0, $this->search->search($querydata)); $querydata->areaids = array($forumpostareaid, $mockareaid); $this->assertCount(3, $this->search->search($querydata)); $querydata->areaids = array($mockareaid); $this->assertCount(3, $this->search->search($querydata)); $querydata->areaids = array(); $this->assertCount(3, $this->search->search($querydata)); // Check that index contents get updated. $this->generator->delete_all(); $this->search->index(true); unset($querydata->title); $querydata->q = '*'; $this->assertCount(0, $this->search->search($querydata)); } /** * @dataProvider file_indexing_provider */ public function test_delete($fileindexing) { $this->engine->test_set_config('fileindexing', $fileindexing); $this->generator->create_record(); $this->generator->create_record(); $this->search->index(); $querydata = new \stdClass(); $querydata->q = 'message'; $this->assertCount(2, $this->search->search($querydata)); $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $this->search->delete_index($areaid); $this->assertCount(0, $this->search->search($querydata)); } /** * @dataProvider file_indexing_provider */ public function test_alloweduserid($fileindexing) { $this->engine->test_set_config('fileindexing', $fileindexing); $area = new \core_mocksearch\search\mock_search_area(); $record = $this->generator->create_record(); // Get the doc and insert the default doc. $doc = $area->get_document($record); $this->engine->add_document($doc); $users = array(); $users[] = $this->getDataGenerator()->create_user(); $users[] = $this->getDataGenerator()->create_user(); $users[] = $this->getDataGenerator()->create_user(); // Add a record that only user 100 can see. $originalid = $doc->get('id'); // Now add a custom doc for each user. foreach ($users as $user) { $doc = $area->get_document($record); $doc->set('id', $originalid.'-'.$user->id); $doc->set('owneruserid', $user->id); $this->engine->add_document($doc); } $this->engine->area_index_complete($area->get_area_id()); $querydata = new \stdClass(); $querydata->q = 'message'; $querydata->title = $doc->get('title'); // We are going to go through each user and see if they get the original and the owned doc. foreach ($users as $user) { $this->setUser($user); $results = $this->search->search($querydata); $this->assertCount(2, $results); $owned = 0; $notowned = 0; // We don't know what order we will get the results in, so we are doing this. foreach ($results as $result) { $owneruserid = $result->get('owneruserid'); if (empty($owneruserid)) { $notowned++; $this->assertEquals(0, $owneruserid); $this->assertEquals($originalid, $result->get('id')); } else { $owned++; $this->assertEquals($user->id, $owneruserid); $this->assertEquals($originalid.'-'.$user->id, $result->get('id')); } } $this->assertEquals(1, $owned); $this->assertEquals(1, $notowned); } // Now test a user with no owned results. $otheruser = $this->getDataGenerator()->create_user(); $this->setUser($otheruser); $results = $this->search->search($querydata); $this->assertCount(1, $results); $this->assertEquals(0, $results[0]->get('owneruserid')); $this->assertEquals($originalid, $results[0]->get('id')); } /** * @dataProvider file_indexing_provider */ public function test_highlight($fileindexing) { global $PAGE; $this->engine->test_set_config('fileindexing', $fileindexing); $this->generator->create_record(); $this->search->index(); $querydata = new \stdClass(); $querydata->q = 'message'; $results = $this->search->search($querydata); $this->assertCount(1, $results); $result = reset($results); $regex = '|'.\search_solr\engine::HIGHLIGHT_START.'message'.\search_solr\engine::HIGHLIGHT_END.'|'; $this->assertMatchesRegularExpression($regex, $result->get('content')); $searchrenderer = $PAGE->get_renderer('core_search'); $exported = $result->export_for_template($searchrenderer); $regex = '|<span class="highlight">message</span>|'; $this->assertMatchesRegularExpression($regex, $exported['content']); } public function test_export_file_for_engine() { // Get area to work with. $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $area = \core_search\manager::get_search_area($areaid); $record = $this->generator->create_record(); $doc = $area->get_document($record); $filerecord = new \stdClass(); $filerecord->timemodified = 978310800; $file = $this->generator->create_file($filerecord); $doc->add_stored_file($file); $filearray = $doc->export_file_for_engine($file); $this->assertEquals(\core_search\manager::TYPE_FILE, $filearray['type']); $this->assertEquals($file->get_id(), $filearray['solr_fileid']); $this->assertEquals($file->get_contenthash(), $filearray['solr_filecontenthash']); $this->assertEquals(\search_solr\document::INDEXED_FILE_TRUE, $filearray['solr_fileindexstatus']); $this->assertEquals($file->get_filename(), $filearray['title']); $this->assertEquals(978310800, \search_solr\document::import_time_from_engine($filearray['modified'])); } public function test_index_file() { // Very simple test. $file = $this->generator->create_file(); $record = new \stdClass(); $record->attachfileids = array($file->get_id()); $this->generator->create_record($record); $this->search->index(); $querydata = new \stdClass(); $querydata->q = '"File contents"'; $this->assertCount(1, $this->search->search($querydata)); } public function test_reindexing_files() { // Get area to work with. $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $area = \core_search\manager::get_search_area($areaid); $record = $this->generator->create_record(); $doc = $area->get_document($record); // Now we are going to make some files. $fs = get_file_storage(); $syscontext = \context_system::instance(); $files = array(); $filerecord = new \stdClass(); // We make enough so that we pass the 500 files threashold. That is the boundary when getting files. $boundary = 500; $top = (int)($boundary * 1.1); for ($i = 0; $i < $top; $i++) { $filerecord->filename = 'searchfile'.$i; $filerecord->content = 'Some FileContents'.$i; $file = $this->generator->create_file($filerecord); $doc->add_stored_file($file); $files[] = $file; } // Add the doc with lots of files, then commit. $this->engine->add_document($doc, true); $this->engine->area_index_complete($area->get_area_id()); // Indexes we are going to check. 0 means we will delete, 1 means we will keep. $checkfiles = array( 0 => 0, // Check the begining of the set. 1 => 1, 2 => 0, ($top - 3) => 0, // Check the end of the set. ($top - 2) => 1, ($top - 1) => 0, ($boundary - 2) => 0, // Check at the boundary between fetch groups. ($boundary - 1) => 0, $boundary => 0, ($boundary + 1) => 0, ((int)($boundary * 0.5)) => 1, // Make sure we keep some middle ones. ((int)($boundary * 1.05)) => 1 ); $querydata = new \stdClass(); // First, check that all the files are currently there. foreach ($checkfiles as $key => $unused) { $querydata->q = 'FileContents'.$key; $this->assertCount(1, $this->search->search($querydata)); $querydata->q = 'searchfile'.$key; $this->assertCount(1, $this->search->search($querydata)); } // Remove the files we want removed from the files array. foreach ($checkfiles as $key => $keep) { if (!$keep) { unset($files[$key]); } } // And make us a new file to add. $filerecord->filename = 'searchfileNew'; $filerecord->content = 'Some FileContentsNew'; $files[] = $this->generator->create_file($filerecord); $checkfiles['New'] = 1; $doc = $area->get_document($record); foreach($files as $file) { $doc->add_stored_file($file); } // Reindex the document with the changed files. $this->engine->add_document($doc, true); $this->engine->area_index_complete($area->get_area_id()); // Go through our check array, and see if the file is there or not. foreach ($checkfiles as $key => $keep) { $querydata->q = 'FileContents'.$key; $this->assertCount($keep, $this->search->search($querydata)); $querydata->q = 'searchfile'.$key; $this->assertCount($keep, $this->search->search($querydata)); } // Now check that we get one result when we search from something in all of them. $querydata->q = 'Some'; $this->assertCount(1, $this->search->search($querydata)); } /** * Test indexing a file we don't consider indexable. */ public function test_index_filtered_file() { // Get area to work with. $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'mock_search_area'); $area = \core_search\manager::get_search_area($areaid); // Get a single record to make a doc from. $record = $this->generator->create_record(); $doc = $area->get_document($record); // Now we are going to make some files. $fs = get_file_storage(); $syscontext = \context_system::instance(); // We need to make a file greater than 1kB in size, which is the lowest filter size. $filerecord = new \stdClass(); $filerecord->filename = 'largefile'; $filerecord->content = 'Some LargeFindContent to find.'; for ($i = 0; $i < 200; $i++) { $filerecord->content .= ' The quick brown fox jumps over the lazy dog.'; } $this->assertGreaterThan(1024, strlen($filerecord->content)); $file = $this->generator->create_file($filerecord); $doc->add_stored_file($file); $filerecord->filename = 'smallfile'; $filerecord->content = 'Some SmallFindContent to find.'; $file = $this->generator->create_file($filerecord); $doc->add_stored_file($file); $this->engine->add_document($doc, true); $this->engine->area_index_complete($area->get_area_id()); $querydata = new \stdClass(); // We shouldn't be able to find the large file contents. $querydata->q = 'LargeFindContent'; $this->assertCount(0, $this->search->search($querydata)); // But we should be able to find the filename. $querydata->q = 'largefile'; $this->assertCount(1, $this->search->search($querydata)); // We should be able to find the small file contents. $querydata->q = 'SmallFindContent'; $this->assertCount(1, $this->search->search($querydata)); // And we should be able to find the filename. $querydata->q = 'smallfile'; $this->assertCount(1, $this->search->search($querydata)); } public function test_delete_by_id() { // First get files in the index. $file = $this->generator->create_file(); $record = new \stdClass(); $record->attachfileids = array($file->get_id()); $this->generator->create_record($record); $this->generator->create_record($record); $this->search->index(); $querydata = new \stdClass(); // Then search to make sure they are there. $querydata->q = '"File contents"'; $results = $this->search->search($querydata); $this->assertCount(2, $results); $first = reset($results); $deleteid = $first->get('id'); $this->engine->delete_by_id($deleteid); // Check that we don't get a result for it anymore. $results = $this->search->search($querydata); $this->assertCount(1, $results); $result = reset($results); $this->assertNotEquals($deleteid, $result->get('id')); } /** * Test that expected results are returned, even with low check_access success rate. * * @dataProvider file_indexing_provider */ public function test_solr_filling($fileindexing) { $this->engine->test_set_config('fileindexing', $fileindexing); $user1 = self::getDataGenerator()->create_user(); $user2 = self::getDataGenerator()->create_user(); // We are going to create a bunch of records that user 1 can see with 2 keywords. // Then we are going to create a bunch for user 2 with only 1 of the keywords. // If user 2 searches for both keywords, solr will return all of the user 1 results, then the user 2 results. // This is because the user 1 results will match 2 keywords, while the others will match only 1. $record = new \stdClass(); // First create a bunch of records for user 1 to see. $record->denyuserids = array($user2->id); $record->content = 'Something1 Something2'; $maxresults = (int)(\core_search\manager::MAX_RESULTS * .75); for ($i = 0; $i < $maxresults; $i++) { $this->generator->create_record($record); } // Then create a bunch of records for user 2 to see. $record->denyuserids = array($user1->id); $record->content = 'Something1'; for ($i = 0; $i < $maxresults; $i++) { $this->generator->create_record($record); } $this->search->index(); // Check that user 1 sees all their results. $this->setUser($user1); $querydata = new \stdClass(); $querydata->q = 'Something1 Something2'; $results = $this->search->search($querydata); $this->assertCount($maxresults, $results); // Check that user 2 will see theirs, even though they may be crouded out. $this->setUser($user2); $results = $this->search->search($querydata); $this->assertCount($maxresults, $results); } /** * Create 40 docs, that will be return from Solr in 10 hidden, 10 visible, 10 hidden, 10 visible if you query for: * Something1 Something2 Something3 Something4, with the specified user set. */ protected function setup_user_hidden_docs($user) { // These results will come first, and will not be visible by the user. $record = new \stdClass(); $record->denyuserids = array($user->id); $record->content = 'Something1 Something2 Something3 Something4'; for ($i = 0; $i < 10; $i++) { $this->generator->create_record($record); } // These results will come second, and will be visible by the user. unset($record->denyuserids); $record->content = 'Something1 Something2 Something3'; for ($i = 0; $i < 10; $i++) { $this->generator->create_record($record); } // These results will come third, and will not be visible by the user. $record->denyuserids = array($user->id); $record->content = 'Something1 Something2'; for ($i = 0; $i < 10; $i++) { $this->generator->create_record($record); } // These results will come fourth, and will be visible by the user. unset($record->denyuserids); $record->content = 'Something1 '; for ($i = 0; $i < 10; $i++) { $this->generator->create_record($record); } } /** * Test that counts are what we expect. * * @dataProvider file_indexing_provider */ public function test_get_query_total_count($fileindexing) { $this->engine->test_set_config('fileindexing', $fileindexing); $user = self::getDataGenerator()->create_user(); $this->setup_user_hidden_docs($user); $this->search->index(); $this->setUser($user); $querydata = new \stdClass(); $querydata->q = 'Something1 Something2 Something3 Something4'; // In this first set, it should have determined the first 10 of 40 are bad, so there could be up to 30 left. $results = $this->engine->execute_query($querydata, (object)['everything' => true], 5); $this->assertEquals(30, $this->engine->get_query_total_count()); $this->assertCount(5, $results); // To get to 15, it has to process the first 10 that are bad, 10 that are good, 10 that are bad, then 5 that are good. // So we now know 20 are bad out of 40. $results = $this->engine->execute_query($querydata, (object)['everything' => true], 15); $this->assertEquals(20, $this->engine->get_query_total_count()); $this->assertCount(15, $results); // Try to get more then all, make sure we still see 20 count and 20 returned. $results = $this->engine->execute_query($querydata, (object)['everything' => true], 30); $this->assertEquals(20, $this->engine->get_query_total_count()); $this->assertCount(20, $results); } /** * Test that paged results are what we expect. * * @dataProvider file_indexing_provider */ public function test_manager_paged_search($fileindexing) { $this->engine->test_set_config('fileindexing', $fileindexing); $user = self::getDataGenerator()->create_user(); $this->setup_user_hidden_docs($user); $this->search->index(); // Check that user 1 sees all their results. $this->setUser($user); $querydata = new \stdClass(); $querydata->q = 'Something1 Something2 Something3 Something4'; // On this first page, it should have determined the first 10 of 40 are bad, so there could be up to 30 left. $results = $this->search->paged_search($querydata, 0); $this->assertEquals(30, $results->totalcount); $this->assertCount(10, $results->results); $this->assertEquals(0, $results->actualpage); // On the second page, it should have found the next 10 bad ones, so we no know there are only 20 total. $results = $this->search->paged_search($querydata, 1); $this->assertEquals(20, $results->totalcount); $this->assertCount(10, $results->results); $this->assertEquals(1, $results->actualpage); // Try to get an additional page - we should get back page 1 results, since that is the last page with valid results. $results = $this->search->paged_search($querydata, 2); $this->assertEquals(20, $results->totalcount); $this->assertCount(10, $results->results); $this->assertEquals(1, $results->actualpage); } /** * Tests searching for results restricted to context id. */ public function test_context_restriction() { // Use real search areas. $this->search->clear_static(); $this->search->add_core_search_areas(); // Create 2 courses and some forums. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(['fullname' => 'Course 1', 'summary' => 'xyzzy']); $contextc1 = \context_course::instance($course1->id); $course1forum1 = $generator->create_module('forum', ['course' => $course1, 'name' => 'C1F1', 'intro' => 'xyzzy']); $contextc1f1 = \context_module::instance($course1forum1->cmid); $course1forum2 = $generator->create_module('forum', ['course' => $course1, 'name' => 'C1F2', 'intro' => 'xyzzy']); $contextc1f2 = \context_module::instance($course1forum2->cmid); $course2 = $generator->create_course(['fullname' => 'Course 2', 'summary' => 'xyzzy']); $contextc2 = \context_course::instance($course1->id); $course2forum = $generator->create_module('forum', ['course' => $course2, 'name' => 'C2F', 'intro' => 'xyzzy']); $contextc2f = \context_module::instance($course2forum->cmid); // Index the courses and forums. $this->search->index(); // Search as admin user should find everything. $querydata = new \stdClass(); $querydata->q = 'xyzzy'; $results = $this->search->search($querydata); $this->assert_result_titles( ['Course 1', 'Course 2', 'C1F1', 'C1F2', 'C2F'], $results); // Admin user manually restricts results by context id to include one course and one forum. $querydata->contextids = [$contextc2f->id, $contextc1->id]; $results = $this->search->search($querydata); $this->assert_result_titles(['Course 1', 'C2F'], $results); // Student enrolled in only one course, same restriction, only has the available results. $student2 = $generator->create_user(); $generator->enrol_user($student2->id, $course2->id, 'student'); $this->setUser($student2); $results = $this->search->search($querydata); $this->assert_result_titles(['C2F'], $results); // Student enrolled in both courses, same restriction, same results as admin. $student1 = $generator->create_user(); $generator->enrol_user($student1->id, $course1->id, 'student'); $generator->enrol_user($student1->id, $course2->id, 'student'); $this->setUser($student1); $results = $this->search->search($querydata); $this->assert_result_titles(['Course 1', 'C2F'], $results); // Restrict both course and context. $querydata->courseids = [$course2->id]; $results = $this->search->search($querydata); $this->assert_result_titles(['C2F'], $results); unset($querydata->courseids); // Restrict both area and context. $querydata->areaids = ['core_course-course']; $results = $this->search->search($querydata); $this->assert_result_titles(['Course 1'], $results); // Restrict area and context, incompatibly - this has no results (and doesn't do a query). $querydata->contextids = [$contextc2f->id]; $results = $this->search->search($querydata); $this->assert_result_titles([], $results); } /** * Tests searching for results in groups, either by specified group ids or based on user * access permissions. */ public function test_groups() { global $USER; // Use real search areas. $this->search->clear_static(); $this->search->add_core_search_areas(); // Create 2 courses and a selection of forums with different group mode. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(['fullname' => 'Course 1']); $forum1nogroups = $generator->create_module('forum', ['course' => $course1, 'groupmode' => NOGROUPS]); $forum1separategroups = $generator->create_module('forum', ['course' => $course1, 'groupmode' => SEPARATEGROUPS]); $forum1visiblegroups = $generator->create_module('forum', ['course' => $course1, 'groupmode' => VISIBLEGROUPS]); $course2 = $generator->create_course(['fullname' => 'Course 2']); $forum2separategroups = $generator->create_module('forum', ['course' => $course2, 'groupmode' => SEPARATEGROUPS]); // Create two groups on each course. $group1a = $generator->create_group(['courseid' => $course1->id]); $group1b = $generator->create_group(['courseid' => $course1->id]); $group2a = $generator->create_group(['courseid' => $course2->id]); $group2b = $generator->create_group(['courseid' => $course2->id]); // Create search records in each activity and (where relevant) in each group. $forumgenerator = $generator->get_plugin_generator('mod_forum'); $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id, 'forum' => $forum1nogroups->id, 'name' => 'F1NG', 'message' => 'xyzzy']); $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id, 'forum' => $forum1separategroups->id, 'name' => 'F1SG-A', 'message' => 'xyzzy', 'groupid' => $group1a->id]); $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id, 'forum' => $forum1separategroups->id, 'name' => 'F1SG-B', 'message' => 'xyzzy', 'groupid' => $group1b->id]); $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id, 'forum' => $forum1visiblegroups->id, 'name' => 'F1VG-A', 'message' => 'xyzzy', 'groupid' => $group1a->id]); $forumgenerator->create_discussion(['course' => $course1->id, 'userid' => $USER->id, 'forum' => $forum1visiblegroups->id, 'name' => 'F1VG-B', 'message' => 'xyzzy', 'groupid' => $group1b->id]); $forumgenerator->create_discussion(['course' => $course2->id, 'userid' => $USER->id, 'forum' => $forum2separategroups->id, 'name' => 'F2SG-A', 'message' => 'xyzzy', 'groupid' => $group2a->id]); $forumgenerator->create_discussion(['course' => $course2->id, 'userid' => $USER->id, 'forum' => $forum2separategroups->id, 'name' => 'F2SG-B', 'message' => 'xyzzy', 'groupid' => $group2b->id]); $this->search->index(); // Search as admin user should find everything. $querydata = new \stdClass(); $querydata->q = 'xyzzy'; $results = $this->search->search($querydata); $this->assert_result_titles( ['F1NG', 'F1SG-A', 'F1SG-B', 'F1VG-A', 'F1VG-B', 'F2SG-A', 'F2SG-B'], $results); // Admin user manually restricts results by groups. $querydata->groupids = [$group1b->id, $group2a->id]; $results = $this->search->search($querydata); $this->assert_result_titles(['F1SG-B', 'F1VG-B', 'F2SG-A'], $results); // Student enrolled in both courses but no groups. $student1 = $generator->create_user(); $generator->enrol_user($student1->id, $course1->id, 'student'); $generator->enrol_user($student1->id, $course2->id, 'student'); $this->setUser($student1); unset($querydata->groupids); $results = $this->search->search($querydata); $this->assert_result_titles(['F1NG', 'F1VG-A', 'F1VG-B'], $results); // Student enrolled in both courses and group A in both cases. $student2 = $generator->create_user(); $generator->enrol_user($student2->id, $course1->id, 'student'); $generator->enrol_user($student2->id, $course2->id, 'student'); groups_add_member($group1a, $student2); groups_add_member($group2a, $student2); $this->setUser($student2); $results = $this->search->search($querydata); $this->assert_result_titles(['F1NG', 'F1SG-A', 'F1VG-A', 'F1VG-B', 'F2SG-A'], $results); // Manually restrict results to group B in course 1. $querydata->groupids = [$group1b->id]; $results = $this->search->search($querydata); $this->assert_result_titles(['F1VG-B'], $results); // Manually restrict results to group A in course 1. $querydata->groupids = [$group1a->id]; $results = $this->search->search($querydata); $this->assert_result_titles(['F1SG-A', 'F1VG-A'], $results); // Manager enrolled in both courses (has access all groups). $manager = $generator->create_user(); $generator->enrol_user($manager->id, $course1->id, 'manager'); $generator->enrol_user($manager->id, $course2->id, 'manager'); $this->setUser($manager); unset($querydata->groupids); $results = $this->search->search($querydata); $this->assert_result_titles( ['F1NG', 'F1SG-A', 'F1SG-B', 'F1VG-A', 'F1VG-B', 'F2SG-A', 'F2SG-B'], $results); } /** * Tests searching for results restricted to specific user id(s). */ public function test_user_restriction() { // Use real search areas. $this->search->clear_static(); $this->search->add_core_search_areas(); // Create a course, a forum, and a glossary. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $forum = $generator->create_module('forum', ['course' => $course->id]); $glossary = $generator->create_module('glossary', ['course' => $course->id]); // Create 3 user accounts, all enrolled as students on the course. $user1 = $generator->create_user(); $user2 = $generator->create_user(); $user3 = $generator->create_user(); $generator->enrol_user($user1->id, $course->id, 'student'); $generator->enrol_user($user2->id, $course->id, 'student'); $generator->enrol_user($user3->id, $course->id, 'student'); // All users create a forum discussion. $forumgen = $generator->get_plugin_generator('mod_forum'); $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $user1->id, 'name' => 'Post1', 'message' => 'plugh']); $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $user2->id, 'name' => 'Post2', 'message' => 'plugh']); $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $user3->id, 'name' => 'Post3', 'message' => 'plugh']); // Two of the users create entries in the glossary. $glossarygen = $generator->get_plugin_generator('mod_glossary'); $glossarygen->create_content($glossary, ['concept' => 'Entry1', 'definition' => 'plugh', 'userid' => $user1->id]); $glossarygen->create_content($glossary, ['concept' => 'Entry3', 'definition' => 'plugh', 'userid' => $user3->id]); // Index the data. $this->search->index(); // Search without user restriction should find everything. $querydata = new \stdClass(); $querydata->q = 'plugh'; $results = $this->search->search($querydata); $this->assert_result_titles( ['Entry1', 'Entry3', 'Post1', 'Post2', 'Post3'], $results); // Restriction to user 3 only. $querydata->userids = [$user3->id]; $results = $this->search->search($querydata); $this->assert_result_titles( ['Entry3', 'Post3'], $results); // Restriction to users 1 and 2. $querydata->userids = [$user1->id, $user2->id]; $results = $this->search->search($querydata); $this->assert_result_titles( ['Entry1', 'Post1', 'Post2'], $results); // Restriction to users 1 and 2 combined with context restriction. $querydata->contextids = [\context_module::instance($glossary->cmid)->id]; $results = $this->search->search($querydata); $this->assert_result_titles( ['Entry1'], $results); // Restriction to users 1 and 2 combined with area restriction. unset($querydata->contextids); $querydata->areaids = [\core_search\manager::generate_areaid('mod_forum', 'post')]; $results = $this->search->search($querydata); $this->assert_result_titles( ['Post1', 'Post2'], $results); } /** * Tests searching for results containing words in italic text. (This used to fail.) */ public function test_italics() { global $USER; // Use real search areas. $this->search->clear_static(); $this->search->add_core_search_areas(); // Create a course and a forum. $generator = $this->getDataGenerator(); $course = $generator->create_course(); $forum = $generator->create_module('forum', ['course' => $course->id]); // As admin user, create forum discussions with various words in italics or with underlines. $this->setAdminUser(); $forumgen = $generator->get_plugin_generator('mod_forum'); $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $USER->id, 'name' => 'Post1', 'message' => '<p>This is a post about <i>frogs</i>.</p>']); $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $USER->id, 'name' => 'Post2', 'message' => '<p>This is a post about <i>toads and zombies</i>.</p>']); $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $USER->id, 'name' => 'Post3', 'message' => '<p>This is a post about toads_and_zombies.</p>']); $forumgen->create_discussion(['course' => $course->id, 'forum' => $forum->id, 'userid' => $USER->id, 'name' => 'Post4', 'message' => '<p>This is a post about _leading and trailing_ underlines.</p>']); // Index the data. $this->search->index(); // Search for 'frogs' should find the post. $querydata = new \stdClass(); $querydata->q = 'frogs'; $results = $this->search->search($querydata); $this->assert_result_titles(['Post1'], $results); // Search for 'toads' or 'zombies' should find post 2 (and not 3)... $querydata->q = 'toads'; $results = $this->search->search($querydata); $this->assert_result_titles(['Post2'], $results); $querydata->q = 'zombies'; $results = $this->search->search($querydata); $this->assert_result_titles(['Post2'], $results); // Search for 'toads_and_zombies' should find post 3. $querydata->q = 'toads_and_zombies'; $results = $this->search->search($querydata); $this->assert_result_titles(['Post3'], $results); // Search for '_leading' or 'trailing_' should find post 4. $querydata->q = '_leading'; $results = $this->search->search($querydata); $this->assert_result_titles(['Post4'], $results); $querydata->q = 'trailing_'; $results = $this->search->search($querydata); $this->assert_result_titles(['Post4'], $results); } /** * Asserts that the returned documents have the expected titles (regardless of order). * * @param string[] $expected List of expected document titles * @param \core_search\document[] $results List of returned documents */ protected function assert_result_titles(array $expected, array $results) { $titles = []; foreach ($results as $result) { $titles[] = $result->get('title'); } sort($titles); sort($expected); $this->assertEquals($expected, $titles); } /** * Tests the get_supported_orders function for contexts where we can only use relevance * (system, category). */ public function test_get_supported_orders_relevance_only() { global $DB; // System or category context: relevance only. $orders = $this->engine->get_supported_orders(\context_system::instance()); $this->assertCount(1, $orders); $this->assertArrayHasKey('relevance', $orders); $categoryid = $DB->get_field_sql('SELECT MIN(id) FROM {course_categories}'); $orders = $this->engine->get_supported_orders(\context_coursecat::instance($categoryid)); $this->assertCount(1, $orders); $this->assertArrayHasKey('relevance', $orders); } /** * Tests the get_supported_orders function for contexts where we support location as well * (course, activity, block). */ public function test_get_supported_orders_relevance_and_location() { global $DB; // Test with course context. $generator = $this->getDataGenerator(); $course = $generator->create_course(['fullname' => 'Frogs']); $coursecontext = \context_course::instance($course->id); $orders = $this->engine->get_supported_orders($coursecontext); $this->assertCount(2, $orders); $this->assertArrayHasKey('relevance', $orders); $this->assertArrayHasKey('location', $orders); $this->assertStringContainsString('Course: Frogs', $orders['location']); // Test with activity context. $page = $generator->create_module('page', ['course' => $course->id, 'name' => 'Toads']); $orders = $this->engine->get_supported_orders(\context_module::instance($page->cmid)); $this->assertCount(2, $orders); $this->assertArrayHasKey('relevance', $orders); $this->assertArrayHasKey('location', $orders); $this->assertStringContainsString('Page: Toads', $orders['location']); // Test with block context. $instance = (object)['blockname' => 'html', 'parentcontextid' => $coursecontext->id, 'showinsubcontexts' => 0, 'pagetypepattern' => 'course-view-*', 'defaultweight' => 0, 'timecreated' => 1, 'timemodified' => 1, 'configdata' => '']; $blockid = $DB->insert_record('block_instances', $instance); $blockcontext = \context_block::instance($blockid); $orders = $this->engine->get_supported_orders($blockcontext); $this->assertCount(2, $orders); $this->assertArrayHasKey('relevance', $orders); $this->assertArrayHasKey('location', $orders); $this->assertStringContainsString('Block: Text', $orders['location']); } /** * Tests ordering by relevance vs location. */ public function test_ordering() { // Create 2 courses and 2 activities. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(['fullname' => 'Course 1']); $course1context = \context_course::instance($course1->id); $course1page = $generator->create_module('page', ['course' => $course1]); $course1pagecontext = \context_module::instance($course1page->cmid); $course2 = $generator->create_course(['fullname' => 'Course 2']); $course2context = \context_course::instance($course2->id); $course2page = $generator->create_module('page', ['course' => $course2]); $course2pagecontext = \context_module::instance($course2page->cmid); // Create one search record in each activity and course. $this->create_search_record($course1->id, $course1context->id, 'C1', 'Xyzzy'); $this->create_search_record($course1->id, $course1pagecontext->id, 'C1P', 'Xyzzy'); $this->create_search_record($course2->id, $course2context->id, 'C2', 'Xyzzy'); $this->create_search_record($course2->id, $course2pagecontext->id, 'C2P', 'Xyzzy plugh'); $this->search->index(); // Default search works by relevance so the one with both words should be top. $querydata = new \stdClass(); $querydata->q = 'xyzzy plugh'; $results = $this->search->search($querydata); $this->assertCount(4, $results); $this->assertEquals('C2P', $results[0]->get('title')); // Same if you explicitly specify relevance. $querydata->order = 'relevance'; $results = $this->search->search($querydata); $this->assertEquals('C2P', $results[0]->get('title')); // If you specify order by location and you are in C2 or C2P then results are the same. $querydata->order = 'location'; $querydata->context = $course2context; $results = $this->search->search($querydata); $this->assertEquals('C2P', $results[0]->get('title')); $querydata->context = $course2pagecontext; $results = $this->search->search($querydata); $this->assertEquals('C2P', $results[0]->get('title')); // But if you are in C1P then you get different results (C1P first). $querydata->context = $course1pagecontext; $results = $this->search->search($querydata); $this->assertEquals('C1P', $results[0]->get('title')); } /** * Tests with bogus content (that can be entered into Moodle) to see if it crashes. */ public function test_bogus_content() { $generator = $this->getDataGenerator(); $course1 = $generator->create_course(['fullname' => 'Course 1']); $course1context = \context_course::instance($course1->id); // It is possible to enter into a Moodle database content containing these characters, // which are Unicode non-characters / byte order marks. If sent to Solr, these cause // failures. $boguscontent = html_entity_decode('', ENT_COMPAT) . 'frog'; $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent); $boguscontent = html_entity_decode('', ENT_COMPAT) . 'frog'; $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent); // Unicode Standard Version 9.0 - Core Specification, section 23.7, lists 66 non-characters // in total. Here are some of them - these work OK for me but it may depend on platform. $boguscontent = html_entity_decode('', ENT_COMPAT) . 'frog'; $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent); $boguscontent = html_entity_decode('', ENT_COMPAT) . 'frog'; $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent); $boguscontent = html_entity_decode('', ENT_COMPAT) . 'frog'; $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent); $boguscontent = html_entity_decode('', ENT_COMPAT) . 'frog'; $this->create_search_record($course1->id, $course1context->id, 'C1', $boguscontent); // Do the indexing (this will check it doesn't throw warnings). $this->search->index(); // Confirm that all 6 documents are found in search. $querydata = new \stdClass(); $querydata->q = 'frog'; $results = $this->search->search($querydata); $this->assertCount(6, $results); } /** * Adds a record to the mock search area, so that the search engine can find it later. * * @param int $courseid Course id * @param int $contextid Context id * @param string $title Title for search index * @param string $content Content for search index */ protected function create_search_record($courseid, $contextid, $title, $content) { $record = new \stdClass(); $record->content = $content; $record->title = $title; $record->courseid = $courseid; $record->contextid = $contextid; $this->generator->create_record($record); } /** * Tries out deleting data for a context or a course. * * @throws coding_exception * @throws moodle_exception */ public function test_deleted_contexts_and_courses() { // Create some courses and activities. $generator = $this->getDataGenerator(); $course1 = $generator->create_course(['fullname' => 'Course 1']); $course1context = \context_course::instance($course1->id); $course1page1 = $generator->create_module('page', ['course' => $course1]); $course1page1context = \context_module::instance($course1page1->cmid); $course1page2 = $generator->create_module('page', ['course' => $course1]); $course1page2context = \context_module::instance($course1page2->cmid); $course2 = $generator->create_course(['fullname' => 'Course 2']); $course2context = \context_course::instance($course2->id); $course2page = $generator->create_module('page', ['course' => $course2]); $course2pagecontext = \context_module::instance($course2page->cmid); // Create one search record in each activity and course. $this->create_search_record($course1->id, $course1context->id, 'C1', 'Xyzzy'); $this->create_search_record($course1->id, $course1page1context->id, 'C1P1', 'Xyzzy'); $this->create_search_record($course1->id, $course1page2context->id, 'C1P2', 'Xyzzy'); $this->create_search_record($course2->id, $course2context->id, 'C2', 'Xyzzy'); $this->create_search_record($course2->id, $course2pagecontext->id, 'C2P', 'Xyzzy plugh'); $this->search->index(); // By default we have all results. $this->assert_raw_solr_query_result('content:xyzzy', ['C1', 'C1P1', 'C1P2', 'C2', 'C2P']); // Say we delete the course2pagecontext... $this->engine->delete_index_for_context($course2pagecontext->id); $this->assert_raw_solr_query_result('content:xyzzy', ['C1', 'C1P1', 'C1P2', 'C2']); // Now delete the second course... $this->engine->delete_index_for_course($course2->id); $this->assert_raw_solr_query_result('content:xyzzy', ['C1', 'C1P1', 'C1P2']); // Finally let's delete using Moodle functions to check that works. Single context first. course_delete_module($course1page1->cmid); $this->assert_raw_solr_query_result('content:xyzzy', ['C1', 'C1P2']); delete_course($course1, false); $this->assert_raw_solr_query_result('content:xyzzy', []); } /** * Specific test of the add_document_batch function (also used in many other tests). */ public function test_add_document_batch() { // Get a default document. $area = new \core_mocksearch\search\mock_search_area(); $record = $this->generator->create_record(); $doc = $area->get_document($record); $originalid = $doc->get('id'); // Now create 5 similar documents. $docs = []; for ($i = 1; $i <= 5; $i++) { $doc = $area->get_document($record); $doc->set('id', $originalid . '-' . $i); $doc->set('title', 'Batch ' . $i); $docs[$i] = $doc; } // Document 3 has a file attached. $fs = get_file_storage(); $filerecord = new \stdClass(); $filerecord->content = 'Some FileContents'; $file = $this->generator->create_file($filerecord); $docs[3]->add_stored_file($file); // Add all these documents to the search engine. $this->assertEquals([5, 0, 1], $this->engine->add_document_batch($docs, true)); $this->engine->area_index_complete($area->get_area_id()); // Check all documents were indexed. $querydata = new \stdClass(); $querydata->q = 'Batch'; $results = $this->search->search($querydata); $this->assertCount(5, $results); // Check it also finds based on the file. $querydata->q = 'FileContents'; $results = $this->search->search($querydata); $this->assertCount(1, $results); } /** * Tests the batching logic, specifically the limit to 100 documents per * batch, and not batching very large documents. */ public function test_batching() { $area = new \core_mocksearch\search\mock_search_area(); $record = $this->generator->create_record(); $doc = $area->get_document($record); $originalid = $doc->get('id'); // Up to 100 documents in 1 batch. $docs = []; for ($i = 1; $i <= 100; $i++) { $doc = $area->get_document($record); $doc->set('id', $originalid . '-' . $i); $docs[$i] = $doc; } [, , , , , $batches] = $this->engine->add_documents( new \ArrayIterator($docs), $area, ['indexfiles' => true]); $this->assertEquals(1, $batches); // More than 100 needs 2 batches. $docs = []; for ($i = 1; $i <= 101; $i++) { $doc = $area->get_document($record); $doc->set('id', $originalid . '-' . $i); $docs[$i] = $doc; } [, , , , , $batches] = $this->engine->add_documents( new \ArrayIterator($docs), $area, ['indexfiles' => true]); $this->assertEquals(2, $batches); // Small number but with some large documents that aren't batched. $docs = []; for ($i = 1; $i <= 10; $i++) { $doc = $area->get_document($record); $doc->set('id', $originalid . '-' . $i); $docs[$i] = $doc; } // This one is just small enough to fit. $docs[3]->set('content', str_pad('xyzzy ', 1024 * 1024, 'x')); // These two don't fit. $docs[5]->set('content', str_pad('xyzzy ', 1024 * 1024 + 1, 'x')); $docs[6]->set('content', str_pad('xyzzy ', 1024 * 1024 + 1, 'x')); [, , , , , $batches] = $this->engine->add_documents( new \ArrayIterator($docs), $area, ['indexfiles' => true]); $this->assertEquals(3, $batches); // Check that all 3 of the large documents (added as batch or not) show up in results. $this->engine->area_index_complete($area->get_area_id()); $querydata = new \stdClass(); $querydata->q = 'xyzzy'; $results = $this->search->search($querydata); $this->assertCount(3, $results); } /** * Tests with large documents. The point of this test is that we stop batching * documents if they are bigger than 1MB, and the maximum batch count is 100, * so the maximum size batch will be about 100 1MB documents. */ public function test_add_document_batch_large() { // This test is a bit slow and not that important to run every time... if (!PHPUNIT_LONGTEST) { $this->markTestSkipped('PHPUNIT_LONGTEST is not defined'); } // Get a default document. $area = new \core_mocksearch\search\mock_search_area(); $record = $this->generator->create_record(); $doc = $area->get_document($record); $originalid = $doc->get('id'); // Now create 100 large documents. $size = 1024 * 1024; $docs = []; for ($i = 1; $i <= 100; $i++) { $doc = $area->get_document($record); $doc->set('id', $originalid . '-' . $i); $doc->set('title', 'Batch ' . $i); $doc->set('content', str_pad('', $size, 'Long text ' . $i . '. ', STR_PAD_RIGHT) . ' xyzzy'); $docs[$i] = $doc; } // Add all these documents to the search engine. $this->engine->add_document_batch($docs, true); $this->engine->area_index_complete($area->get_area_id()); // Check all documents were indexed, searching for text at end. $querydata = new \stdClass(); $querydata->q = 'xyzzy'; $results = $this->search->search($querydata); $this->assertCount(100, $results); // Search for specific text that's only in one. $querydata->q = '42'; $results = $this->search->search($querydata); $this->assertCount(1, $results); } /** * Carries out a raw Solr query using the Solr basic query syntax. * * This is used to test data contained in the index without going through Moodle processing. * * @param string $q Search query * @param string[] $expected Expected titles of results, in alphabetical order */ protected function assert_raw_solr_query_result(string $q, array $expected) { $solr = $this->engine->get_search_client_public(); $query = new \SolrQuery($q); $results = $solr->query($query)->getResponse()->response->docs; if ($results) { $titles = array_map(function($x) { return $x->title; }, $results); sort($titles); } else { $titles = []; } $this->assertEquals($expected, $titles); } } engine/solr/settings.php 0000644 00000016107 15152012435 0011344 0 ustar 00 <?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/>. /** * Solr search engine settings. * * @package search_solr * @copyright 2015 Daniel Neis * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); if ($ADMIN->fulltree) { if (!during_initial_install()) { if (!function_exists('solr_get_version')) { $settings->add(new admin_setting_heading('search_solr_settings', '', get_string('extensionerror', 'search_solr'))); } else { $settings->add(new admin_setting_heading('search_solr_connection', new lang_string('connectionsettings', 'search_solr'), '')); $settings->add(new admin_setting_configtext('search_solr/server_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/indexname', new lang_string('solrindexname', 'search_solr'), '', '', PARAM_ALPHANUMEXT)); $settings->add(new admin_setting_configcheckbox('search_solr/secure', new lang_string('solrsecuremode', 'search_solr'), '', 0, 1, 0)); $secure = get_config('search_solr', 'secure'); $defaultport = !empty($secure) ? 8443 : 8983; $settings->add(new admin_setting_configtext('search_solr/server_port', new lang_string('solrhttpconnectionport', 'search_solr'), '', $defaultport, PARAM_INT)); $settings->add(new admin_setting_configtext('search_solr/server_username', new lang_string('solrauthuser', 'search_solr'), '', '', PARAM_RAW)); $settings->add(new admin_setting_configpasswordunmask('search_solr/server_password', new lang_string('solrauthpassword', 'search_solr'), '', '')); $settings->add(new admin_setting_configtext('search_solr/server_timeout', new lang_string('solrhttpconnectiontimeout', 'search_solr'), new lang_string('solrhttpconnectiontimeout_desc', 'search_solr'), 30, PARAM_INT)); $settings->add(new admin_setting_configtext('search_solr/ssl_cert', new lang_string('solrsslcert', 'search_solr'), new lang_string('solrsslcert_desc', 'search_solr'), '', PARAM_RAW)); $settings->add(new admin_setting_configtext('search_solr/ssl_key', new lang_string('solrsslkey', 'search_solr'), new lang_string('solrsslkey_desc', 'search_solr'), '', PARAM_RAW)); $settings->add(new admin_setting_configpasswordunmask('search_solr/ssl_keypassword', new lang_string('solrsslkeypassword', 'search_solr'), new lang_string('solrsslkeypassword_desc', 'search_solr'), '')); $settings->add(new admin_setting_configtext('search_solr/ssl_cainfo', new lang_string('solrsslcainfo', 'search_solr'), new lang_string('solrsslcainfo_desc', 'search_solr'), '', PARAM_RAW)); $settings->add(new admin_setting_configtext('search_solr/ssl_capath', new lang_string('solrsslcapath', 'search_solr'), new lang_string('solrsslcapath_desc', 'search_solr'), '', PARAM_RAW)); $settings->add(new admin_setting_heading('search_solr_fileindexing', new lang_string('fileindexsettings', 'search_solr'), '')); $settings->add(new admin_setting_configcheckbox('search_solr/fileindexing', new lang_string('fileindexing', 'search_solr'), new lang_string('fileindexing_help', 'search_solr'), 1)); $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)); } } } engine/solr/version.php 0000644 00000001733 15152012435 0011170 0 ustar 00 <?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/>. /** * Version info. * * @package search_solr * @copyright 2015 Daniel Neis Araujo * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->version = 2022112800; $plugin->requires = 2022111800; $plugin->component = 'search_solr'; engine/solr/classes/schema.php 0000644 00000027450 15152012435 0012404 0 ustar 00 <?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/>. /** * Solr schema manipulation manager. * * @package search_solr * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace search_solr; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/lib/filelib.php'); /** * Schema class to interact with Solr schema. * * At the moment it only implements create which should be enough for a basic * moodle configuration in Solr. * * @package search_solr * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class schema { /** * @var stdClass */ protected $config = null; /** * cUrl instance. * @var \curl */ protected $curl = null; /** * An engine instance. * @var engine */ protected $engine = null; /** * Constructor. * * @param engine $engine Optional engine parameter, if not specified then one will be created * @throws \moodle_exception * @return void */ public function __construct(engine $engine = null) { if (!$this->config = get_config('search_solr')) { throw new \moodle_exception('missingconfig', 'search_solr'); } if (empty($this->config->server_hostname) || empty($this->config->indexname)) { throw new \moodle_exception('missingconfig', 'search_solr'); } $this->engine = $engine ?? new engine(); $this->curl = $this->engine->get_curl_object(); // HTTP headers. $this->curl->setHeader('Content-type: application/json'); } /** * Can setup be executed against the configured server. * * @return true|string True or error message. */ public function can_setup_server() { $status = $this->engine->is_server_configured(); if ($status !== true) { return $status; } // At this stage we know that the server is properly configured with a valid host:port and indexname. // We're not too concerned about repeating the SolrClient::system() call (already called in // is_server_configured) because this is just a setup script. if ($this->engine->get_solr_major_version() < 5) { // Schema setup script only available for 5.0 onwards. return get_string('schemasetupfromsolr5', 'search_solr'); } return true; } /** * Setup solr stuff required by moodle. * * @param bool $checkexisting Whether to check if the fields already exist or not * @return bool */ public function setup($checkexisting = true) { $fields = \search_solr\document::get_default_fields_definition(); // Field id is already there. unset($fields['id']); $this->check_index(); $return = $this->add_fields($fields, $checkexisting); // Tell the engine we are now using the latest schema version. $this->engine->record_applied_schema_version(document::SCHEMA_VERSION); return $return; } /** * Checks the schema is properly set up. * * @throws \moodle_exception * @return void */ public function validate_setup() { $fields = \search_solr\document::get_default_fields_definition(); // Field id is already there. unset($fields['id']); $this->check_index(); $this->validate_fields($fields, true); } /** * Checks if the index is ready, triggers an exception otherwise. * * @throws \moodle_exception * @return void */ protected function check_index() { // Check that the server is available and the index exists. $url = $this->engine->get_connection_url('/select?wt=json'); $result = $this->curl->get($url); if ($this->curl->error) { throw new \moodle_exception('connectionerror', 'search_solr'); } if ($this->curl->info['http_code'] === 404) { throw new \moodle_exception('connectionerror', 'search_solr'); } } /** * Adds the provided fields to Solr schema. * * Intentionally separated from create(), it can be called to add extra fields. * fields separately. * * @throws \coding_exception * @throws \moodle_exception * @param array $fields \core_search\document::$requiredfields format * @param bool $checkexisting Whether to check if the fields already exist or not * @return bool */ protected function add_fields($fields, $checkexisting = true) { if ($checkexisting) { // Check that non of them exists. $this->validate_fields($fields, false); } $url = $this->engine->get_connection_url('/schema'); // Add all fields. foreach ($fields as $fieldname => $data) { if (!isset($data['type']) || !isset($data['stored']) || !isset($data['indexed'])) { throw new \coding_exception($fieldname . ' does not define all required field params: type, stored and indexed.'); } $type = $this->doc_field_to_solr_field($data['type']); // Changing default multiValued value to false as we want to match values easily. $params = array( 'add-field' => array( 'name' => $fieldname, 'type' => $type, 'stored' => $data['stored'], 'multiValued' => false, 'indexed' => $data['indexed'] ) ); $results = $this->curl->post($url, json_encode($params)); // We only validate if we are interested on it. if ($checkexisting) { if ($this->curl->error) { throw new \moodle_exception('errorcreatingschema', 'search_solr', '', $this->curl->error); } $this->validate_add_field_result($results); } } return true; } /** * Checks if the schema existing fields are properly set, triggers an exception otherwise. * * @throws \moodle_exception * @param array $fields * @param bool $requireexisting Require the fields to exist, otherwise exception. * @return void */ protected function validate_fields(&$fields, $requireexisting = false) { global $CFG; foreach ($fields as $fieldname => $data) { $url = $this->engine->get_connection_url('/schema/fields/' . $fieldname); $results = $this->curl->get($url); if ($this->curl->error) { throw new \moodle_exception('errorcreatingschema', 'search_solr', '', $this->curl->error); } if (!$results) { throw new \moodle_exception('errorcreatingschema', 'search_solr', '', get_string('nodatafromserver', 'search_solr')); } $results = json_decode($results); if ($requireexisting && !empty($results->error) && $results->error->code === 404) { $a = new \stdClass(); $a->fieldname = $fieldname; $a->setupurl = $CFG->wwwroot . '/search/engine/solr/setup_schema.php'; throw new \moodle_exception('errorvalidatingschema', 'search_solr', '', $a); } // The field should not exist so we only accept 404 errors. if (empty($results->error) || (!empty($results->error) && $results->error->code !== 404)) { if (!empty($results->error)) { throw new \moodle_exception('errorcreatingschema', 'search_solr', '', $results->error->msg); } else { // All these field attributes are set when fields are added through this script and should // be returned and match the defined field's values. $expectedsolrfield = $this->doc_field_to_solr_field($data['type']); if (empty($results->field) || !isset($results->field->type) || !isset($results->field->multiValued) || !isset($results->field->indexed) || !isset($results->field->stored)) { throw new \moodle_exception('errorcreatingschema', 'search_solr', '', get_string('schemafieldautocreated', 'search_solr', $fieldname)); } else if ($results->field->type !== $expectedsolrfield || $results->field->multiValued !== false || $results->field->indexed !== $data['indexed'] || $results->field->stored !== $data['stored']) { throw new \moodle_exception('errorcreatingschema', 'search_solr', '', get_string('schemafieldautocreated', 'search_solr', $fieldname)); } else { // The field already exists and it is properly defined, no need to create it. unset($fields[$fieldname]); } } } } } /** * Checks that the field results do not contain errors. * * @throws \moodle_exception * @param string $results curl response body * @return void */ protected function validate_add_field_result($result) { if (!$result) { throw new \moodle_exception('errorcreatingschema', 'search_solr', '', get_string('nodatafromserver', 'search_solr')); } $results = json_decode($result); if (!$results) { if (is_scalar($result)) { $errormsg = $result; } else { $errormsg = json_encode($result); } throw new \moodle_exception('errorcreatingschema', 'search_solr', '', $errormsg); } // It comes as error when fetching fields data. if (!empty($results->error)) { throw new \moodle_exception('errorcreatingschema', 'search_solr', '', $results->error); } // It comes as errors when adding fields. if (!empty($results->errors)) { // We treat this error separately. $errorstr = ''; foreach ($results->errors as $error) { $errorstr .= implode(', ', $error->errorMessages); } throw new \moodle_exception('errorcreatingschema', 'search_solr', '', $errorstr); } } /** * Returns the solr field type from the document field type string. * * @param string $datatype * @return string */ private function doc_field_to_solr_field($datatype) { $type = $datatype; $solrversion = $this->engine->get_solr_major_version(); switch($datatype) { case 'text': $type = 'text_general'; break; case 'int': if ($solrversion >= 7) { $type = 'pint'; } break; case 'tdate': if ($solrversion >= 7) { $type = 'pdate'; } break; } return $type; } } engine/solr/classes/privacy/provider.php 0000644 00000007771 15152012435 0014457 0 ustar 00 <?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/>. /** * Privacy class for requesting user data. * * @package search_solr * @copyright 2018 David Monllao * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace search_solr\privacy; defined('MOODLE_INTERNAL') || die(); use core_privacy\local\metadata\collection; use core_privacy\local\request\contextlist; use core_privacy\local\request\approved_contextlist; use core_privacy\local\request\approved_userlist; use core_privacy\local\request\userlist; /** * Provider for the search_solr plugin. * * @copyright 2018 David Monllao * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements // This search engine plugin does not store any data itself. // It has no database tables, and it purely acts as a conduit, sending data externally. // This plugin is capable of determining which users have data within it. \core_privacy\local\metadata\provider, \core_privacy\local\request\core_userlist_provider, \core_privacy\local\request\plugin\provider { /** * Returns meta data about this system. * * @param collection $collection The initialised collection to add items to. * @return collection A listing of user data stored through this system. */ public static function get_metadata(collection $collection) : collection { return $collection->add_external_location_link('solr', ['data' => 'privacy:metadata:data'], 'privacy:metadata'); } /** * Get the list of contexts that contain user information for the specified user. * * @param int $userid The user to search. * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin. */ public static function get_contexts_for_userid(int $userid) : contextlist { return new contextlist(); } /** * Get the list of users who have data within a context. * * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination. */ public static function get_users_in_context(userlist $userlist) { } /** * Export all user data for the specified user, in the specified contexts. * * @param approved_contextlist $contextlist The approved contexts to export information for. */ public static function export_user_data(approved_contextlist $contextlist) { } /** * Delete all data for all users in the specified context. * * @param context $context The specific context to delete data for. */ public static function delete_data_for_all_users_in_context(\context $context) { } /** * Delete all user data for the specified user, in the specified contexts. * * @param approved_contextlist $contextlist The approved contexts and user information to delete information for. */ public static function delete_data_for_user(approved_contextlist $contextlist) { } /** * Delete multiple users within a single context. * * @param approved_userlist $userlist The approved context and user information to delete information for. */ public static function delete_data_for_users(approved_userlist $userlist) { } } engine/solr/classes/engine.php 0000644 00000170263 15152012435 0012412 0 ustar 00 <?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/>. /** * Solr engine. * * @package search_solr * @copyright 2015 Daniel Neis Araujo * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace search_solr; defined('MOODLE_INTERNAL') || die(); /** * Solr engine. * * @package search_solr * @copyright 2015 Daniel Neis Araujo * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class engine extends \core_search\engine { /** * @var string The date format used by solr. */ const DATE_FORMAT = 'Y-m-d\TH:i:s\Z'; /** * @var int Commit documents interval (number of miliseconds). */ const AUTOCOMMIT_WITHIN = 15000; /** * The maximum number of results to fetch at a time. */ const QUERY_SIZE = 120; /** * Highlighting fragsize. Slightly larger than output size (500) to allow for ... appending. */ const FRAG_SIZE = 510; /** * Marker for the start of a highlight. */ const HIGHLIGHT_START = '@@HI_S@@'; /** * Marker for the end of a highlight. */ const HIGHLIGHT_END = '@@HI_E@@'; /** @var float Boost value for matching course in location-ordered searches */ const COURSE_BOOST = 1; /** @var float Boost value for matching context (in addition to course boost) */ const CONTEXT_BOOST = 0.5; /** * @var \SolrClient */ protected $client = null; /** * @var bool True if we should reuse SolrClients, false if not. */ protected $cacheclient = true; /** * @var \curl Direct curl object. */ protected $curl = null; /** * @var array Fields that can be highlighted. */ protected $highlightfields = array('title', 'content', 'description1', 'description2'); /** * @var int Number of total docs reported by Sorl for the last query. */ protected $totalenginedocs = 0; /** * @var int Number of docs we have processed for the last query. */ protected $processeddocs = 0; /** * @var int Number of docs that have been skipped while processing the last query. */ protected $skippeddocs = 0; /** * Solr server major version. * * @var int */ protected $solrmajorversion = null; /** * Initialises the search engine configuration. * * @param bool $alternateconfiguration If true, use alternate configuration settings * @return void */ public function __construct(bool $alternateconfiguration = false) { parent::__construct($alternateconfiguration); $curlversion = curl_version(); if (isset($curlversion['version']) && stripos($curlversion['version'], '7.35.') === 0) { // There is a flaw with curl 7.35.0 that causes problems with client reuse. $this->cacheclient = false; } } /** * Prepares a Solr query, applies filters and executes it returning its results. * * @throws \core_search\engine_exception * @param \stdClass $filters Containing query and filters. * @param \stdClass $accessinfo Information about areas user can access. * @param int $limit The maximum number of results to return. * @return \core_search\document[] Results or false if no results */ public function execute_query($filters, $accessinfo, $limit = 0) { global $USER; if (empty($limit)) { $limit = \core_search\manager::MAX_RESULTS; } // If there is any problem we trigger the exception as soon as possible. $client = $this->get_search_client(); // Create the query object. $query = $this->create_user_query($filters, $accessinfo); // If the query cannot have results, return none. if (!$query) { return []; } // We expect good match rates, so for our first get, we will get a small number of records. // This significantly speeds solr response time for first few pages. $query->setRows(min($limit * 3, static::QUERY_SIZE)); $response = $this->get_query_response($query); // Get count data out of the response, and reset our counters. list($included, $found) = $this->get_response_counts($response); $this->totalenginedocs = $found; $this->processeddocs = 0; $this->skippeddocs = 0; if ($included == 0 || $this->totalenginedocs == 0) { // No results. return array(); } // Get valid documents out of the response. $results = $this->process_response($response, $limit); // We have processed all the docs in the response at this point. $this->processeddocs += $included; // If we haven't reached the limit, and there are more docs left in Solr, lets keep trying. while (count($results) < $limit && ($this->totalenginedocs - $this->processeddocs) > 0) { // Offset the start of the query, and since we are making another call, get more per call. $query->setStart($this->processeddocs); $query->setRows(static::QUERY_SIZE); $response = $this->get_query_response($query); list($included, $found) = $this->get_response_counts($response); if ($included == 0 || $found == 0) { // No new results were found. Found being empty would be weird, so we will just return. return $results; } $this->totalenginedocs = $found; // Get the new response docs, limiting to remaining we need, then add it to the end of the results array. $newdocs = $this->process_response($response, $limit - count($results)); $results = array_merge($results, $newdocs); // Add to our processed docs count. $this->processeddocs += $included; } return $results; } /** * Takes a query and returns the response in SolrObject format. * * @param SolrQuery $query Solr query object. * @return SolrObject|false Response document or false on error. */ protected function get_query_response($query) { try { return $this->get_search_client()->query($query)->getResponse(); } catch (\SolrClientException $ex) { debugging('Error executing the provided query: ' . $ex->getMessage(), DEBUG_DEVELOPER); $this->queryerror = $ex->getMessage(); return false; } catch (\SolrServerException $ex) { debugging('Error executing the provided query: ' . $ex->getMessage(), DEBUG_DEVELOPER); $this->queryerror = $ex->getMessage(); return false; } } /** * Returns the total number of documents available for the most recently call to execute_query. * * @return int */ public function get_query_total_count() { // Return the total engine count minus the docs we have determined are bad. return $this->totalenginedocs - $this->skippeddocs; } /** * Returns count information for a provided response. Will return 0, 0 for invalid or empty responses. * * @param SolrDocument $response The response document from Solr. * @return array A two part array. First how many response docs are in the response. * Second, how many results are vailable in the engine. */ protected function get_response_counts($response) { $found = 0; $included = 0; if (isset($response->grouped->solr_filegroupingid->ngroups)) { // Get the number of results for file grouped queries. $found = $response->grouped->solr_filegroupingid->ngroups; $included = count($response->grouped->solr_filegroupingid->groups); } else if (isset($response->response->numFound)) { // Get the number of results for standard queries. $found = $response->response->numFound; if ($found > 0 && is_array($response->response->docs)) { $included = count($response->response->docs); } } return array($included, $found); } /** * Prepares a new query object with needed limits, filters, etc. * * @param \stdClass $filters Containing query and filters. * @param \stdClass $accessinfo Information about contexts the user can access * @return \SolrDisMaxQuery|null Query object or null if they can't get any results */ protected function create_user_query($filters, $accessinfo) { global $USER; // Let's keep these changes internal. $data = clone $filters; $query = new \SolrDisMaxQuery(); $this->set_query($query, self::replace_underlines($data->q)); $this->add_fields($query); // Search filters applied, we don't cache these filters as we don't want to pollute the cache with tmp filters // we are really interested in caching contexts filters instead. if (!empty($data->title)) { $query->addFilterQuery('{!field cache=false f=title}' . $data->title); } if (!empty($data->areaids)) { // If areaids are specified, we want to get any that match. $query->addFilterQuery('{!cache=false}areaid:(' . implode(' OR ', $data->areaids) . ')'); } if (!empty($data->courseids)) { $query->addFilterQuery('{!cache=false}courseid:(' . implode(' OR ', $data->courseids) . ')'); } if (!empty($data->groupids)) { $query->addFilterQuery('{!cache=false}groupid:(' . implode(' OR ', $data->groupids) . ')'); } if (!empty($data->userids)) { $query->addFilterQuery('{!cache=false}userid:(' . implode(' OR ', $data->userids) . ')'); } if (!empty($data->timestart) or !empty($data->timeend)) { if (empty($data->timestart)) { $data->timestart = '*'; } else { $data->timestart = \search_solr\document::format_time_for_engine($data->timestart); } if (empty($data->timeend)) { $data->timeend = '*'; } else { $data->timeend = \search_solr\document::format_time_for_engine($data->timeend); } // No cache. $query->addFilterQuery('{!cache=false}modified:[' . $data->timestart . ' TO ' . $data->timeend . ']'); } // Restrict to users who are supposed to be able to see a particular result. $query->addFilterQuery('owneruserid:(' . \core_search\manager::NO_OWNER_ID . ' OR ' . $USER->id . ')'); // And finally restrict it to the context where the user can access, we want this one cached. // If the user can access all contexts $usercontexts value is just true, we don't need to filter // in that case. if (!$accessinfo->everything && is_array($accessinfo->usercontexts)) { // Join all area contexts into a single array and implode. $allcontexts = array(); foreach ($accessinfo->usercontexts as $areaid => $areacontexts) { if (!empty($data->areaids) && !in_array($areaid, $data->areaids)) { // Skip unused areas. continue; } foreach ($areacontexts as $contextid) { // Ensure they are unique. $allcontexts[$contextid] = $contextid; } } if (empty($allcontexts)) { // This means there are no valid contexts for them, so they get no results. return null; } $query->addFilterQuery('contextid:(' . implode(' OR ', $allcontexts) . ')'); } if (!$accessinfo->everything && $accessinfo->separategroupscontexts) { // Add another restriction to handle group ids. If there are any contexts using separate // groups, then results in that context will not show unless you belong to the group. // (Note: Access all groups is taken care of earlier, when computing these arrays.) // This special exceptions list allows for particularly pig-headed developers to create // multiple search areas within the same module, where one of them uses separate // groups and the other uses visible groups. It is a little inefficient, but this should // be rare. $exceptions = ''; if ($accessinfo->visiblegroupscontextsareas) { foreach ($accessinfo->visiblegroupscontextsareas as $contextid => $areaids) { $exceptions .= ' OR (contextid:' . $contextid . ' AND areaid:(' . implode(' OR ', $areaids) . '))'; } } if ($accessinfo->usergroups) { // Either the document has no groupid, or the groupid is one that the user // belongs to, or the context is not one of the separate groups contexts. $query->addFilterQuery('(*:* -groupid:[* TO *]) OR ' . 'groupid:(' . implode(' OR ', $accessinfo->usergroups) . ') OR ' . '(*:* -contextid:(' . implode(' OR ', $accessinfo->separategroupscontexts) . '))' . $exceptions); } else { // Either the document has no groupid, or the context is not a restricted one. $query->addFilterQuery('(*:* -groupid:[* TO *]) OR ' . '(*:* -contextid:(' . implode(' OR ', $accessinfo->separategroupscontexts) . '))' . $exceptions); } } if ($this->file_indexing_enabled()) { // Now group records by solr_filegroupingid. Limit to 3 results per group. $query->setGroup(true); $query->setGroupLimit(3); $query->setGroupNGroups(true); $query->addGroupField('solr_filegroupingid'); } else { // Make sure we only get text files, in case the index has pre-existing files. $query->addFilterQuery('type:'.\core_search\manager::TYPE_TEXT); } // If ordering by location, add in boost for the relevant course or context ids. if (!empty($filters->order) && $filters->order === 'location') { $coursecontext = $filters->context->get_course_context(); $query->addBoostQuery('courseid', $coursecontext->instanceid, self::COURSE_BOOST); if ($filters->context->contextlevel !== CONTEXT_COURSE) { // If it's a block or activity, also add a boost for the specific context id. $query->addBoostQuery('contextid', $filters->context->id, self::CONTEXT_BOOST); } } return $query; } /** * Prepares a new query by setting the query, start offset and rows to return. * * @param SolrQuery $query * @param object $q Containing query and filters. */ protected function set_query($query, $q) { // Set hightlighting. $query->setHighlight(true); foreach ($this->highlightfields as $field) { $query->addHighlightField($field); } $query->setHighlightFragsize(static::FRAG_SIZE); $query->setHighlightSimplePre(self::HIGHLIGHT_START); $query->setHighlightSimplePost(self::HIGHLIGHT_END); $query->setHighlightMergeContiguous(true); $query->setQuery($q); // A reasonable max. $query->setRows(static::QUERY_SIZE); } /** * Sets fields to be returned in the result. * * @param SolrDisMaxQuery|SolrQuery $query object. */ public function add_fields($query) { $documentclass = $this->get_document_classname(); $fields = $documentclass::get_default_fields_definition(); $dismax = false; if ($query instanceof \SolrDisMaxQuery) { $dismax = true; } foreach ($fields as $key => $field) { $query->addField($key); if ($dismax && !empty($field['mainquery'])) { // Add fields the main query should be run against. // Due to a regression in the PECL solr extension, https://bugs.php.net/bug.php?id=72740, // a boost value is required, even if it is optional; to avoid boosting one among other fields, // the explicit boost value will be the default one, for every field. $query->addQueryField($key, 1); } } } /** * Finds the key common to both highlighing and docs array returned from response. * @param object $response containing results. */ public function add_highlight_content($response) { if (!isset($response->highlighting)) { // There is no highlighting to add. return; } $highlightedobject = $response->highlighting; foreach ($response->response->docs as $doc) { $x = $doc->id; $highlighteddoc = $highlightedobject->$x; $this->merge_highlight_field_values($doc, $highlighteddoc); } } /** * Adds the highlighting array values to docs array values. * * @throws \core_search\engine_exception * @param object $doc containing the results. * @param object $highlighteddoc containing the highlighted results values. */ public function merge_highlight_field_values($doc, $highlighteddoc) { foreach ($this->highlightfields as $field) { if (!empty($doc->$field)) { // Check that the returned value is not an array. No way we can make this work with multivalued solr fields. if (is_array($doc->{$field})) { throw new \core_search\engine_exception('multivaluedfield', 'search_solr', '', $field); } if (!empty($highlighteddoc->$field)) { // Replace by the highlighted result. $doc->$field = reset($highlighteddoc->$field); } } } } /** * Filters the response on Moodle side. * * @param SolrObject $response Solr object containing the response return from solr server. * @param int $limit The maximum number of results to return. 0 for all. * @param bool $skipaccesscheck Don't use check_access() on results. Only to be used when results have known access. * @return array $results containing final results to be displayed. */ protected function process_response($response, $limit = 0, $skipaccesscheck = false) { global $USER; if (empty($response)) { return array(); } if (isset($response->grouped)) { return $this->grouped_files_process_response($response, $limit); } $userid = $USER->id; $noownerid = \core_search\manager::NO_OWNER_ID; $numgranted = 0; if (!$docs = $response->response->docs) { return array(); } $out = array(); if (!empty($response->response->numFound)) { $this->add_highlight_content($response); // Iterate through the results checking its availability and whether they are available for the user or not. foreach ($docs as $key => $docdata) { if ($docdata['owneruserid'] != $noownerid && $docdata['owneruserid'] != $userid) { // If owneruserid is set, no other user should be able to access this record. continue; } if (!$searcharea = $this->get_search_area($docdata->areaid)) { continue; } $docdata = $this->standarize_solr_obj($docdata); if ($skipaccesscheck) { $access = \core_search\manager::ACCESS_GRANTED; } else { $access = $searcharea->check_access($docdata['itemid']); } switch ($access) { case \core_search\manager::ACCESS_DELETED: $this->delete_by_id($docdata['id']); // Remove one from our processed and total counters, since we promptly deleted. $this->processeddocs--; $this->totalenginedocs--; break; case \core_search\manager::ACCESS_DENIED: $this->skippeddocs++; break; case \core_search\manager::ACCESS_GRANTED: $numgranted++; // Add the doc. $out[] = $this->to_document($searcharea, $docdata); break; } // Stop when we hit our limit. if (!empty($limit) && count($out) >= $limit) { break; } } } return $out; } /** * Processes grouped file results into documents, with attached matching files. * * @param SolrObject $response The response returned from solr server * @param int $limit The maximum number of results to return. 0 for all. * @return array Final results to be displayed. */ protected function grouped_files_process_response($response, $limit = 0) { // If we can't find the grouping, or there are no matches in the grouping, return empty. if (!isset($response->grouped->solr_filegroupingid) || empty($response->grouped->solr_filegroupingid->matches)) { return array(); } $numgranted = 0; $orderedids = array(); $completedocs = array(); $incompletedocs = array(); $highlightingobj = $response->highlighting; // Each group represents a "master document". $groups = $response->grouped->solr_filegroupingid->groups; foreach ($groups as $group) { $groupid = $group->groupValue; $groupdocs = $group->doclist->docs; $firstdoc = reset($groupdocs); if (!$searcharea = $this->get_search_area($firstdoc->areaid)) { // Well, this is a problem. continue; } // Check for access. $access = $searcharea->check_access($firstdoc->itemid); switch ($access) { case \core_search\manager::ACCESS_DELETED: // If deleted from Moodle, delete from index and then continue. $this->delete_by_id($firstdoc->id); // Remove one from our processed and total counters, since we promptly deleted. $this->processeddocs--; $this->totalenginedocs--; continue 2; break; case \core_search\manager::ACCESS_DENIED: // This means we should just skip for the current user. $this->skippeddocs++; continue 2; break; } $numgranted++; $maindoc = false; $fileids = array(); // Seperate the main document and any files returned. foreach ($groupdocs as $groupdoc) { if ($groupdoc->id == $groupid) { $maindoc = $groupdoc; } else if (isset($groupdoc->solr_fileid)) { $fileids[] = $groupdoc->solr_fileid; } } // Store the id of this group, in order, for later merging. $orderedids[] = $groupid; if (!$maindoc) { // We don't have the main doc, store what we know for later building. $incompletedocs[$groupid] = $fileids; } else { if (isset($highlightingobj->$groupid)) { // Merge the highlighting for this doc. $this->merge_highlight_field_values($maindoc, $highlightingobj->$groupid); } $docdata = $this->standarize_solr_obj($maindoc); $doc = $this->to_document($searcharea, $docdata); // Now we need to attach the result files to the doc. foreach ($fileids as $fileid) { $doc->add_stored_file($fileid); } $completedocs[$groupid] = $doc; } if (!empty($limit) && $numgranted >= $limit) { // We have hit the max results, we will just ignore the rest. break; } } $incompletedocs = $this->get_missing_docs($incompletedocs); $out = array(); // Now merge the complete and incomplete documents, in results order. foreach ($orderedids as $docid) { if (isset($completedocs[$docid])) { $out[] = $completedocs[$docid]; } else if (isset($incompletedocs[$docid])) { $out[] = $incompletedocs[$docid]; } } return $out; } /** * Retreive any missing main documents and attach provided files. * * The missingdocs array should be an array, indexed by document id, of main documents we need to retrieve. The value * associated to the key should be an array of stored_files or stored file ids to attach to the result document. * * Return array also indexed by document id. * * @param array() $missingdocs An array, indexed by document id, with arrays of files/ids to attach. * @return document[] */ protected function get_missing_docs($missingdocs) { if (empty($missingdocs)) { return array(); } $docids = array_keys($missingdocs); // Build a custom query that will get all the missing documents. $query = new \SolrQuery(); $this->set_query($query, '*'); $this->add_fields($query); $query->setRows(count($docids)); $query->addFilterQuery('{!cache=false}id:(' . implode(' OR ', $docids) . ')'); $response = $this->get_query_response($query); // We know the missing docs have already been checked for access, so don't recheck. $results = $this->process_response($response, 0, true); $out = array(); foreach ($results as $result) { $resultid = $result->get('id'); if (!isset($missingdocs[$resultid])) { // We got a result we didn't expect. Skip it. continue; } // Attach the files. foreach ($missingdocs[$resultid] as $filedoc) { $result->add_stored_file($filedoc); } $out[$resultid] = $result; } return $out; } /** * Returns a standard php array from a \SolrObject instance. * * @param \SolrObject $obj * @return array The returned document as an array. */ public function standarize_solr_obj(\SolrObject $obj) { $properties = $obj->getPropertyNames(); $docdata = array(); foreach($properties as $name) { // http://php.net/manual/en/solrobject.getpropertynames.php#98018. $name = trim($name); $docdata[$name] = $obj->offsetGet($name); } return $docdata; } /** * Adds a document to the search engine. * * This does not commit to the search engine. * * @param document $document * @param bool $fileindexing True if file indexing is to be used * @return bool */ public function add_document($document, $fileindexing = false) { $docdata = $document->export_for_engine(); if (!$this->add_solr_document($docdata)) { return false; } if ($fileindexing) { // This will take care of updating all attached files in the index. $this->process_document_files($document); } return true; } /** * Adds a batch of documents to the engine at once. * * @param \core_search\document[] $documents Documents to add * @param bool $fileindexing If true, indexes files (these are done one at a time) * @return int[] Array of three elements: successfully processed, failed processed, batch count */ public function add_document_batch(array $documents, bool $fileindexing = false): array { $docdatabatch = []; foreach ($documents as $document) { $docdatabatch[] = $document->export_for_engine(); } $resultcounts = $this->add_solr_documents($docdatabatch); // Files are processed one document at a time (if there are files it's slow anyway). if ($fileindexing) { foreach ($documents as $document) { // This will take care of updating all attached files in the index. $this->process_document_files($document); } } return $resultcounts; } /** * Replaces underlines at edges of words in the content with spaces. * * For example '_frogs_' will become 'frogs', '_frogs and toads_' will become 'frogs and toads', * and 'frogs_and_toads' will be left as 'frogs_and_toads'. * * The reason for this is that for italic content_to_text puts _italic_ underlines at the start * and end of the italicised phrase (not between words). Solr treats underlines as part of the * word, which means that if you search for a word in italic then you can't find it. * * @param string $str String to replace * @return string Replaced string */ protected static function replace_underlines(string $str): string { return preg_replace('~\b_|_\b~', '', $str); } /** * Creates a Solr document object. * * @param array $doc Array of document fields * @return \SolrInputDocument Created document */ protected function create_solr_document(array $doc): \SolrInputDocument { $solrdoc = new \SolrInputDocument(); // Replace underlines in the content with spaces. The reason for this is that for italic // text, content_to_text puts _italic_ underlines. Solr treats underlines as part of the // word, which means that if you search for a word in italic then you can't find it. if (array_key_exists('content', $doc)) { $doc['content'] = self::replace_underlines($doc['content']); } // Set all the fields. foreach ($doc as $field => $value) { $solrdoc->addField($field, $value); } return $solrdoc; } /** * Adds a text document to the search engine. * * @param array $doc * @return bool */ protected function add_solr_document($doc) { $solrdoc = $this->create_solr_document($doc); try { $result = $this->get_search_client()->addDocument($solrdoc, true, static::AUTOCOMMIT_WITHIN); return true; } catch (\SolrClientException $e) { debugging('Solr client error adding document with id ' . $doc['id'] . ': ' . $e->getMessage(), DEBUG_DEVELOPER); } catch (\SolrServerException $e) { // We only use the first line of the message, as it's a fully java stacktrace behind it. $msg = strtok($e->getMessage(), "\n"); debugging('Solr server error adding document with id ' . $doc['id'] . ': ' . $msg, DEBUG_DEVELOPER); } return false; } /** * Adds multiple text documents to the search engine. * * @param array $docs Array of documents (each an array of fields) to add * @return int[] Array of success, failure, batch count * @throws \core_search\engine_exception */ protected function add_solr_documents(array $docs): array { $solrdocs = []; foreach ($docs as $doc) { $solrdocs[] = $this->create_solr_document($doc); } try { // Add documents in a batch and report that they all succeeded. $this->get_search_client()->addDocuments($solrdocs, true, static::AUTOCOMMIT_WITHIN); return [count($solrdocs), 0, 1]; } catch (\SolrClientException $e) { // If there is an exception, fall through... $donothing = true; } catch (\SolrServerException $e) { // If there is an exception, fall through... $donothing = true; } // When there is an error, we fall back to adding them individually so that we can report // which document(s) failed. Since it overwrites, adding the successful ones multiple // times won't hurt. $success = 0; $failure = 0; $batches = 0; foreach ($docs as $doc) { $result = $this->add_solr_document($doc); $batches++; if ($result) { $success++; } else { $failure++; } } return [$success, $failure, $batches]; } /** * Index files attached to the docuemnt, ensuring the index matches the current document files. * * For documents that aren't known to be new, we check the index for existing files. * - New files we will add. * - Existing and unchanged files we will skip. * - File that are in the index but not on the document will be deleted from the index. * - Files that have changed will be re-indexed. * * @param document $document */ protected function process_document_files($document) { if (!$this->file_indexing_enabled()) { return; } // Maximum rows to process at a time. $rows = 500; // Get the attached files. $files = $document->get_files(); // If this isn't a new document, we need to check the exiting indexed files. if (!$document->get_is_new()) { // We do this progressively, so we can handle lots of files cleanly. list($numfound, $indexedfiles) = $this->get_indexed_files($document, 0, $rows); $count = 0; $idstodelete = array(); do { // Go through each indexed file. We want to not index any stored and unchanged ones, delete any missing ones. foreach ($indexedfiles as $indexedfile) { $fileid = $indexedfile->solr_fileid; if (isset($files[$fileid])) { // Check for changes that would mean we need to re-index the file. If so, just leave in $files. // Filelib does not guarantee time modified is updated, so we will check important values. if ($indexedfile->modified != $files[$fileid]->get_timemodified()) { continue; } if (strcmp($indexedfile->title, $files[$fileid]->get_filename()) !== 0) { continue; } if ($indexedfile->solr_filecontenthash != $files[$fileid]->get_contenthash()) { continue; } if ($indexedfile->solr_fileindexstatus == document::INDEXED_FILE_FALSE && $this->file_is_indexable($files[$fileid])) { // This means that the last time we indexed this file, filtering blocked it. // Current settings say it is indexable, so we will allow it to be indexed. continue; } // If the file is already indexed, we can just remove it from the files array and skip it. unset($files[$fileid]); } else { // This means we have found a file that is no longer attached, so we need to delete from the index. // We do it later, since this is progressive, and it could reorder results. $idstodelete[] = $indexedfile->id; } } $count += $rows; if ($count < $numfound) { // If we haven't hit the total count yet, fetch the next batch. list($numfound, $indexedfiles) = $this->get_indexed_files($document, $count, $rows); } } while ($count < $numfound); // Delete files that are no longer attached. foreach ($idstodelete as $id) { // We directly delete the item using the client, as the engine delete_by_id won't work on file docs. $this->get_search_client()->deleteById($id); } } // Now we can actually index all the remaining files. foreach ($files as $file) { $this->add_stored_file($document, $file); } } /** * Get the currently indexed files for a particular document, returns the total count, and a subset of files. * * @param document $document * @param int $start The row to start the results on. Zero indexed. * @param int $rows The number of rows to fetch * @return array A two element array, the first is the total number of availble results, the second is an array * of documents for the current request. */ protected function get_indexed_files($document, $start = 0, $rows = 500) { // Build a custom query that will get any document files that are in our solr_filegroupingid. $query = new \SolrQuery(); // We want to get all file records tied to a document. // For efficiency, we are building our own, stripped down, query. $query->setQuery('*'); $query->setRows($rows); $query->setStart($start); // We want a consistent sorting. $query->addSortField('id'); // We only want the bare minimum of fields. $query->addField('id'); $query->addField('modified'); $query->addField('title'); $query->addField('solr_fileid'); $query->addField('solr_filecontenthash'); $query->addField('solr_fileindexstatus'); $query->addFilterQuery('{!cache=false}solr_filegroupingid:(' . $document->get('id') . ')'); $query->addFilterQuery('type:' . \core_search\manager::TYPE_FILE); $response = $this->get_query_response($query); if (empty($response->response->numFound)) { return array(0, array()); } return array($response->response->numFound, $this->convert_file_results($response)); } /** * A very lightweight handler for getting information about already indexed files from a Solr response. * * @param SolrObject $responsedoc A Solr response document * @return stdClass[] An array of objects that contain the basic information for file processing. */ protected function convert_file_results($responsedoc) { if (!$docs = $responsedoc->response->docs) { return array(); } $out = array(); foreach ($docs as $doc) { // Copy the bare minimim needed info. $result = new \stdClass(); $result->id = $doc->id; $result->modified = document::import_time_from_engine($doc->modified); $result->title = $doc->title; $result->solr_fileid = $doc->solr_fileid; $result->solr_filecontenthash = $doc->solr_filecontenthash; $result->solr_fileindexstatus = $doc->solr_fileindexstatus; $out[] = $result; } return $out; } /** * Adds a file to the search engine. * * Notes about Solr and Tika indexing. We do not send the mime type, only the filename. * Tika has much better content type detection than Moodle, and we will have many more doc failures * if we try to send mime types. * * @param document $document * @param \stored_file $storedfile * @return void */ protected function add_stored_file($document, $storedfile) { $filedoc = $document->export_file_for_engine($storedfile); if (!$this->file_is_indexable($storedfile)) { // For files that we don't consider indexable, we will still place a reference in the search engine. $filedoc['solr_fileindexstatus'] = document::INDEXED_FILE_FALSE; $this->add_solr_document($filedoc); return; } $curl = $this->get_curl_object(); $url = $this->get_connection_url('/update/extract'); // Return results as XML. $url->param('wt', 'xml'); // This will prevent solr from automatically making fields for every tika output. $url->param('uprefix', 'ignored_'); // Control how content is captured. This will keep our file content clean of non-important metadata. $url->param('captureAttr', 'true'); // Move the content to a field for indexing. $url->param('fmap.content', 'solr_filecontent'); // These are common fields that matches the standard *_point dynamic field and causes an error. $url->param('fmap.media_white_point', 'ignored_mwp'); $url->param('fmap.media_black_point', 'ignored_mbp'); // Copy each key to the url with literal. // We place in a temp name then copy back to the true field, which prevents errors or Tika overwriting common field names. foreach ($filedoc as $key => $value) { // This will take any fields from tika that match our schema and discard them, so they don't overwrite ours. $url->param('fmap.'.$key, 'ignored_'.$key); // Place data in a tmp field. $url->param('literal.mdltmp_'.$key, $value); // Then move to the final field. $url->param('fmap.mdltmp_'.$key, $key); } // This sets the true filename for Tika. $url->param('resource.name', $storedfile->get_filename()); // A giant block of code that is really just error checking around the curl request. try { // We have to post the file directly in binary data (not using multipart) to avoid // Solr bug SOLR-15039 which can cause incorrect data when you use multipart upload. // Note this loads the whole file into memory; see limit in file_is_indexable(). $result = $curl->post($url->out(false), $storedfile->get_content()); $code = $curl->get_errno(); $info = $curl->get_info(); // Now error handling. It is just informational, since we aren't tracking per file/doc results. if ($code != 0) { // This means an internal cURL error occurred error is in result. $message = 'Curl error '.$code.' while indexing file with document id '.$filedoc['id'].': '.$result.'.'; debugging($message, DEBUG_DEVELOPER); } else if (isset($info['http_code']) && ($info['http_code'] !== 200)) { // Unexpected HTTP response code. $message = 'Error while indexing file with document id '.$filedoc['id']; // Try to get error message out of msg or title if it exists. if (preg_match('|<str [^>]*name="msg"[^>]*>(.*?)</str>|i', $result, $matches)) { $message .= ': '.$matches[1]; } else if (preg_match('|<title[^>]*>([^>]*)</title>|i', $result, $matches)) { $message .= ': '.$matches[1]; } // This is a common error, happening whenever a file fails to index for any reason, so we will make it quieter. if (CLI_SCRIPT && !PHPUNIT_TEST) { mtrace($message); } } else { // Check for the expected status field. if (preg_match('|<int [^>]*name="status"[^>]*>(\d*)</int>|i', $result, $matches)) { // Now check for the expected status of 0, if not, error. if ((int)$matches[1] !== 0) { $message = 'Unexpected Solr status code '.(int)$matches[1]; $message .= ' while indexing file with document id '.$filedoc['id'].'.'; debugging($message, DEBUG_DEVELOPER); } else { // The document was successfully indexed. return; } } else { // We received an unprocessable response. $message = 'Unexpected Solr response while indexing file with document id '.$filedoc['id'].': '; $message .= strtok($result, "\n"); debugging($message, DEBUG_DEVELOPER); } } } catch (\Exception $e) { // There was an error, but we are not tracking per-file success, so we just continue on. debugging('Unknown exception while indexing file "'.$storedfile->get_filename().'".', DEBUG_DEVELOPER); } // If we get here, the document was not indexed due to an error. So we will index just the base info without the file. $filedoc['solr_fileindexstatus'] = document::INDEXED_FILE_ERROR; $this->add_solr_document($filedoc); } /** * Checks to see if a passed file is indexable. * * @param \stored_file $file The file to check * @return bool True if the file can be indexed */ protected function file_is_indexable($file) { if (!empty($this->config->maxindexfilekb) && ($file->get_filesize() > ($this->config->maxindexfilekb * 1024))) { // The file is too big to index. return false; } // Because we now load files into memory to index them in Solr, we also have to ensure that // we don't try to index anything bigger than the memory limit (less 100MB for safety). // Memory limit in cron is MEMORY_EXTRA which is usually 256 or 384MB but can be increased // in config, so this will allow files over 100MB to be indexed. $limit = ini_get('memory_limit'); if ($limit && $limit != -1) { $limitbytes = get_real_size($limit); if ($file->get_filesize() > $limitbytes) { return false; } } $mime = $file->get_mimetype(); if ($mime == 'application/vnd.moodle.backup') { // We don't index Moodle backup files. There is nothing usefully indexable in them. return false; } return true; } /** * Commits all pending changes. * * @return void */ protected function commit() { $this->get_search_client()->commit(); } /** * Do any area cleanup needed, and do anything to confirm contents. * * Return false to prevent the search area completed time and stats from being updated. * * @param \core_search\base $searcharea The search area that was complete * @param int $numdocs The number of documents that were added to the index * @param bool $fullindex True if a full index is being performed * @return bool True means that data is considered indexed */ public function area_index_complete($searcharea, $numdocs = 0, $fullindex = false) { $this->commit(); return true; } /** * Return true if file indexing is supported and enabled. False otherwise. * * @return bool */ public function file_indexing_enabled() { return (bool)$this->config->fileindexing; } /** * Deletes the specified document. * * @param string $id The document id to delete * @return void */ public function delete_by_id($id) { // We need to make sure we delete the item and all related files, which can be done with solr_filegroupingid. $this->get_search_client()->deleteByQuery('solr_filegroupingid:' . $id); $this->commit(); } /** * Delete all area's documents. * * @param string $areaid * @return void */ public function delete($areaid = null) { if ($areaid) { $this->get_search_client()->deleteByQuery('areaid:' . $areaid); } else { $this->get_search_client()->deleteByQuery('*:*'); } $this->commit(); } /** * Pings the Solr server using search_solr config * * @return true|string Returns true if all good or an error string. */ public function is_server_ready() { $configured = $this->is_server_configured(); if ($configured !== true) { return $configured; } // As part of the above we have already checked that we can contact the server. For pages // where performance is important, we skip doing a full schema check as well. if ($this->should_skip_schema_check()) { return true; } // Update schema if required/possible. $schemalatest = $this->check_latest_schema(); if ($schemalatest !== true) { return $schemalatest; } // Check that the schema is already set up. try { $schema = new schema($this); $schema->validate_setup(); } catch (\moodle_exception $e) { return $e->getMessage(); } return true; } /** * Is the solr server properly configured?. * * @return true|string Returns true if all good or an error string. */ public function is_server_configured() { if (empty($this->config->server_hostname) || empty($this->config->indexname)) { return 'No solr configuration found'; } if (!$client = $this->get_search_client(false)) { return get_string('engineserverstatus', 'search'); } try { if ($this->get_solr_major_version() < 4) { // Minimum solr 4.0. return get_string('minimumsolr4', 'search_solr'); } } catch (\SolrClientException $ex) { debugging('Solr client error: ' . html_to_text($ex->getMessage()), DEBUG_DEVELOPER); return get_string('engineserverstatus', 'search'); } catch (\SolrServerException $ex) { debugging('Solr server error: ' . html_to_text($ex->getMessage()), DEBUG_DEVELOPER); return get_string('engineserverstatus', 'search'); } return true; } /** * Returns the solr server major version. * * @return int */ public function get_solr_major_version() { if ($this->solrmajorversion !== null) { return $this->solrmajorversion; } // We should really ping first the server to see if the specified indexname is valid but // we want to minimise solr server requests as they are expensive. system() emits a warning // if it can not connect to the configured index in the configured server. $systemdata = @$this->get_search_client()->system(); $solrversion = $systemdata->getResponse()->offsetGet('lucene')->offsetGet('solr-spec-version'); $this->solrmajorversion = intval(substr($solrversion, 0, strpos($solrversion, '.'))); return $this->solrmajorversion; } /** * Checks if the PHP Solr extension is available. * * @return bool */ public function is_installed() { return function_exists('solr_get_version'); } /** * Returns the solr client instance. * * We don't reuse SolrClient if we are on libcurl 7.35.0, due to a bug in that version of curl. * * @throws \core_search\engine_exception * @param bool $triggerexception * @return \SolrClient */ protected function get_search_client($triggerexception = true) { global $CFG; // Type comparison as it is set to false if not available. if ($this->client !== null) { return $this->client; } $options = array( 'hostname' => $this->config->server_hostname, 'path' => '/solr/' . $this->config->indexname, 'login' => !empty($this->config->server_username) ? $this->config->server_username : '', 'password' => !empty($this->config->server_password) ? $this->config->server_password : '', 'port' => !empty($this->config->server_port) ? $this->config->server_port : '', 'secure' => !empty($this->config->secure) ? true : false, 'ssl_cert' => !empty($this->config->ssl_cert) ? $this->config->ssl_cert : '', 'ssl_key' => !empty($this->config->ssl_key) ? $this->config->ssl_key : '', 'ssl_keypassword' => !empty($this->config->ssl_keypassword) ? $this->config->ssl_keypassword : '', 'ssl_cainfo' => !empty($this->config->ssl_cainfo) ? $this->config->ssl_cainfo : '', 'ssl_capath' => !empty($this->config->ssl_capath) ? $this->config->ssl_capath : '', 'timeout' => !empty($this->config->server_timeout) ? $this->config->server_timeout : '30' ); if ($CFG->proxyhost && !is_proxybypass('http://' . $this->config->server_hostname . '/')) { $options['proxy_host'] = $CFG->proxyhost; if (!empty($CFG->proxyport)) { $options['proxy_port'] = $CFG->proxyport; } if (!empty($CFG->proxyuser) && !empty($CFG->proxypassword)) { $options['proxy_login'] = $CFG->proxyuser; $options['proxy_password'] = $CFG->proxypassword; } } if (!class_exists('\SolrClient')) { throw new \core_search\engine_exception('enginenotinstalled', 'search', '', 'solr'); } $client = new \SolrClient($options); if ($client === false && $triggerexception) { throw new \core_search\engine_exception('engineserverstatus', 'search'); } if ($this->cacheclient) { $this->client = $client; } return $client; } /** * Returns a curl object for conntecting to solr. * * @return \curl */ public function get_curl_object() { if (!is_null($this->curl)) { return $this->curl; } // Connection to Solr is allowed to use 'localhost' and other potentially blocked hosts/ports. $this->curl = new \curl(['ignoresecurity' => true]); $options = array(); // Build the SSL options. Based on pecl-solr and general testing. if (!empty($this->config->secure)) { if (!empty($this->config->ssl_cert)) { $options['CURLOPT_SSLCERT'] = $this->config->ssl_cert; $options['CURLOPT_SSLCERTTYPE'] = 'PEM'; } if (!empty($this->config->ssl_key)) { $options['CURLOPT_SSLKEY'] = $this->config->ssl_key; $options['CURLOPT_SSLKEYTYPE'] = 'PEM'; } if (!empty($this->config->ssl_keypassword)) { $options['CURLOPT_KEYPASSWD'] = $this->config->ssl_keypassword; } if (!empty($this->config->ssl_cainfo)) { $options['CURLOPT_CAINFO'] = $this->config->ssl_cainfo; } if (!empty($this->config->ssl_capath)) { $options['CURLOPT_CAPATH'] = $this->config->ssl_capath; } } // Set timeout as for Solr client. $options['CURLOPT_TIMEOUT'] = !empty($this->config->server_timeout) ? $this->config->server_timeout : '30'; $this->curl->setopt($options); if (!empty($this->config->server_username) && !empty($this->config->server_password)) { $authorization = $this->config->server_username . ':' . $this->config->server_password; $this->curl->setHeader('Authorization: Basic ' . base64_encode($authorization)); } return $this->curl; } /** * Return a Moodle url object for the server connection. * * @param string $path The solr path to append. * @return \moodle_url */ public function get_connection_url($path) { // Must use the proper protocol, or SSL will fail. $protocol = !empty($this->config->secure) ? 'https' : 'http'; $url = $protocol . '://' . rtrim($this->config->server_hostname, '/'); if (!empty($this->config->server_port)) { $url .= ':' . $this->config->server_port; } $url .= '/solr/' . $this->config->indexname . '/' . ltrim($path, '/'); return new \moodle_url($url); } /** * Solr includes group support in the execute_query function. * * @return bool True */ public function supports_group_filtering() { return true; } protected function update_schema($oldversion, $newversion) { // Construct schema. $schema = new schema($this); $cansetup = $schema->can_setup_server(); if ($cansetup !== true) { return $cansetup; } switch ($newversion) { // This version just requires a setup call to add new fields. case 2017091700: $setup = true; break; // If we don't know about the schema version we might not have implemented the // change correctly, so return. default: return get_string('schemaversionunknown', 'search'); } if ($setup) { $schema->setup(); } return true; } /** * Solr supports sort by location within course contexts or below. * * @param \context $context Context that the user requested search from * @return array Array from order name => display text */ public function get_supported_orders(\context $context) { $orders = parent::get_supported_orders($context); // If not within a course, no other kind of sorting supported. $coursecontext = $context->get_course_context(false); if ($coursecontext) { // Within a course or activity/block, support sort by location. $orders['location'] = get_string('order_location', 'search', $context->get_context_name()); } return $orders; } /** * Solr supports search by user id. * * @return bool True */ public function supports_users() { return true; } /** * Solr supports adding documents in a batch. * * @return bool True */ public function supports_add_document_batch(): bool { return true; } /** * Solr supports deleting the index for a context. * * @param int $oldcontextid Context that has been deleted * @return bool True to indicate that any data was actually deleted * @throws \core_search\engine_exception */ public function delete_index_for_context(int $oldcontextid) { $client = $this->get_search_client(); try { $client->deleteByQuery('contextid:' . $oldcontextid); $client->commit(true); return true; } catch (\Exception $e) { throw new \core_search\engine_exception('error_solr', 'search_solr', '', $e->getMessage()); } } /** * Solr supports deleting the index for a course. * * @param int $oldcourseid * @return bool True to indicate that any data was actually deleted * @throws \core_search\engine_exception */ public function delete_index_for_course(int $oldcourseid) { $client = $this->get_search_client(); try { $client->deleteByQuery('courseid:' . $oldcourseid); $client->commit(true); return true; } catch (\Exception $e) { 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); } } engine/solr/classes/document.php 0000644 00000014651 15152012435 0012761 0 ustar 00 <?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/>. /** * Document representation. * * @package search_solr * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace search_solr; defined('MOODLE_INTERNAL') || die(); /** * Respresents a document to index. * * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class document extends \core_search\document { /** * Indicates the file contents were not indexed due to an error. */ const INDEXED_FILE_ERROR = -1; /** * Indicates the file contents were not indexed due filtering/settings. */ const INDEXED_FILE_FALSE = 0; /** * Indicates the file contents are indexed with the record. */ const INDEXED_FILE_TRUE = 1; /** * Any fields that are engine specifc. These are fields that are solely used by a seach engine plugin * for internal purposes. * * @var array */ protected static $enginefields = array( 'solr_filegroupingid' => array( 'type' => 'string', 'stored' => true, 'indexed' => true ), 'solr_fileid' => array( 'type' => 'string', 'stored' => true, 'indexed' => true ), 'solr_filecontenthash' => array( 'type' => 'string', 'stored' => true, 'indexed' => true ), // Stores the status of file indexing. 'solr_fileindexstatus' => array( 'type' => 'int', 'stored' => true, 'indexed' => true ), // Field to index, but not store, file contents. 'solr_filecontent' => array( 'type' => 'text', 'stored' => false, 'indexed' => true, 'mainquery' => true ) ); /** * Formats the timestamp according to the search engine needs. * * @param int $timestamp * @return string */ public static function format_time_for_engine($timestamp) { return gmdate(\search_solr\engine::DATE_FORMAT, $timestamp); } /** * Formats the timestamp according to the search engine needs. * * @param int $timestamp * @return string */ public static function format_string_for_engine($string) { // 2^15 default. We could convert this to a setting as is possible to // change the max in solr. return \core_text::str_max_bytes($string, 32766); } /** * Returns a timestamp from the value stored in the search engine. * * @param string $time * @return int */ public static function import_time_from_engine($time) { return strtotime($time); } /** * Overwritten to use markdown format as we use markdown for solr highlighting. * * @return int */ protected function get_text_format() { return FORMAT_HTML; } /** * Formats a text string coming from the search engine. * * @param string $text Text to format * @return string HTML text to be renderer */ protected function format_text($text) { // Since we allow output for highlighting, we need to encode html entities. // This ensures plaintext html chars don't become valid html. $out = s($text); $startcount = 0; $endcount = 0; // Remove end/start pairs that span a few common seperation characters. Allows us to highlight phrases instead of words. $regex = '|'.engine::HIGHLIGHT_END.'([ .,-]{0,3})'.engine::HIGHLIGHT_START.'|'; $out = preg_replace($regex, '$1', $out); // Now replace our start and end highlight markers. $out = str_replace(engine::HIGHLIGHT_START, '<span class="highlight">', $out, $startcount); $out = str_replace(engine::HIGHLIGHT_END, '</span>', $out, $endcount); // This makes sure any highlight tags are balanced, incase truncation or the highlight text contained our markers. while ($startcount > $endcount) { $out .= '</span>'; $endcount++; } while ($startcount < $endcount) { $out = '<span class="highlight">' . $out; $endcount++; } return parent::format_text($out); } /** * Apply any defaults to unset fields before export. Called after document building, but before export. * * Sub-classes of this should make sure to call parent::apply_defaults(). */ protected function apply_defaults() { parent::apply_defaults(); // We want to set the solr_filegroupingid to id if it isn't set. if (!isset($this->data['solr_filegroupingid'])) { $this->data['solr_filegroupingid'] = $this->data['id']; } } /** * Export the data for the given file in relation to this document. * * @param \stored_file $file The stored file we are talking about. * @return array */ public function export_file_for_engine($file) { $data = $this->export_for_engine(); // Content is index in the main document. unset($data['content']); unset($data['description1']); unset($data['description2']); // Going to append the fileid to give it a unique id. $data['id'] = $data['id'].'-solrfile'.$file->get_id(); $data['type'] = \core_search\manager::TYPE_FILE; $data['solr_fileid'] = $file->get_id(); $data['solr_filecontenthash'] = $file->get_contenthash(); $data['solr_fileindexstatus'] = self::INDEXED_FILE_TRUE; $data['title'] = $file->get_filename(); $data['modified'] = self::format_time_for_engine($file->get_timemodified()); return $data; } } engine/solr/lang/en/search_solr.php 0000644 00000012225 15152012435 0013330 0 ustar 00 <?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/>. /** * Strings for component 'search_solr'. * * @package search_solr * @copyright Prateek Sachan {@link http://prateeksachan.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['connectionerror'] = 'The specified Solr server is not available or the specified index does not exist'; $string['connectionsettings'] = 'Connection settings'; $string['errorcreatingschema'] = 'Error creating the Solr schema: {$a}'; $string['errorvalidatingschema'] = 'Error validating Solr schema: field {$a->fieldname} does not exist. Please <a href="{$a->setupurl}">follow this link</a> to set up the required fields.'; $string['errorsolr'] = 'The Solr search engine reported an error: {$a}'; $string['extensionerror'] = 'The Apache Solr PHP extension is not installed. Please check the documentation.'; $string['fileindexing'] = 'Enable file indexing'; $string['fileindexing_help'] = 'If your Solr install supports it, this feature allows Moodle to send files to be indexed.<br/> You will need to reindex all site contents after enabling this option for all files to be added.'; $string['fileindexsettings'] = 'File indexing settings'; $string['maxindexfilekb'] = 'Maximum file size to index (kB)'; $string['maxindexfilekb_help'] = 'Files larger than this number of kilobytes will not be included in search indexing. If set to zero, files of any size will be indexed.'; $string['minimumsolr4'] = 'Solr 4.0 is the minimum version required for Moodle'; $string['missingconfig'] = 'Your Apache Solr server is not yet configured in Moodle.'; $string['multivaluedfield'] = 'Field "{$a}" returned an array instead of a scalar. Please delete the current index, create a new one and run setup_schema.php before indexing data in Solr.'; $string['nodatafromserver'] = 'No data from server'; $string['pluginname'] = 'Solr'; $string['privacy:metadata'] = 'This plugin sends data externally to a linked Solr search engine. It does not store data locally.'; $string['privacy:metadata:data'] = 'Personal data passed through from the search subsystem.'; $string['schemafieldautocreated'] = 'Field "{$a}" already exists in Solr schema. You probably forgot to run this script before indexing data and fields were autocreated by Solr. Please delete the current index, create a new one and run setup_schema.php again before indexing data in Solr.'; $string['schemasetupfromsolr5'] = 'Your Solr server version is lower than 5.0. This script can only set your schema if your Solr version is 5.0 or higher. You need to manually set the fields in your schema according to \\search_solr\\document::get_default_fields_definition().'; $string['searchinfo'] = 'Search queries'; $string['searchinfo_help'] = 'The field to be searched may be specified by prefixing the search query with \'title:\', \'content:\', \'name:\', or \'intro:\'. For example, searching for \'title:news\' would return results with the word \'news\' in the title. Boolean operators (\'AND\', \'OR\', \'NOT\') may be used to combine or exclude keywords. Wildcard characters (\'*\' or \'?\' ) may be used to represent characters in the search query.'; $string['setupok'] = 'The schema is ready to be used.'; $string['solrauthpassword'] = 'HTTP authentication password'; $string['solrauthuser'] = 'HTTP authentication username'; $string['solrindexname'] = 'Index name'; $string['solrhttpconnectionport'] = 'Port'; $string['solrhttpconnectiontimeout'] = 'Timeout'; $string['solrhttpconnectiontimeout_desc'] = 'The HTTP connection timeout is the maximum time in seconds allowed for the HTTP data transfer operation.'; $string['solrinfo'] = 'Solr'; $string['solrnotselected'] = 'Solr engine is not the configured search engine'; $string['solrserverhostname'] = 'Host name'; $string['solrserverhostname_desc'] = 'Domain name of the Solr server.'; $string['solrsecuremode'] = 'Secure mode'; $string['solrsetting'] = 'Solr settings'; $string['solrsslcainfo'] = 'SSL CA certificates name'; $string['solrsslcainfo_desc'] = 'File name holding one or more CA certificates to verify peer with'; $string['solrsslcapath'] = 'SSL CA certificates path'; $string['solrsslcapath_desc'] = 'Directory path holding multiple CA certificates to verify peer with'; $string['solrsslcert'] = 'SSL certificate'; $string['solrsslcert_desc'] = 'File name to a PEM-formatted private certificate'; $string['solrsslkey'] = 'SSL key'; $string['solrsslkey_desc'] = 'File name to a PEM-formatted private key'; $string['solrsslkeypassword'] = 'SSL key password'; $string['solrsslkeypassword_desc'] = 'Password for PEM-formatted private key file'; upgrade.txt 0000644 00000015421 15152012435 0006735 0 ustar 00 This files describes API changes in /search/*, information provided here is intended especially for developers. === 3.10 === * Search indexing now supports sending multiple documents to the server in a batch. This is implemented for the Solr search engine, where it significantly increases performance. For this to work, engines 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 === * Search indexing supports time limits to make the scheduled task run more neatly since 3.4. In order for this to work, search engine plugins will need to implement the 'stopat' parameter if they override the add_documents() function, and return an extra parameter from this function (see base class in engine.php). Unmodified plugins will not work anymore. * New search engine functions delete_index_for_context and delete_index_for_course are called by the search manager to inform the search engine it can remove some documents from its index. (Otherwise, documents from delete courses are never removed unless you reindex.) It is optional for search engines to support these; if they don't implement them then behaviour is unchanged. === 3.7 === * Search areas now have categories and can optionally implement get_category_names method to display search results of the area in the required tab on the search results screen (if this feature is enabled). * Added a new call back search_area_categories. Plugins can implement this method in lib.php and return a list of custom search area categories (\core_search\area_category) and associated with them search areas. This will bring additional custom tabs to the search results screen. * Added \core_search\manager::clean_up_non_existing_area method to clean up removed or renamed search areas. To support that a new adhoc task core\task\clean_up_deleted_search_area_task added. === 3.5 === * Search areas may now optionally implement the get_contexts_to_reindex function (for modules and blocks, see also get_contexts_to_reindex_extra_sql). This allows a search area to customise the order in which it is reindexed when doing a gradual reindex, so as to reindex the most important contexts first. If not implemented, the default behaviour for modules and blocks is to reindex the newest items first; for other types of search area it will just index the whole system context, oldest data first. * Search engines may now implement get_supported_orders function to provide multiple ordering options (other than 'relevance' which is default). If there is more than one order then a choice will be shown to users. (This is an optional feature, existing search engine plugins do not need to be modified in order to continue working.) * Module search areas that wish to support group filtering should set the new optional search document field groupid (note: to remain compatible with earlier versions, do this inside an if statement so that it only happens on 3.4+) and return true to the supports_group_restriction function. See documentation in \core_search\base_mod class and example in \mod_forum\search\post. * When a search engine supports group filtering, the \core_search\manager::search function now accepts the optional 'groupids' parameter in its $data input. This parameter is an array of one or more group IDs. If supplied, only results from those groups will be returned. * Search engine plugins will need to be be modified if they wish to support group filtering. (Search engines should continue to work unmodified, but will not then support group filtering.) The modification steps are: - Implement the new update_schema function to make the schema change (add groupid field). - Ensure that the groupid field is stored correctly when provided in a document while indexing. - Return true to new supports_group_filtering() function. - execute_query should support the new $data->groupids parameter (to allow users to restrict search results to specific groups) and the modified meaning of the second parameter, $accessinfo (to automatically restrict search results users cannot access due to groups). See implementation in Solr search engine. * Search engine plugins can optionally use a new $this->should_skip_schema_check() function to decide when to skip slow schema checking inside the is_server_ready function, improving user performance on the search page. The Solr plugin implements this. * API function \core_search\manager::instance() now includes a $fast parameter to skip schema checks (as above). * Search engines should now implement the 'userids' option to restrict search results to those from specific users, and return true to the new supports_users() function. The supplied Solr search engine includes this feature. If this is not implemented, the search engine will continue to work but the 'Users' search option will not appear. === 3.4 === * Search indexing now supports time limits to make the scheduled task run more neatly. In order for this to work, search engine plugins will need to implement the 'stopat' parameter if they override the add_documents() function, and return an extra parameter from this function (see base class in engine.php). Unmodified plugins will still work, but without supporting time limits. * Search areas should now implement the get_document_recordset function instead of the old get_recordset_by_timestamp API (implement both if the area should work in older Moodle versions as well). The new function is the same as the old one, but has an additional context parameter. There is a helper function get_context_restriction_sql to make this easy to implement; see code in base_activity.php for an example of how to implement this in your search area. (The change was required to make search work after restoring sites. It also allows more flexible reindexing in other cases.) === 3.2 === * Base search area classes have been renamed, please update your search areas to use the classes below: - \core_search\area\base has been renamed to \core_search\base - \core_search\area\base_mod has been renamed to \core_search\base_mod - \core_search\area\base_activity has been renamed to \core_search\base_activity index.php 0000644 00000015736 15152012435 0006376 0 ustar 00 <?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/>. /** * Global Search index page for entering queries and display of results * * @package core_search * @copyright Prateek Sachan {@link http://prateeksachan.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once(__DIR__ . '/../config.php'); $page = optional_param('page', 0, PARAM_INT); $q = optional_param('q', '', PARAM_NOTAGS); $title = optional_param('title', '', PARAM_NOTAGS); $contextid = optional_param('context', 0, PARAM_INT); $cat = optional_param('cat', '', PARAM_NOTAGS); $mycoursesonly = optional_param('mycoursesonly', 0, PARAM_INT); if (\core_search\manager::is_search_area_categories_enabled()) { $cat = \core_search\manager::get_search_area_category_by_name($cat); } // Moving areaids, courseids, timestart, and timeend further down as they might come as an array if they come from the form. $context = context_system::instance(); $pagetitle = get_string('globalsearch', 'search'); $PAGE->set_context($context); $PAGE->set_pagelayout('standard'); $PAGE->set_title($pagetitle); $PAGE->set_heading($pagetitle); if (!empty($CFG->forcelogin)) { require_login(); } // Unlock the session during a search. \core\session\manager::write_close(); require_capability('moodle/search:query', $context); $searchrenderer = $PAGE->get_renderer('core_search'); if (\core_search\manager::is_global_search_enabled() === false) { $PAGE->set_url(new moodle_url('/search/index.php')); echo $OUTPUT->header(); echo $searchrenderer->render_search_disabled(); echo $OUTPUT->footer(); exit; } $search = \core_search\manager::instance(true, true); // Set up custom data for form. $customdata = ['searchengine' => $search->get_engine()->get_plugin_name()]; if ($contextid) { // When a context is supplied, check if it's within course level. If so, show dropdown. $context = context::instance_by_id($contextid); $coursecontext = $context->get_course_context(false); if ($coursecontext) { $searchwithin = []; $searchwithin[''] = get_string('everywhere', 'search'); $searchwithin['course'] = $coursecontext->get_context_name(); if ($context->contextlevel != CONTEXT_COURSE) { $searchwithin['context'] = $context->get_context_name(); if ($context->contextlevel == CONTEXT_MODULE) { $customdata['withincmid'] = $context->instanceid; } } $customdata['searchwithin'] = $searchwithin; $customdata['withincourseid'] = $coursecontext->instanceid; } } // Get available ordering options from search engine. $customdata['orderoptions'] = $search->get_engine()->get_supported_orders($context); if ($cat instanceof \core_search\area_category) { $customdata['cat'] = $cat->get_name(); } $mform = new \core_search\output\form\search(null, $customdata); $data = $mform->get_data(); if (!$data && $q) { // Data can also come from the URL. $data = new stdClass(); $data->q = $q; $data->title = $title; $areaids = optional_param('areaids', '', PARAM_RAW); if (!empty($areaids)) { $areaids = explode(',', $areaids); $data->areaids = clean_param_array($areaids, PARAM_ALPHANUMEXT); } $courseids = optional_param('courseids', '', PARAM_RAW); if (!empty($courseids)) { $courseids = explode(',', $courseids); $data->courseids = clean_param_array($courseids, PARAM_INT); } $data->timestart = optional_param('timestart', 0, PARAM_INT); $data->timeend = optional_param('timeend', 0, PARAM_INT); $data->context = $contextid; $data->mycoursesonly = $mycoursesonly; $mform->set_data($data); } // Convert the 'search within' option, if used, to course or context restrictions. if ($data && !empty($data->searchwithin)) { switch ($data->searchwithin) { case 'course': $data->courseids = [$coursecontext->instanceid]; break; case 'context': $data->courseids = [$coursecontext->instanceid]; $data->contextids = [$context->id]; break; } } // Inform search engine about source context. if (!empty($context) && $data) { $data->context = $context; } if ($data && $cat instanceof \core_search\area_category) { $data->cat = $cat->get_name(); } // Set the page URL. $urlparams = array('page' => $page); if ($data) { $urlparams['q'] = $data->q; $urlparams['title'] = $data->title; if (!empty($data->areaids)) { $urlparams['areaids'] = implode(',', $data->areaids); } if (!empty($data->courseids)) { $urlparams['courseids'] = implode(',', $data->courseids); } $urlparams['timestart'] = $data->timestart; $urlparams['timeend'] = $data->timeend; $urlparams['mycoursesonly'] = isset($data->mycoursesonly) ? $data->mycoursesonly : 0; } if ($cat instanceof \core_search\area_category) { $urlparams['cat'] = $cat->get_name(); } $url = new moodle_url('/search/index.php', $urlparams); $PAGE->set_url($url); // We are ready to render. echo $OUTPUT->header(); // Get the results. 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)) { echo $OUTPUT->notification(get_string('noresults', 'search'), 'notifymessage'); } $mform->display(); if (!empty($results)) { $topresults = $search->search_top($data); if (!empty($topresults)) { echo $searchrenderer->render_top_results($topresults); } echo $searchrenderer->render_results($results->results, $results->actualpage, $results->totalcount, $url, $cat); \core_search\manager::trigger_search_results_viewed([ 'q' => $data->q, 'page' => $page, 'title' => $data->title, 'areaids' => !empty($data->areaids) ? $data->areaids : array(), 'courseids' => !empty($data->courseids) ? $data->courseids : array(), 'timestart' => isset($data->timestart) ? $data->timestart : 0, 'timeend' => isset($data->timeend) ? $data->timeend : 0 ]); } echo $OUTPUT->footer(); classes/output/renderer.php 0000644 00000013274 15152012435 0012065 0 ustar 00 <?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/>. /** * Search renderer. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search\output; defined('MOODLE_INTERNAL') || die(); /** * Search renderer. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class renderer extends \plugin_renderer_base { /** * @var int Max number chars to display of a string value */ const SEARCH_RESULT_STRING_SIZE = 100; /** * @var int Max number chars to display of a text value */ const SEARCH_RESULT_TEXT_SIZE = 500; /** * Renders search results. * * @param \core_search\document[] $results * @param int $page Zero based page number. * @param int $totalcount Total number of results available. * @param \moodle_url $url * @param \core_search\area_category|null $cat Selected search are category or null if category functionality is disabled. * @return string HTML */ public function render_results($results, $page, $totalcount, $url, $cat = null) { $content = ''; if (\core_search\manager::is_search_area_categories_enabled() && !empty($cat)) { $toprow = []; foreach (\core_search\manager::get_search_area_categories() as $category) { $taburl = clone $url; $taburl->param('cat', $category->get_name()); $taburl->param('page', 0); $taburl->remove_params(['page', 'areaids']); $toprow[$category->get_name()] = new \tabobject($category->get_name(), $taburl, $category->get_visiblename()); } if (\core_search\manager::should_hide_all_results_category()) { unset($toprow[\core_search\manager::SEARCH_AREA_CATEGORY_ALL]); } $content .= $this->tabtree($toprow, $cat->get_name()); } // Paging bar. $perpage = \core_search\manager::DISPLAY_RESULTS_PER_PAGE; $content .= $this->output->paging_bar($totalcount, $page, $perpage, $url); // Results. $resultshtml = array(); foreach ($results as $hit) { $resultshtml[] = $this->render_result($hit); } $content .= \html_writer::tag('div', implode('<hr/>', $resultshtml), array('class' => 'search-results')); // Paging bar. $content .= $this->output->paging_bar($totalcount, $page, $perpage, $url); return $content; } /** * Top results content * * @param \core_search\document[] $results Search Results * @return string content of the top result section */ public function render_top_results($results): string { $content = $this->output->box_start('topresults'); $content .= $this->output->heading(get_string('topresults', 'core_search')); $content .= \html_writer::tag('hr', ''); $resultshtml = array(); foreach ($results as $hit) { $resultshtml[] = $this->render_result($hit); } $content .= \html_writer::tag('div', implode('<hr/>', $resultshtml), array('class' => 'search-results')); $content .= $this->output->box_end(); return $content; } /** * Displaying search results. * * @param \core_search\document Containing a single search response to be displayed.a * @return string HTML */ public function render_result(\core_search\document $doc) { $docdata = $doc->export_for_template($this); // Limit text fields size. $docdata['title'] = shorten_text($docdata['title'], static::SEARCH_RESULT_STRING_SIZE, true); $docdata['content'] = $docdata['content'] ? shorten_text($docdata['content'], static::SEARCH_RESULT_TEXT_SIZE, true) : ''; $docdata['description1'] = $docdata['description1'] ? shorten_text($docdata['description1'], static::SEARCH_RESULT_TEXT_SIZE, true) : ''; $docdata['description2'] = $docdata['description2'] ? shorten_text($docdata['description2'], static::SEARCH_RESULT_TEXT_SIZE, true) : ''; return $this->output->render_from_template('core_search/result', $docdata); } /** * Returns a box with a search disabled lang string. * * @return string HTML */ public function render_search_disabled() { $content = $this->output->box_start(); $content .= $this->output->notification(get_string('globalsearchdisabled', 'search'), 'notifymessage'); $content .= $this->output->box_end(); return $content; } /** * Returns information about queued index requests. * * @param \stdClass $info Info object from get_index_requests_info * @return string HTML * @throws \moodle_exception Any error with template */ public function render_index_requests_info(\stdClass $info) { return $this->output->render_from_template('core_search/index_requests', $info); } } classes/output/form/search.php 0000644 00000021277 15152012435 0012471 0 ustar 00 <?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/>. /** * Global search search form definition * * @package core_search * @copyright Prateek Sachan {@link http://prateeksachan.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search\output\form; use core_search\manager; defined('MOODLE_INTERNAL') || die; require_once($CFG->libdir . '/formslib.php'); require_once($CFG->libdir . '/externallib.php'); class search extends \moodleform { /** * Form definition. * * @return void */ function definition() { global $USER, $DB, $OUTPUT; $mform =& $this->_form; if (\core_search\manager::is_search_area_categories_enabled() && !empty($this->_customdata['cat'])) { $mform->addElement('hidden', 'cat'); $mform->setType('cat', PARAM_NOTAGS); $mform->setDefault('cat', $this->_customdata['cat']); } $mform->disable_form_change_checker(); $mform->addElement('header', 'search', get_string('search', 'search')); // Help info depends on the selected search engine. $mform->addElement('text', 'q', get_string('enteryoursearchquery', 'search')); $mform->addHelpButton('q', 'searchinfo', $this->_customdata['searchengine']); $mform->setType('q', PARAM_TEXT); $mform->addRule('q', get_string('required'), 'required', null, 'client'); // Show the 'search within' option if the user came from a particular context. if (!empty($this->_customdata['searchwithin'])) { $mform->addElement('select', 'searchwithin', get_string('searchwithin', 'search'), $this->_customdata['searchwithin']); $mform->setDefault('searchwithin', ''); } // If the search engine provides multiple ways to order results, show options. if (!empty($this->_customdata['orderoptions']) && count($this->_customdata['orderoptions']) > 1) { $mform->addElement('select', 'order', get_string('order', 'search'), $this->_customdata['orderoptions']); $mform->setDefault('order', 'relevance'); } $mform->addElement('header', 'filtersection', get_string('filterheader', 'search')); $mform->setExpanded('filtersection', false); $mform->addElement('text', 'title', get_string('title', 'search')); $mform->setType('title', PARAM_TEXT); $search = \core_search\manager::instance(true); $enabledsearchareas = \core_search\manager::get_search_areas_list(true); $areanames = array(); if (\core_search\manager::is_search_area_categories_enabled() && !empty($this->_customdata['cat'])) { $searchareacategory = \core_search\manager::get_search_area_category_by_name($this->_customdata['cat']); $searchareas = $searchareacategory->get_areas(); foreach ($searchareas as $areaid => $searcharea) { if (key_exists($areaid, $enabledsearchareas)) { $areanames[$areaid] = $searcharea->get_visible_name(); } } } else { foreach ($enabledsearchareas as $areaid => $searcharea) { $areanames[$areaid] = $searcharea->get_visible_name(); } } // Sort the array by the text. \core_collator::asort($areanames); $options = array( 'multiple' => true, 'noselectionstring' => get_string('allareas', 'search'), ); $mform->addElement('autocomplete', 'areaids', get_string('searcharea', 'search'), $areanames, $options); if (is_siteadmin()) { $limittoenrolled = false; } else { $limittoenrolled = !manager::include_all_courses(); } $options = array( 'multiple' => true, 'limittoenrolled' => $limittoenrolled, 'noselectionstring' => get_string('allcourses', 'search'), ); $mform->addElement('course', 'courseids', get_string('courses', 'core'), $options); $mform->setType('courseids', PARAM_INT); if (manager::include_all_courses() || !empty(get_config('core', 'searchallavailablecourses'))) { $mform->addElement('checkbox', 'mycoursesonly', get_string('mycoursesonly', 'search')); $mform->setType('mycoursesonly', PARAM_INT); } // If the search engine can search by user, and the user is logged in (so we have // permission to call the user-listing web service) then show the user selector. if ($search->get_engine()->supports_users() && isloggedin()) { $options = [ 'ajax' => 'core_search/form-search-user-selector', 'multiple' => true, 'noselectionstring' => get_string('allusers', 'search'), 'valuehtmlcallback' => function($value) { global $DB, $OUTPUT; $user = $DB->get_record('user', ['id' => (int)$value], '*', IGNORE_MISSING); if (!$user || !user_can_view_profile($user)) { return false; } $details = user_get_user_details($user); return $OUTPUT->render_from_template( 'core_search/form-user-selector-suggestion', $details); } ]; if (!empty($this->_customdata['withincourseid'])) { $options['withincourseid'] = $this->_customdata['withincourseid']; } $mform->addElement('autocomplete', 'userids', get_string('users'), [], $options); } if (!empty($this->_customdata['searchwithin'])) { // Course options should be hidden if we choose to search within a specific location. $mform->hideIf('courseids', 'searchwithin', 'ne', ''); // Get groups on course (we don't show group selector if there aren't any). $courseid = $this->_customdata['withincourseid']; $allgroups = groups_get_all_groups($courseid); if ($allgroups && $search->get_engine()->supports_group_filtering()) { $groupnames = []; foreach ($allgroups as $group) { $groupnames[$group->id] = $group->name; } // Create group autocomplete option. $options = array( 'multiple' => true, 'noselectionstring' => get_string('allgroups'), ); $mform->addElement('autocomplete', 'groupids', get_string('groups'), $groupnames, $options); // Is the second 'search within' option a cm? if (!empty($this->_customdata['withincmid'])) { // Find out if the cm supports groups. $modinfo = get_fast_modinfo($courseid); $cm = $modinfo->get_cm($this->_customdata['withincmid']); if ($cm->effectivegroupmode != NOGROUPS) { // If it does, group ids are available when you have course or module selected. $mform->hideIf('groupids', 'searchwithin', 'eq', ''); } else { // Group ids are only available if you have course selected. $mform->hideIf('groupids', 'searchwithin', 'ne', 'course'); } } else { $mform->hideIf('groupids', 'searchwithin', 'eq', ''); } } } $mform->addElement('date_time_selector', 'timestart', get_string('fromtime', 'search'), array('optional' => true)); $mform->setDefault('timestart', 0); $mform->addElement('date_time_selector', 'timeend', get_string('totime', 'search'), array('optional' => true)); $mform->setDefault('timeend', 0); // Source context i.e. the page they came from when they clicked search. $mform->addElement('hidden', 'context'); $mform->setType('context', PARAM_INT); $this->add_action_buttons(false, get_string('search', 'search')); } } classes/engine_exception.php 0000644 00000002317 15152012435 0012236 0 ustar 00 <?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/>. /** * Search engine exceptions. * * @package core_search * @copyright David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die; /** * Dummy class to identify search engine exceptions. * * @package core_search * @copyright David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class engine_exception extends \moodle_exception { } classes/area_category.php 0000644 00000006070 15152012435 0011520 0 ustar 00 <?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/>. /** * Search area category. * * @package core_search * @copyright Dmitrii Metelkin <dmitriim@catalyst-au.net> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die(); /** * Search area category. * * @package core_search * @copyright Dmitrii Metelkin <dmitriim@catalyst-au.net> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class area_category { /** * Category name. * @var string */ protected $name; /** * Category visible name. * @var string */ protected $visiblename; /** * Category order. * @var int */ protected $order = 0; /** * Category areas. * @var \core_search\base[] */ protected $areas = []; /** * Constructor. * * @param string $name Unique name of the category. * @param string $visiblename Visible name of the category. * @param int $order Category position in the list (smaller numbers will be displayed first). * @param \core_search\base[] $areas A list of search areas associated with this category. */ public function __construct(string $name, string $visiblename, int $order = 0, array $areas = []) { $this->name = $name; $this->visiblename = $visiblename; $this->order = $order; $this->set_areas($areas); } /** * Get name. * * @return string */ public function get_name() { return $this->name; } /** * Get visible name. * * @return string */ public function get_visiblename() { return $this->visiblename; } /** * Get order to display. * * @return int */ public function get_order() { return $this->order; } /** * Return a keyed by area id list of areas for this category. * * @return \core_search\base[] */ public function get_areas() { return $this->areas; } /** * Set list of search areas for this category, * * @param \core_search\base[] $areas */ public function set_areas(array $areas) { foreach ($areas as $area) { if ($area instanceof base && !key_exists($area->get_area_id(), $this->areas)) { $this->areas[$area->get_area_id()] = $area; } } } } classes/privacy/provider.php 0000644 00000002707 15152012435 0012225 0 ustar 00 <?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/>. /** * Privacy Subsystem implementation for core_search. * * @package core_search * @copyright 2018 David Monllao * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for core_search implementing null_provider. * * @copyright 2018 David Monllao * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason() : string { return 'privacy:metadata'; } } classes/base_mod.php 0000644 00000027532 15152012435 0010472 0 ustar 00 <?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/>. /** * Search area base class for areas working at module level. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die(); /** * Base implementation for search areas working at module level. * * Even if the search area works at multiple levels, if module is one of these levels * it should extend this class, as this class provides helper methods for module level search management. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class base_mod extends base { /** * The context levels the search area is working on. * * This can be overwriten by the search area if it works at multiple * levels. * * @var array */ protected static $levels = [CONTEXT_MODULE]; /** * Returns the module name. * * @return string */ protected function get_module_name() { return substr($this->componentname, 4); } /** * Gets the course module for the required instanceid + modulename. * * The returned data depends on the logged user, when calling this through * self::get_document the admin user is used so everything would be returned. * * No need more internal caching here, modinfo is already cached. * * @throws \dml_missing_record_exception * @param string $modulename The module name * @param int $instanceid Module instance id (depends on the module) * @param int $courseid Helps speeding up things * @return \cm_info */ protected function get_cm($modulename, $instanceid, $courseid) { $modinfo = get_fast_modinfo($courseid); // Hopefully not many, they are indexed by cmid. $instances = $modinfo->get_instances_of($modulename); foreach ($instances as $cminfo) { if ($cminfo->instance == $instanceid) { return $cminfo; } } // Nothing found. throw new \dml_missing_record_exception($modulename); } /** * Helper function that gets SQL useful for restricting a search query given a passed-in * context. * * The SQL returned will be zero or more JOIN statements, surrounded by whitespace, which act * as restrictions on the query based on the rows in a module table. * * You can pass in a null or system context, which will both return an empty string and no * params. * * Returns an array with two nulls if there can be no results for the activity within this * context (e.g. it is a block context). * * If named parameters are used, these will be named gcrs0, gcrs1, etc. The table aliases used * in SQL also all begin with gcrs, to avoid conflicts. * * @param \context|null $context Context to restrict the query * @param string $modname Name of module e.g. 'forum' * @param string $modtable Alias of table containing module id * @param int $paramtype Type of SQL parameters to use (default question mark) * @return array Array with SQL and parameters; both null if no need to query * @throws \coding_exception If called with invalid params */ protected function get_context_restriction_sql(?\context $context, $modname, $modtable, $paramtype = SQL_PARAMS_QM) { global $DB; if (!$context) { return ['', []]; } switch ($paramtype) { case SQL_PARAMS_QM: $param1 = '?'; $param2 = '?'; $param3 = '?'; $key1 = 0; $key2 = 1; $key3 = 2; break; case SQL_PARAMS_NAMED: $param1 = ':gcrs0'; $param2 = ':gcrs1'; $param3 = ':gcrs2'; $key1 = 'gcrs0'; $key2 = 'gcrs1'; $key3 = 'gcrs2'; break; default: throw new \coding_exception('Unexpected $paramtype: ' . $paramtype); } $params = []; switch ($context->contextlevel) { case CONTEXT_SYSTEM: $sql = ''; break; case CONTEXT_COURSECAT: // Find all activities of this type within the specified category or any // sub-category. $pathmatch = $DB->sql_like('gcrscc2.path', $DB->sql_concat('gcrscc1.path', $param3)); $sql = " JOIN {course_modules} gcrscm ON gcrscm.instance = $modtable.id AND gcrscm.module = (SELECT id FROM {modules} WHERE name = $param1) JOIN {course} gcrsc ON gcrsc.id = gcrscm.course JOIN {course_categories} gcrscc1 ON gcrscc1.id = $param2 JOIN {course_categories} gcrscc2 ON gcrscc2.id = gcrsc.category AND (gcrscc2.id = gcrscc1.id OR $pathmatch) "; $params[$key1] = $modname; $params[$key2] = $context->instanceid; // Note: This param is a bit annoying as it obviously never changes, but sql_like // throws a debug warning if you pass it anything with quotes in, so it has to be // a bound parameter. $params[$key3] = '/%'; break; case CONTEXT_COURSE: // Find all activities of this type within the course. $sql = " JOIN {course_modules} gcrscm ON gcrscm.instance = $modtable.id AND gcrscm.course = $param1 AND gcrscm.module = (SELECT id FROM {modules} WHERE name = $param2) "; $params[$key1] = $context->instanceid; $params[$key2] = $modname; break; case CONTEXT_MODULE: // Find only the specified activity of this type. $sql = " JOIN {course_modules} gcrscm ON gcrscm.instance = $modtable.id AND gcrscm.id = $param1 AND gcrscm.module = (SELECT id FROM {modules} WHERE name = $param2) "; $params[$key1] = $context->instanceid; $params[$key2] = $modname; break; case CONTEXT_BLOCK: case CONTEXT_USER: // These contexts cannot contain any activities, so return null. return [null, null]; default: throw new \coding_exception('Unexpected contextlevel: ' . $context->contextlevel); } return [$sql, $params]; } /** * This can be used in subclasses to change ordering within the get_contexts_to_reindex * function. * * It returns 2 values: * - Extra SQL joins (tables course_modules 'cm' and context 'x' already exist). * - An ORDER BY value which must use aggregate functions, by default 'MAX(cm.added) DESC'. * * Note the query already includes a GROUP BY on the context fields, so if your joins result * in multiple rows, you can use aggregate functions in the ORDER BY. See forum for an example. * * @return string[] Array with 2 elements; extra joins for the query, and ORDER BY value */ protected function get_contexts_to_reindex_extra_sql() { return ['', 'MAX(cm.added) DESC']; } /** * Gets a list of all contexts to reindex when reindexing this search area. * * For modules, the default is to return all contexts for modules of that type, in order of * time added (most recent first). * * @return \Iterator Iterator of contexts to reindex * @throws \moodle_exception If any DB error */ public function get_contexts_to_reindex() { global $DB; list ($extrajoins, $dborder) = $this->get_contexts_to_reindex_extra_sql(); $contexts = []; $selectcolumns = \context_helper::get_preload_record_columns_sql('x'); $groupbycolumns = ''; foreach (\context_helper::get_preload_record_columns('x') as $column => $thing) { if ($groupbycolumns !== '') { $groupbycolumns .= ','; } $groupbycolumns .= $column; } $rs = $DB->get_recordset_sql(" SELECT $selectcolumns FROM {course_modules} cm JOIN {context} x ON x.instanceid = cm.id AND x.contextlevel = ? $extrajoins WHERE cm.module = (SELECT id FROM {modules} WHERE name = ?) GROUP BY $groupbycolumns ORDER BY $dborder", [CONTEXT_MODULE, $this->get_module_name()]); return new \core\dml\recordset_walk($rs, function($rec) { $id = $rec->ctxid; \context_helper::preload_from_record($rec); return \context::instance_by_id($id); }); } /** * Indicates whether this search area may restrict access by group. * * This should return true if the search area (sometimes) sets the 'groupid' schema field, and * false if it never sets that field. * * (If this function returns false, but the field is set, then results may be restricted * unintentionally.) * * If this returns true, the search engine will automatically apply group restrictions in some * cases (by default, where a module is configured to use separate groups). See function * restrict_cm_access_by_group(). * * @return bool */ public function supports_group_restriction() { return false; } /** * Checks whether the content of this search area should be restricted by group for a * specific module. Called at query time. * * The default behaviour simply checks if the effective group mode is SEPARATEGROUPS, which * is probably correct for most cases. * * If restricted by group, the search query will (where supported by the engine) filter out * results for groups the user does not belong to, unless the user has 'access all groups' * for the activity. This affects only documents which set the 'groupid' field; results with no * groupid will not be restricted. * * Even if you return true to this function, you may still need to do group access checks in * check_access, because the search engine may not support group restrictions. * * @param \cm_info $cm * @return bool True to restrict by group */ public function restrict_cm_access_by_group(\cm_info $cm) { return $cm->effectivegroupmode == SEPARATEGROUPS; } /** * Returns an icon instance for the document. * * @param \core_search\document $doc * @return \core_search\document_icon */ public function get_doc_icon(document $doc) : document_icon { return new document_icon('monologo', $this->get_module_name()); } /** * Returns a list of category names associated with the area. * * @return array */ public function get_category_names() { return [manager::SEARCH_AREA_CATEGORY_COURSE_CONTENT]; } } classes/skip_future_documents_iterator.php 0000644 00000006475 15152012435 0015256 0 ustar 00 <?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/>. /** * Iterator for skipping search recordset documents that are in the future. * * @package core_search * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die(); /** * Iterator for skipping search recordset documents that are in the future. * * This iterator stops iterating if it receives a document that was modified later than the * specified cut-off time (usually current time). * * This iterator assumes that its parent iterator returns documents in modified order (which is * required to be the case for search indexing). This means we will stop retrieving data from the * recordset * * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class skip_future_documents_iterator implements \Iterator { /** @var \Iterator Parent iterator */ protected $parent; /** @var int Cutoff time; anything later than this will cause the iterator to stop */ protected $cutoff; /** @var mixed Current value of iterator */ protected $currentdoc; /** @var bool True if current value is available */ protected $gotcurrent; /** * Constructor. * * @param \Iterator $parent Parent iterator, must return search documents in modified order * @param int $cutoff Cut-off time, default is current time */ public function __construct(\Iterator $parent, $cutoff = 0) { $this->parent = $parent; if ($cutoff) { $this->cutoff = $cutoff; } else { $this->cutoff = time(); } } #[\ReturnTypeWillChange] public function current() { if (!$this->gotcurrent) { $this->currentdoc = $this->parent->current(); $this->gotcurrent = true; } return $this->currentdoc; } public function next(): void { $this->parent->next(); $this->gotcurrent = false; } #[\ReturnTypeWillChange] public function key() { return $this->parent->key(); } public function valid(): bool { // Check that the parent is valid. if (!$this->parent->valid()) { return false; } if ($doc = $this->current()) { // This document is valid if the modification date is before the cutoff. return $doc->get('modified') <= $this->cutoff; } else { // If the document is false/null, allow iterator to continue. return true; } } public function rewind(): void { $this->parent->rewind(); $this->gotcurrent = false; } } classes/document_factory.php 0000644 00000005401 15152012435 0012255 0 ustar 00 <?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/>. /** * Search documents factory. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die(); /** * Search document factory. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class document_factory { /** * The document class used by search engines. * * Defined as an array to prevent unexpected caching issues, it should only contain one search * engine as only one search engine will be used during a request. This might change during * testing, remember to use document_factory::clean_statics in that case. * * @var array */ protected static $docclassnames = array(); /** * Returns the appropiate document object as it depends on the engine. * * @param int $itemid Document itemid * @param string $componentname Document component name * @param string $areaname Document area name * @param \core_search\engine $engine Falls back to the search engine in use. * @return \core_search\document Base document or the engine implementation. */ public static function instance($itemid, $componentname, $areaname, $engine = false) { if ($engine === false) { $search = \core_search\manager::instance(); $engine = $search->get_engine(); } $pluginname = $engine->get_plugin_name(); if (!empty(self::$docclassnames[$pluginname])) { return new self::$docclassnames[$pluginname]($itemid, $componentname, $areaname); } self::$docclassnames[$pluginname] = $engine->get_document_classname(); return new self::$docclassnames[$pluginname]($itemid, $componentname, $areaname); } /** * Clears static vars. * * @return void */ public static function clean_static() { self::$docclassnames = array(); } } classes/external.php 0000644 00000010522 15152012435 0010532 0 ustar 00 <?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/>. /** * Handles external (web service) function calls related to search. * * @package core_search * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; use core_user\external\user_summary_exporter; use \external_value; use \external_single_structure; use \external_multiple_structure; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir . '/externallib.php'); /** * Handles external (web service) function calls related to search. * * @package core_search * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class external extends \external_api { /** * Returns parameter types for get_relevant_users function. * * @return \external_function_parameters Parameters */ public static function get_relevant_users_parameters() { return new \external_function_parameters([ 'query' => new external_value(PARAM_RAW, 'Query string (full or partial user full name or other details)'), 'courseid' => new external_value(PARAM_INT, 'Course id (0 if none)'), ]); } /** * Returns result type for get_relevant_users function. * * @return \external_description Result type */ public static function get_relevant_users_returns() { return new external_multiple_structure( new external_single_structure([ 'id' => new external_value(PARAM_INT, 'User id'), 'fullname' => new external_value(PARAM_RAW, 'Full name as text'), 'profileimageurlsmall' => new external_value(PARAM_URL, 'URL to small profile image') ])); } /** * Searches for users given a query, taking into account the current user's permissions and * possibly a course to check within. * * @param string $query Query text * @param int $courseid Course id or 0 if no restriction * @return array Defined return structure */ public static function get_relevant_users($query, $courseid) { global $CFG, $PAGE; // Validate parameter. [ 'query' => $query, 'courseid' => $courseid, ] = self::validate_parameters(self::get_relevant_users_parameters(), [ 'query' => $query, 'courseid' => $courseid, ]); // Validate the context (search page is always system context). $systemcontext = \context_system::instance(); self::validate_context($systemcontext); // Get course object too. if ($courseid) { $coursecontext = \context_course::instance($courseid); } else { $coursecontext = null; } // If not logged in, can't see anyone when forceloginforprofiles is on. if (!empty($CFG->forceloginforprofiles)) { if (!isloggedin() || isguestuser()) { return []; } } $users = \core_user::search($query, $coursecontext); $result = []; foreach ($users as $user) { // Get a standard exported user object. $fulldetails = (new user_summary_exporter($user))->export($PAGE->get_renderer('core')); // To avoid leaking private data to students, only include the specific information we // are going to display (and not the email, idnumber, etc). $result[] = (object)['id' => $fulldetails->id, 'fullname' => $fulldetails->fullname, 'profileimageurlsmall' => $fulldetails->profileimageurlsmall]; } return $result; } } classes/base_activity.php 0000644 00000021047 15152012435 0011542 0 ustar 00 <?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/>. /** * Search area base class for activities. * * @package core_search * @copyright 2016 Dan Poltawski * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die(); /** * Base implementation for activity modules. * * @package core_search * @copyright 2016 Dan Poltawski * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class base_activity extends base_mod { /** * @var string The time modified field name. * * Activities not using timemodified as field name * can overwrite this constant. */ const MODIFIED_FIELD_NAME = 'timemodified'; /** * Activities with a time created field can overwrite this constant. */ const CREATED_FIELD_NAME = ''; /** * The context levels the search area is working on. * @var array */ protected static $levels = [CONTEXT_MODULE]; /** * Returns recordset containing all activities within the given context. * * @param \context|null $context Context * @param int $modifiedfrom Return only records modified after this date * @return \moodle_recordset|null Recordset, or null if no possible activities in given context */ public function get_document_recordset($modifiedfrom = 0, \context $context = null) { global $DB; list ($contextjoin, $contextparams) = $this->get_context_restriction_sql( $context, $this->get_module_name(), 'modtable'); if ($contextjoin === null) { return null; } return $DB->get_recordset_sql('SELECT modtable.* FROM {' . $this->get_module_name() . '} modtable ' . $contextjoin . ' WHERE modtable.' . static::MODIFIED_FIELD_NAME . ' >= ? ORDER BY modtable.' . static::MODIFIED_FIELD_NAME . ' ASC', array_merge($contextparams, [$modifiedfrom])); } /** * Returns the document associated with this activity. * * This default implementation for activities sets the activity name to title and the activity intro to * content. Any activity can overwrite this function if it is interested in setting other fields than the * default ones, or to fill description optional fields with extra stuff. * * @param stdClass $record * @param array $options * @return \core_search\document */ public function get_document($record, $options = array()) { try { $cm = $this->get_cm($this->get_module_name(), $record->id, $record->course); $context = \context_module::instance($cm->id); } catch (\dml_missing_record_exception $ex) { // Notify it as we run here as admin, we should see everything. debugging('Error retrieving ' . $this->areaid . ' ' . $record->id . ' document, not all required data is available: ' . $ex->getMessage(), DEBUG_DEVELOPER); return false; } catch (\dml_exception $ex) { // Notify it as we run here as admin, we should see everything. debugging('Error retrieving ' . $this->areaid . ' ' . $record->id . ' document: ' . $ex->getMessage(), DEBUG_DEVELOPER); return false; } // Prepare associative array with data from DB. $doc = \core_search\document_factory::instance($record->id, $this->componentname, $this->areaname); $doc->set('title', content_to_text($record->name, false)); $doc->set('content', content_to_text($record->intro, $record->introformat)); $doc->set('contextid', $context->id); $doc->set('courseid', $record->course); $doc->set('owneruserid', \core_search\manager::NO_OWNER_ID); $doc->set('modified', $record->{static::MODIFIED_FIELD_NAME}); // Check if this document should be considered new. if (isset($options['lastindexedtime'])) { $createdfield = static::CREATED_FIELD_NAME; if (!empty($createdfield) && ($options['lastindexedtime'] < $record->{$createdfield})) { // If the document was created after the last index time, it must be new. $doc->set_is_new(true); } } return $doc; } /** * Whether the user can access the document or not. * * @throws \dml_missing_record_exception * @throws \dml_exception * @param int $id The activity instance id. * @return bool */ public function check_access($id) { global $DB; try { $activity = $this->get_activity($id); $cminfo = $this->get_cm($this->get_module_name(), $activity->id, $activity->course); $cminfo->get_course_module_record(); } catch (\dml_missing_record_exception $ex) { return \core_search\manager::ACCESS_DELETED; } catch (\dml_exception $ex) { return \core_search\manager::ACCESS_DENIED; } // Recheck uservisible although it should have already been checked in core_search. if ($cminfo->uservisible === false) { return \core_search\manager::ACCESS_DENIED; } return \core_search\manager::ACCESS_GRANTED; } /** * Link to the module instance. * * @param \core_search\document $doc * @return \moodle_url */ public function get_doc_url(\core_search\document $doc) { return $this->get_context_url($doc); } /** * Link to the module instance. * * @param \core_search\document $doc * @return \moodle_url */ public function get_context_url(\core_search\document $doc) { $cminfo = $this->get_cm($this->get_module_name(), strval($doc->get('itemid')), $doc->get('courseid')); return new \moodle_url('/mod/' . $this->get_module_name() . '/view.php', array('id' => $cminfo->id)); } /** * Returns an activity instance. Internally uses the class component to know which activity module should be retrieved. * * @param int $instanceid * @return stdClass */ protected function get_activity($instanceid) { global $DB; if (empty($this->activitiesdata[$this->get_module_name()][$instanceid])) { $this->activitiesdata[$this->get_module_name()][$instanceid] = $DB->get_record($this->get_module_name(), array('id' => $instanceid), '*', MUST_EXIST); } return $this->activitiesdata[$this->get_module_name()][$instanceid]; } /** * Return the context info required to index files for * this search area. * * Should be onerridden by each search area. * * @return array */ public function get_search_fileareas() { $fileareas = array( 'intro' // Fileareas. ); return $fileareas; } /** * Files related to the current document are attached, * to the document object ready for indexing by * Global Search. * * The default implementation retrieves all files for * the file areas returned by get_search_fileareas(). * If you need to filter files to specific items per * file area, you will need to override this method * and explicitly provide the items. * * @param document $document The current document * @return void */ public function attach_files($document) { $fileareas = $this->get_search_fileareas(); if (!empty($fileareas)) { $cm = $this->get_cm($this->get_module_name(), $document->get('itemid'), $document->get('courseid')); $context = \context_module::instance($cm->id); $contextid = $context->id; $fs = get_file_storage(); $files = $fs->get_area_files($contextid, $this->get_component_name(), $fileareas, false, '', false); foreach ($files as $file) { $document->add_stored_file($file); } } return; } } classes/base_block.php 0000644 00000042345 15152012435 0011004 0 ustar 00 <?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/>. /** * Search area base class for blocks. * * Note: Only blocks within courses are supported. * * @package core_search * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die(); /** * Search area base class for blocks. * * Note: Only blocks within courses are supported. * * @package core_search * @copyright 2017 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class base_block extends base { /** @var string Cache name used for block instances */ const CACHE_INSTANCES = 'base_block_instances'; /** * The context levels the search area is working on. * * This can be overwriten by the search area if it works at multiple * levels. * * @var array */ protected static $levels = [CONTEXT_BLOCK]; /** * Gets the block name only. * * @return string Block name e.g. 'html' */ public function get_block_name() { // Remove 'block_' text. return substr($this->get_component_name(), 6); } /** * Returns restrictions on which block_instances rows to return. By default, excludes rows * that have empty configdata. * * If no restriction is required, you could return ['', []]. * * @return array 2-element array of SQL restriction and params for it */ protected function get_indexing_restrictions() { global $DB; // This includes completely empty configdata, and also three other values that are // equivalent to empty: // - A serialized completely empty object. // - A serialized object with one field called '0' (string not int) set to boolean false // (this can happen after backup and restore, at least historically). // - A serialized null. $stupidobject = (object)[]; $zero = '0'; $stupidobject->{$zero} = false; return [$DB->sql_compare_text('bi.configdata') . " != ? AND " . $DB->sql_compare_text('bi.configdata') . " != ? AND " . $DB->sql_compare_text('bi.configdata') . " != ? AND " . $DB->sql_compare_text('bi.configdata') . " != ?", ['', base64_encode(serialize((object)[])), base64_encode(serialize($stupidobject)), base64_encode(serialize(null))]]; } /** * Gets recordset of all blocks of this type modified since given time within the given context. * * See base class for detailed requirements. This implementation includes the key fields * from block_instances. * * This can be overridden to do something totally different if the block's data is stored in * other tables. * * If there are certain instances of the block which should not be included in the search index * then you can override get_indexing_restrictions; by default this excludes rows with empty * configdata. * * @param int $modifiedfrom Return only records modified after this date * @param \context|null $context Context to find blocks within * @return false|\moodle_recordset|null */ public function get_document_recordset($modifiedfrom = 0, \context $context = null) { global $DB; // Get context restrictions. list ($contextjoin, $contextparams) = $this->get_context_restriction_sql($context, 'bi'); // Get custom restrictions for block type. list ($restrictions, $restrictionparams) = $this->get_indexing_restrictions(); if ($restrictions) { $restrictions = 'AND ' . $restrictions; } // Query for all entries in block_instances for this type of block, within the specified // context. The query is based on the one from get_recordset_by_timestamp and applies the // same restrictions. return $DB->get_recordset_sql(" SELECT bi.id, bi.timemodified, bi.timecreated, bi.configdata, c.id AS courseid, x.id AS contextid FROM {block_instances} bi $contextjoin JOIN {context} x ON x.instanceid = bi.id AND x.contextlevel = ? JOIN {context} parent ON parent.id = bi.parentcontextid LEFT JOIN {course_modules} cm ON cm.id = parent.instanceid AND parent.contextlevel = ? JOIN {course} c ON c.id = cm.course OR (c.id = parent.instanceid AND parent.contextlevel = ?) WHERE bi.timemodified >= ? AND bi.blockname = ? AND (parent.contextlevel = ? AND (" . $DB->sql_like('bi.pagetypepattern', '?') . " OR bi.pagetypepattern IN ('site-index', 'course-*', '*'))) $restrictions ORDER BY bi.timemodified ASC", array_merge($contextparams, [CONTEXT_BLOCK, CONTEXT_MODULE, CONTEXT_COURSE, $modifiedfrom, $this->get_block_name(), CONTEXT_COURSE, 'course-view-%'], $restrictionparams)); } public function get_doc_url(\core_search\document $doc) { // Load block instance and find cmid if there is one. $blockinstanceid = preg_replace('~^.*-~', '', $doc->get('id')); $instance = $this->get_block_instance($blockinstanceid); $courseid = $doc->get('courseid'); $anchor = 'inst' . $blockinstanceid; // Check if the block is at course or module level. if ($instance->cmid) { // No module-level page types are supported at present so the search system won't return // them. But let's put some example code here to indicate how it could work. debugging('Unexpected module-level page type for block ' . $blockinstanceid . ': ' . $instance->pagetypepattern, DEBUG_DEVELOPER); $modinfo = get_fast_modinfo($courseid); $cm = $modinfo->get_cm($instance->cmid); return new \moodle_url($cm->url, null, $anchor); } else { // The block is at course level. Let's check the page type, although in practice we // currently only support the course main page. if ($instance->pagetypepattern === '*' || $instance->pagetypepattern === 'course-*' || preg_match('~^course-view-(.*)$~', $instance->pagetypepattern)) { return new \moodle_url('/course/view.php', ['id' => $courseid], $anchor); } else if ($instance->pagetypepattern === 'site-index') { return new \moodle_url('/', ['redirect' => 0], $anchor); } else { debugging('Unexpected page type for block ' . $blockinstanceid . ': ' . $instance->pagetypepattern, DEBUG_DEVELOPER); return new \moodle_url('/course/view.php', ['id' => $courseid], $anchor); } } } public function get_context_url(\core_search\document $doc) { return $this->get_doc_url($doc); } /** * Checks access for a document in this search area. * * If you override this function for a block, you should call this base class version first * as it will check that the block is still visible to users in a supported location. * * @param int $id Document id * @return int manager:ACCESS_xx constant */ public function check_access($id) { $instance = $this->get_block_instance($id, IGNORE_MISSING); if (!$instance) { // This generally won't happen because if the block has been deleted then we won't have // included its context in the search area list, but just in case. return manager::ACCESS_DELETED; } // Check block has not been moved to an unsupported area since it was indexed. (At the // moment, only blocks within site and course context are supported, also only certain // page types.) if (!$instance->courseid || !self::is_supported_page_type_at_course_context($instance->pagetypepattern)) { return manager::ACCESS_DELETED; } // Note we do not need to check if the block was hidden or if the user has access to the // context, because those checks are included in the list of search contexts user can access // that is calculated in manager.php every time they do a query. return manager::ACCESS_GRANTED; } /** * Checks if a page type is supported for blocks when at course (or also site) context. This * function should be consistent with the SQL in get_recordset_by_timestamp. * * @param string $pagetype Page type * @return bool True if supported */ protected static function is_supported_page_type_at_course_context($pagetype) { if (in_array($pagetype, ['site-index', 'course-*', '*'])) { return true; } if (preg_match('~^course-view-~', $pagetype)) { return true; } return false; } /** * Gets a block instance with given id. * * Returns the fields id, pagetypepattern, subpagepattern from block_instances and also the * cmid (if parent context is an activity module). * * @param int $id ID of block instance * @param int $strictness MUST_EXIST or IGNORE_MISSING * @return false|mixed Block instance data (may be false if strictness is IGNORE_MISSING) */ protected function get_block_instance($id, $strictness = MUST_EXIST) { global $DB; $cache = \cache::make_from_params(\cache_store::MODE_REQUEST, 'core_search', self::CACHE_INSTANCES, [], ['simplekeys' => true]); $id = (int)$id; $instance = $cache->get($id); if (!$instance) { $instance = $DB->get_record_sql(" SELECT bi.id, bi.pagetypepattern, bi.subpagepattern, c.id AS courseid, cm.id AS cmid FROM {block_instances} bi JOIN {context} parent ON parent.id = bi.parentcontextid LEFT JOIN {course} c ON c.id = parent.instanceid AND parent.contextlevel = ? LEFT JOIN {course_modules} cm ON cm.id = parent.instanceid AND parent.contextlevel = ? WHERE bi.id = ?", [CONTEXT_COURSE, CONTEXT_MODULE, $id], $strictness); $cache->set($id, $instance); } return $instance; } /** * Clears static cache. This function can be removed (with calls to it in the test script * replaced with cache_helper::purge_all) if MDL-59427 is fixed. */ public static function clear_static() { \cache::make_from_params(\cache_store::MODE_REQUEST, 'core_search', self::CACHE_INSTANCES, [], ['simplekeys' => true])->purge(); } /** * Helper function that gets SQL useful for restricting a search query given a passed-in * context. * * The SQL returned will be one or more JOIN statements, surrounded by whitespace, which act * as restrictions on the query based on the rows in the block_instances table. * * We assume the block instances have already been restricted by blockname. * * Returns null if there can be no results for this block within this context. * * If named parameters are used, these will be named gcrs0, gcrs1, etc. The table aliases used * in SQL also all begin with gcrs, to avoid conflicts. * * @param \context|null $context Context to restrict the query * @param string $blocktable Alias of block_instances table * @param int $paramtype Type of SQL parameters to use (default question mark) * @return array Array with SQL and parameters * @throws \coding_exception If called with invalid params */ protected function get_context_restriction_sql(\context $context = null, $blocktable = 'bi', $paramtype = SQL_PARAMS_QM) { global $DB; if (!$context) { return ['', []]; } switch ($paramtype) { case SQL_PARAMS_QM: $param1 = '?'; $param2 = '?'; $key1 = 0; $key2 = 1; break; case SQL_PARAMS_NAMED: $param1 = ':gcrs0'; $param2 = ':gcrs1'; $key1 = 'gcrs0'; $key2 = 'gcrs1'; break; default: throw new \coding_exception('Unexpected $paramtype: ' . $paramtype); } $params = []; switch ($context->contextlevel) { case CONTEXT_SYSTEM: $sql = ''; break; case CONTEXT_COURSECAT: case CONTEXT_COURSE: case CONTEXT_MODULE: case CONTEXT_USER: // Find all blocks whose parent is within the specified context. $sql = " JOIN {context} gcrsx ON gcrsx.id = $blocktable.parentcontextid AND (gcrsx.id = $param1 OR " . $DB->sql_like('gcrsx.path', $param2) . ") "; $params[$key1] = $context->id; $params[$key2] = $context->path . '/%'; break; case CONTEXT_BLOCK: // Find only the specified block of this type. Since we are generating JOINs // here, we do this by joining again to the block_instances table with the same ID. $sql = " JOIN {block_instances} gcrsbi ON gcrsbi.id = $blocktable.id AND gcrsbi.id = $param1 "; $params[$key1] = $context->instanceid; break; default: throw new \coding_exception('Unexpected contextlevel: ' . $context->contextlevel); } return [$sql, $params]; } /** * This can be used in subclasses to change ordering within the get_contexts_to_reindex * function. * * It returns 2 values: * - Extra SQL joins (tables block_instances 'bi' and context 'x' already exist). * - An ORDER BY value which must use aggregate functions, by default 'MAX(bi.timemodified) DESC'. * * Note the query already includes a GROUP BY on the context fields, so if your joins result * in multiple rows, you can use aggregate functions in the ORDER BY. See forum for an example. * * @return string[] Array with 2 elements; extra joins for the query, and ORDER BY value */ protected function get_contexts_to_reindex_extra_sql() { return ['', 'MAX(bi.timemodified) DESC']; } /** * Gets a list of all contexts to reindex when reindexing this search area. * * For blocks, the default is to return all contexts for blocks of that type, that are on a * course page, in order of time added (most recent first). * * @return \Iterator Iterator of contexts to reindex * @throws \moodle_exception If any DB error */ public function get_contexts_to_reindex() { global $DB; list ($extrajoins, $dborder) = $this->get_contexts_to_reindex_extra_sql(); $contexts = []; $selectcolumns = \context_helper::get_preload_record_columns_sql('x'); $groupbycolumns = ''; foreach (\context_helper::get_preload_record_columns('x') as $column => $thing) { if ($groupbycolumns !== '') { $groupbycolumns .= ','; } $groupbycolumns .= $column; } $rs = $DB->get_recordset_sql(" SELECT $selectcolumns FROM {block_instances} bi JOIN {context} x ON x.instanceid = bi.id AND x.contextlevel = ? JOIN {context} parent ON parent.id = bi.parentcontextid $extrajoins WHERE bi.blockname = ? AND parent.contextlevel = ? GROUP BY $groupbycolumns ORDER BY $dborder", [CONTEXT_BLOCK, $this->get_block_name(), CONTEXT_COURSE]); return new \core\dml\recordset_walk($rs, function($rec) { $id = $rec->ctxid; \context_helper::preload_from_record($rec); return \context::instance_by_id($id); }); } /** * Returns an icon instance for the document. * * @param \core_search\document $doc * @return \core_search\document_icon */ public function get_doc_icon(document $doc) : document_icon { return new document_icon('e/anchor'); } /** * Returns a list of category names associated with the area. * * @return array */ public function get_category_names() { return [manager::SEARCH_AREA_CATEGORY_COURSE_CONTENT]; } } classes/base.php 0000644 00000047213 15152012435 0007631 0 ustar 00 <?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/>. /** * Search base class to be extended by search areas. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die(); /** * Base search implementation. * * Components and plugins interested in filling the search engine with data should extend this class (or any extension of this * class). * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class base { /** * The area name as defined in the class name. * * @var string */ protected $areaname = null; /** * The component frankenstyle name. * * @var string */ protected $componentname = null; /** * The component type (core or the plugin type). * * @var string */ protected $componenttype = null; /** * The context levels the search implementation is working on. * * @var array */ protected static $levels = [CONTEXT_SYSTEM]; /** * Constructor. * * @throws \coding_exception * @return void */ public final function __construct() { $classname = get_class($this); // Detect possible issues when defining the class. if (strpos($classname, '\search') === false) { throw new \coding_exception('Search area classes should be located in \PLUGINTYPE_PLUGINNAME\search\AREANAME.'); } else if (strpos($classname, '_') === false) { throw new \coding_exception($classname . ' class namespace level 1 should be its component frankenstyle name'); } $this->areaname = substr(strrchr($classname, '\\'), 1); $this->componentname = substr($classname, 0, strpos($classname, '\\')); $this->areaid = \core_search\manager::generate_areaid($this->componentname, $this->areaname); $this->componenttype = substr($this->componentname, 0, strpos($this->componentname, '_')); } /** * Returns context levels property. * * @return int */ public static function get_levels() { return static::$levels; } /** * Returns the area id. * * @return string */ public function get_area_id() { return $this->areaid; } /** * Returns the moodle component name. * * It might be the plugin name (whole frankenstyle name) or the core subsystem name. * * @return string */ public function get_component_name() { return $this->componentname; } /** * Returns the component type. * * It might be a plugintype or 'core' for core subsystems. * * @return string */ public function get_component_type() { return $this->componenttype; } /** * Returns the area visible name. * * @param bool $lazyload Usually false, unless when in admin settings. * @return string */ public function get_visible_name($lazyload = false) { $component = $this->componentname; // Core subsystem strings go to lang/XX/search.php. if ($this->componenttype === 'core') { $component = 'search'; } return get_string('search:' . $this->areaname, $component, null, $lazyload); } /** * Returns the config var name. * * It depends on whether it is a moodle subsystem or a plugin as plugin-related config should remain in their own scope. * * @access private * @return string Config var path including the plugin (or component) and the varname */ public function get_config_var_name() { if ($this->componenttype === 'core') { // Core subsystems config in core_search and setting name using only [a-zA-Z0-9_]+. $parts = \core_search\manager::extract_areaid_parts($this->areaid); return array('core_search', $parts[0] . '_' . $parts[1]); } // Plugins config in the plugin scope. return array($this->componentname, 'search_' . $this->areaname); } /** * Returns all the search area configuration. * * @return array */ public function get_config() { list($componentname, $varname) = $this->get_config_var_name(); $config = []; $settingnames = self::get_settingnames(); foreach ($settingnames as $name) { $config[$varname . $name] = get_config($componentname, $varname . $name); } // Search areas are enabled by default. if ($config[$varname . '_enabled'] === false) { $config[$varname . '_enabled'] = 1; } return $config; } /** * Return a list of all required setting names. * * @return array */ public static function get_settingnames() { return array('_enabled', '_indexingstart', '_indexingend', '_lastindexrun', '_docsignored', '_docsprocessed', '_recordsprocessed', '_partial'); } /** * Is the search component enabled by the system administrator? * * @return bool */ public function is_enabled() { list($componentname, $varname) = $this->get_config_var_name(); $value = get_config($componentname, $varname . '_enabled'); // Search areas are enabled by default. if ($value === false) { $value = 1; } return (bool)$value; } public function set_enabled($isenabled) { list($componentname, $varname) = $this->get_config_var_name(); return set_config($varname . '_enabled', $isenabled, $componentname); } /** * Gets the length of time spent indexing this area (the last time it was indexed). * * @return int|bool Time in seconds spent indexing this area last time, false if never indexed */ public function get_last_indexing_duration() { list($componentname, $varname) = $this->get_config_var_name(); $start = get_config($componentname, $varname . '_indexingstart'); $end = get_config($componentname, $varname . '_indexingend'); if ($start && $end) { return $end - $start; } else { return false; } } /** * Returns true if this area uses file indexing. * * @return bool */ public function uses_file_indexing() { return false; } /** * Returns a recordset ordered by modification date ASC. * * Each record can include any data self::get_document might need but it must: * - Include an 'id' field: Unique identifier (in this area's scope) of a document to index in the search engine * If the indexed content field can contain embedded files, the 'id' value should match the filearea itemid. * - Only return data modified since $modifiedfrom, including $modifiedform to prevent * some records from not being indexed (e.g. your-timemodified-fieldname >= $modifiedfrom) * - Order the returned data by time modified in ascending order, as \core_search::manager will need to store the modified time * of the last indexed document. * * Since Moodle 3.4, subclasses should instead implement get_document_recordset, which has * an additional context parameter. This function continues to work for implementations which * haven't been updated, or where the context parameter is not required. * * @param int $modifiedfrom * @return \moodle_recordset */ public function get_recordset_by_timestamp($modifiedfrom = 0) { $result = $this->get_document_recordset($modifiedfrom); if ($result === false) { throw new \coding_exception( 'Search area must implement get_document_recordset or get_recordset_by_timestamp'); } return $result; } /** * Returns a recordset containing all items from this area, optionally within the given context, * and including only items modifed from (>=) the specified time. The recordset must be ordered * in ascending order of modified time. * * Each record can include any data self::get_document might need. It must include an 'id' * field,a unique identifier (in this area's scope) of a document to index in the search engine. * If the indexed content field can contain embedded files, the 'id' value should match the * filearea itemid. * * The return value can be a recordset, null (if this area does not provide any results in the * given context and there is no need to do a database query to find out), or false (if this * facility is not currently supported by this search area). * * If this function returns false, then: * - If indexing the entire system (no context restriction) the search indexer will try * get_recordset_by_timestamp instead * - If trying to index a context (e.g. when restoring a course), the search indexer will not * index this area, so that restored content may not be indexed. * * The default implementation returns false, indicating that this facility is not supported and * the older get_recordset_by_timestamp function should be used. * * This function must accept all possible values for the $context parameter. For example, if * you are implementing this function for the forum module, it should still operate correctly * if called with the context for a glossary module, or for the HTML block. (In these cases * where it will not return any data, it may return null.) * * The $context parameter can also be null or the system context; both of these indicate that * all data, without context restriction, should be returned. * * @param int $modifiedfrom Return only records modified after this date * @param \context|null $context Context (null means no context restriction) * @return \moodle_recordset|null|false Recordset / null if no results / false if not supported * @since Moodle 3.4 */ public function get_document_recordset($modifiedfrom = 0, \context $context = null) { return false; } /** * Checks if get_document_recordset is supported for this search area. * * For many uses you can simply call get_document_recordset and see if it returns false, but * this function is useful when you don't want to actually call the function right away. */ public function supports_get_document_recordset() { // Easiest way to check this is simply to see if the class has overridden the default // function. $method = new \ReflectionMethod($this, 'get_document_recordset'); return $method->getDeclaringClass()->getName() !== self::class; } /** * Returns the document related with the provided record. * * This method receives a record with the document id and other info returned by get_recordset_by_timestamp * or get_recordset_by_contexts that might be useful here. The idea is to restrict database queries to * minimum as this function will be called for each document to index. As an alternative, use cached data. * * Internally it should use \core_search\document to standarise the documents before sending them to the search engine. * * Search areas should send plain text to the search engine, use the following function to convert any user * input data to plain text: {@link content_to_text} * * Valid keys for the options array are: * indexfiles => File indexing is enabled if true. * lastindexedtime => The last time this area was indexed. 0 if never indexed. * * The lastindexedtime value is not set if indexing a specific context rather than the whole * system. * * @param \stdClass $record A record containing, at least, the indexed document id and a modified timestamp * @param array $options Options for document creation * @return \core_search\document */ abstract public function get_document($record, $options = array()); /** * Returns the document title to display. * * Allow to customize the document title string to display. * * @param \core_search\document $doc * @return string Document title to display in the search results page */ public function get_document_display_title(\core_search\document $doc) { return $doc->get('title'); } /** * Return the context info required to index files for * this search area. * * Should be onerridden by each search area. * * @return array */ public function get_search_fileareas() { $fileareas = array(); return $fileareas; } /** * Files related to the current document are attached, * to the document object ready for indexing by * Global Search. * * The default implementation retrieves all files for * the file areas returned by get_search_fileareas(). * If you need to filter files to specific items per * file area, you will need to override this method * and explicitly provide the items. * * @param document $document The current document * @return void */ public function attach_files($document) { $fileareas = $this->get_search_fileareas(); $contextid = $document->get('contextid'); $component = $this->get_component_name(); $itemid = $document->get('itemid'); foreach ($fileareas as $filearea) { $fs = get_file_storage(); $files = $fs->get_area_files($contextid, $component, $filearea, $itemid, '', false); foreach ($files as $file) { $document->add_stored_file($file); } } } /** * Can the current user see the document. * * @param int $id The internal search area entity id. * @return int manager:ACCESS_xx constant */ abstract public function check_access($id); /** * Returns a url to the document, it might match self::get_context_url(). * * @param \core_search\document $doc * @return \moodle_url */ abstract public function get_doc_url(\core_search\document $doc); /** * Returns a url to the document context. * * @param \core_search\document $doc * @return \moodle_url */ abstract public function get_context_url(\core_search\document $doc); /** * Helper function that gets SQL useful for restricting a search query given a passed-in * context, for data stored at course level. * * The SQL returned will be zero or more JOIN statements, surrounded by whitespace, which act * as restrictions on the query based on the rows in a module table. * * You can pass in a null or system context, which will both return an empty string and no * params. * * Returns an array with two nulls if there can be no results for a course within this context. * * If named parameters are used, these will be named gclcrs0, gclcrs1, etc. The table aliases * used in SQL also all begin with gclcrs, to avoid conflicts. * * @param \context|null $context Context to restrict the query * @param string $coursetable Name of alias for course table e.g. 'c' * @param int $paramtype Type of SQL parameters to use (default question mark) * @return array Array with SQL and parameters; both null if no need to query * @throws \coding_exception If called with invalid params */ protected function get_course_level_context_restriction_sql(?\context $context, $coursetable, $paramtype = SQL_PARAMS_QM) { global $DB; if (!$context) { return ['', []]; } switch ($paramtype) { case SQL_PARAMS_QM: $param1 = '?'; $param2 = '?'; $key1 = 0; $key2 = 1; break; case SQL_PARAMS_NAMED: $param1 = ':gclcrs0'; $param2 = ':gclcrs1'; $key1 = 'gclcrs0'; $key2 = 'gclcrs1'; break; default: throw new \coding_exception('Unexpected $paramtype: ' . $paramtype); } $params = []; switch ($context->contextlevel) { case CONTEXT_SYSTEM: $sql = ''; break; case CONTEXT_COURSECAT: // Find all courses within the specified category or any sub-category. $pathmatch = $DB->sql_like('gclcrscc2.path', $DB->sql_concat('gclcrscc1.path', $param2)); $sql = " JOIN {course_categories} gclcrscc1 ON gclcrscc1.id = $param1 JOIN {course_categories} gclcrscc2 ON gclcrscc2.id = $coursetable.category AND (gclcrscc2.id = gclcrscc1.id OR $pathmatch) "; $params[$key1] = $context->instanceid; // Note: This param is a bit annoying as it obviously never changes, but sql_like // throws a debug warning if you pass it anything with quotes in, so it has to be // a bound parameter. $params[$key2] = '/%'; break; case CONTEXT_COURSE: // We just join again against the same course entry and confirm that it has the // same id as the context. $sql = " JOIN {course} gclcrsc ON gclcrsc.id = $coursetable.id AND gclcrsc.id = $param1"; $params[$key1] = $context->instanceid; break; case CONTEXT_BLOCK: case CONTEXT_MODULE: case CONTEXT_USER: // Context cannot contain any courses. return [null, null]; default: throw new \coding_exception('Unexpected contextlevel: ' . $context->contextlevel); } return [$sql, $params]; } /** * Gets a list of all contexts to reindex when reindexing this search area. The list should be * returned in an order that is likely to be suitable when reindexing, for example with newer * contexts first. * * The default implementation simply returns the system context, which will result in * reindexing everything in normal date order (oldest first). * * @return \Iterator Iterator of contexts to reindex */ public function get_contexts_to_reindex() { return new \ArrayIterator([\context_system::instance()]); } /** * Returns an icon instance for the document. * * @param \core_search\document $doc * @return \core_search\document_icon */ public function get_doc_icon(document $doc) : document_icon { return new document_icon('i/empty'); } /** * Returns a list of category names associated with the area. * * @return array */ public function get_category_names() { return [manager::SEARCH_AREA_CATEGORY_OTHER]; } } classes/engine.php 0000644 00000071213 15152012435 0010161 0 ustar 00 <?php // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Base class for search engines. * * All search engines must extend this class. * * @package core_search * @copyright 2015 Daniel Neis * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die(); /** * Base class for search engines. * * All search engines must extend this class. * * @package core_search * @copyright 2015 Daniel Neis * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class engine { /** * The search engine configuration. * * @var \stdClass */ protected $config = null; /** * Last executed query error, if there was any. * @var string */ protected $queryerror = null; /** * @var array Internal cache. */ protected $cachedareas = array(); /** * @var array Internal cache. */ protected $cachedcourses = array(); /** * User data required to show their fullnames. Indexed by userid. * * @var \stdClass[] */ protected static $cachedusers = array(); /** * @var string Frankenstyle plugin name. */ protected $pluginname = null; /** * @var bool If true, should skip schema validity check when checking the search engine is ready */ protected $skipschemacheck = false; /** * Initialises the search engine configuration. * * 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(bool $alternateconfiguration = false) { $classname = get_class($this); if (strpos($classname, '\\') === false) { throw new \coding_exception('"' . $classname . '" class should specify its component namespace and it should be named engine.'); } else if (strpos($classname, '_') === false) { throw new \coding_exception('"' . $classname . '" class namespace should be its frankenstyle name'); } // This is search_xxxx config. $this->pluginname = substr($classname, 0, strpos($classname, '\\')); if ($config = get_config($this->pluginname)) { $this->config = $config; } 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; } /** * Returns a course instance checking internal caching. * * @param int $courseid * @return stdClass */ protected function get_course($courseid) { if (!empty($this->cachedcourses[$courseid])) { return $this->cachedcourses[$courseid]; } // No need to clone, only read. $this->cachedcourses[$courseid] = get_course($courseid, false); return $this->cachedcourses[$courseid]; } /** * Returns user data checking the internal static cache. * * Including here the minimum required user information as this may grow big. * * @param int $userid * @return stdClass */ public function get_user($userid) { global $DB; if (empty(self::$cachedusers[$userid])) { $userfieldsapi = \core_user\fields::for_name(); $fields = $userfieldsapi->get_sql('', false, '', '', false)->selects; self::$cachedusers[$userid] = $DB->get_record('user', array('id' => $userid), 'id, ' . $fields); } return self::$cachedusers[$userid]; } /** * Clears the users cache. * * @return null */ public static function clear_users_cache() { self::$cachedusers = []; } /** * Returns a search instance of the specified area checking internal caching. * * @param string $areaid Area id * @return \core_search\base */ protected function get_search_area($areaid) { if (isset($this->cachedareas[$areaid]) && $this->cachedareas[$areaid] === false) { // We already checked that area and it is not available. return false; } if (!isset($this->cachedareas[$areaid])) { // First result that matches this area. $this->cachedareas[$areaid] = \core_search\manager::get_search_area($areaid); if ($this->cachedareas[$areaid] === false) { // The area does not exist or it is not available any more. $this->cachedareas[$areaid] = false; return false; } if (!$this->cachedareas[$areaid]->is_enabled()) { // We skip the area if it is not enabled. // Marking it as false so next time we don' need to check it again. $this->cachedareas[$areaid] = false; return false; } } return $this->cachedareas[$areaid]; } /** * Returns a document instance prepared to be rendered. * * @param \core_search\base $searcharea * @param array $docdata * @return \core_search\document */ protected function to_document(\core_search\base $searcharea, $docdata) { list($componentname, $areaname) = \core_search\manager::extract_areaid_parts($docdata['areaid']); $doc = \core_search\document_factory::instance($docdata['itemid'], $componentname, $areaname, $this); $doc->set_data_from_engine($docdata); $doc->set_doc_url($searcharea->get_doc_url($doc)); $doc->set_context_url($searcharea->get_context_url($doc)); $doc->set_doc_icon($searcharea->get_doc_icon($doc)); // Uses the internal caches to get required data needed to render the document later. $course = $this->get_course($doc->get('courseid')); $doc->set_extra('coursefullname', $course->fullname); if ($doc->is_set('userid')) { $user = $this->get_user($doc->get('userid')); $doc->set_extra('userfullname', fullname($user)); } return $doc; } /** * Loop through given iterator of search documents * and and have the search engine back end add them * to the index. * * @param \iterator $iterator the iterator of documents to index * @param base $searcharea the area for the documents to index * @param array $options document indexing options * @return array Processed document counts */ public function add_documents($iterator, $searcharea, $options) { $numrecords = 0; $numdocs = 0; $numdocsignored = 0; $numbatches = 0; $lastindexeddoc = 0; $firstindexeddoc = 0; $partial = false; $lastprogress = manager::get_current_time(); $batchmode = $this->supports_add_document_batch(); $currentbatch = []; foreach ($iterator as $document) { // Stop if we have exceeded the time limit (and there are still more items). Always // do at least one second's worth of documents otherwise it will never make progress. if ($lastindexeddoc !== $firstindexeddoc && !empty($options['stopat']) && manager::get_current_time() >= $options['stopat']) { $partial = true; break; } if (!$document instanceof \core_search\document) { continue; } if (isset($options['lastindexedtime']) && $options['lastindexedtime'] == 0) { // If we have never indexed this area before, it must be new. $document->set_is_new(true); } if ($options['indexfiles']) { // Attach files if we are indexing. $searcharea->attach_files($document); } if ($batchmode && strlen($document->get('content')) <= $this->get_batch_max_content()) { $currentbatch[] = $document; if (count($currentbatch) >= $this->get_batch_max_documents()) { [$processed, $failed, $batches] = $this->add_document_batch($currentbatch, $options['indexfiles']); $numdocs += $processed; $numdocsignored += $failed; $numbatches += $batches; $currentbatch = []; } } else { if ($this->add_document($document, $options['indexfiles'])) { $numdocs++; } else { $numdocsignored++; } $numbatches++; } $lastindexeddoc = $document->get('modified'); if (!$firstindexeddoc) { $firstindexeddoc = $lastindexeddoc; } $numrecords++; // If indexing the area takes a long time, periodically output progress information. if (isset($options['progress'])) { $now = manager::get_current_time(); if ($now - $lastprogress >= manager::DISPLAY_INDEXING_PROGRESS_EVERY) { $lastprogress = $now; // The first date format is the same used in cron_trace_time_and_memory(). $options['progress']->output(date('H:i:s', (int)$now) . ': Done to ' . userdate( $lastindexeddoc, get_string('strftimedatetimeshort', 'langconfig')), 1); } } } // Add remaining documents from batch. if ($batchmode && $currentbatch) { [$processed, $failed, $batches] = $this->add_document_batch($currentbatch, $options['indexfiles']); $numdocs += $processed; $numdocsignored += $failed; $numbatches += $batches; } return [$numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial, $numbatches]; } /** * Returns the plugin name. * * @return string Frankenstyle plugin name. */ public function get_plugin_name() { return $this->pluginname; } /** * Gets the document class used by this search engine. * * Search engines can overwrite \core_search\document with \search_ENGINENAME\document class. * * Looks for a document class in the current search engine namespace, falling back to \core_search\document. * Publicly available because search areas do not have access to the engine details, * \core_search\document_factory accesses this function. * * @return string */ public function get_document_classname() { $classname = $this->pluginname . '\\document'; if (!class_exists($classname)) { $classname = '\\core_search\\document'; } return $classname; } /** * Run any pre-indexing operations. * * Should be overwritten if the search engine needs to do any pre index preparation. * * @param bool $fullindex True if a full index will be performed * @return void */ public function index_starting($fullindex = false) { // Nothing by default. } /** * Run any post indexing operations. * * Should be overwritten if the search engine needs to do any post index cleanup. * * @param int $numdocs The number of documents that were added to the index * @param bool $fullindex True if a full index was performed * @return void */ public function index_complete($numdocs = 0, $fullindex = false) { // Nothing by default. } /** * Do anything that may need to be done before an area is indexed. * * @param \core_search\base $searcharea The search area that was complete * @param bool $fullindex True if a full index is being performed * @return void */ public function area_index_starting($searcharea, $fullindex = false) { // Nothing by default. } /** * Do any area cleanup needed, and do anything to confirm contents. * * Return false to prevent the search area completed time and stats from being updated. * * @param \core_search\base $searcharea The search area that was complete * @param int $numdocs The number of documents that were added to the index * @param bool $fullindex True if a full index is being performed * @return bool True means that data is considered indexed */ public function area_index_complete($searcharea, $numdocs = 0, $fullindex = false) { return true; } /** * Optimizes the search engine. * * Should be overwritten if the search engine can optimize its contents. * * @return void */ public function optimize() { // Nothing by default. mtrace('The ' . get_string('pluginname', $this->get_plugin_name()) . ' search engine does not require automatic optimization.'); } /** * Does the system satisfy all the requirements. * * Should be overwritten if the search engine has any system dependencies * that needs to be checked. * * @return bool */ public function is_installed() { return true; } /** * Returns any error reported by the search engine when executing the provided query. * * It should be called from static::execute_query when an exception is triggered. * * @return string */ public function get_query_error() { return $this->queryerror; } /** * Returns the total number of documents available for the most recent call to execute_query. * * This can be an estimate, but should get more accurate the higher the limited passed to execute_query is. * To do that, the engine can use (actual result returned count + count of unchecked documents), or * (total possible docs - docs that have been checked and rejected). * * Engine can limit to manager::MAX_RESULTS if there is cost to determining more. * If this cannot be computed in a reasonable way, manager::MAX_RESULTS may be returned. * * @return int */ abstract public function get_query_total_count(); /** * Return true if file indexing is supported and enabled. False otherwise. * * @return bool */ public function file_indexing_enabled() { return false; } /** * Clears the current query error value. * * @return void */ public function clear_query_error() { $this->queryerror = null; } /** * Is the server ready to use? * * This should also check that the search engine configuration is ok. * * If the function $this->should_skip_schema_check() returns true, then this function may leave * out time-consuming checks that the schema is valid. (This allows for improved performance on * critical pages such as the main search form.) * * @return true|string Returns true if all good or an error string. */ abstract function is_server_ready(); /** * Tells the search engine to skip any time-consuming checks that it might do as part of the * is_server_ready function, and only carry out a basic check that it can contact the server. * * This setting is not remembered and applies only to the current request. * * @since Moodle 3.5 * @param bool $skip True to skip the checks, false to start checking again */ public function skip_schema_check($skip = true) { $this->skipschemacheck = $skip; } /** * For use by subclasses. The engine can call this inside is_server_ready to check whether it * should skip time-consuming schema checks. * * @since Moodle 3.5 * @return bool True if schema checks should be skipped */ protected function should_skip_schema_check() { return $this->skipschemacheck; } /** * Adds a document to the search engine. * * @param document $document * @param bool $fileindexing True if file indexing is to be used * @return bool False if the file was skipped or failed, true on success */ abstract function add_document($document, $fileindexing = false); /** * Adds multiple documents to the search engine. * * It should return the number successfully processed, and the number of batches they were * processed in (for example if you add 100 documents and there is an error processing one of * those documents, and it took 4 batches, it would return [99, 1, 4]). * * If the engine implements this, it should return true to {@see supports_add_document_batch}. * * The system will only call this function with up to {@see get_batch_max_documents} documents, * and each document in the batch will have content no larger than specified by * {@see get_batch_max_content}. * * @param document[] $documents Documents to add * @param bool $fileindexing True if file indexing is to be used * @return int[] Array of three elements, successfully processed, failed processed, batch count */ public function add_document_batch(array $documents, bool $fileindexing = false): array { throw new \coding_exception('add_document_batch not supported by this engine'); } /** * Executes the query on the engine. * * Implementations of this function should check user context array to limit the results to contexts where the * user have access. They should also limit the owneruserid field to manger::NO_OWNER_ID or the current user's id. * Engines must use area->check_access() to confirm user access. * * Engines should reasonably attempt to fill up to limit with valid results if they are available. * * The $filters object may include the following fields (optional except q): * - q: value of main search field; results should include this text * - title: if included, title must match this search * - areaids: array of search area id strings (only these areas will be searched) * - courseids: array of course ids (only these courses will be searched) * - groupids: array of group ids (only results specifically from these groupids will be * searched) - this option will be ignored if the search engine doesn't support groups * * The $accessinfo parameter has two different values (for historical compatibility). If the * engine returns false to supports_group_filtering then it is an array of user contexts, or * true if the user can access all contexts. (This parameter used to be called $usercontexts.) * If the engine returns true to supports_group_filtering then it will be an object containing * these fields: * - everything (true if admin is searching with no restrictions) * - usercontexts (same as above) * - separategroupscontexts (array of context ids where separate groups are used) * - visiblegroupscontextsareas (array of subset of those where some areas use visible groups) * - usergroups (array of relevant group ids that user belongs to) * * The engine should apply group restrictions to those contexts listed in the * 'separategroupscontexts' array. In these contexts, it shouled only include results if the * groupid is not set, or if the groupid matches one of the values in USER_GROUPS array, or * if the search area is one of those listed in 'visiblegroupscontextsareas' for that context. * * @param \stdClass $filters Query and filters to apply. * @param \stdClass $accessinfo Information about the contexts the user can access * @param int $limit The maximum number of results to return. If empty, limit to manager::MAX_RESULTS. * @return \core_search\document[] Results or false if no results */ public abstract function execute_query($filters, $accessinfo, $limit = 0); /** * Delete all documents. * * @param string $areaid To filter by area * @return void */ abstract function delete($areaid = null); /** * Deletes information related to a specific context id. This should be used when the context * itself is deleted from Moodle. * * This only deletes information for the specified context - not for any child contexts. * * This function is optional; if not supported it will return false and the information will * not be deleted from the search index. * * If an engine implements this function it should also implement delete_index_for_course; * otherwise, nothing will be deleted when users delete an entire course at once. * * @param int $oldcontextid ID of context that has been deleted * @return bool True if implemented * @throws \core_search\engine_exception Engines may throw this exception for any problem */ public function delete_index_for_context(int $oldcontextid) { return false; } /** * Deletes information related to a specific course id. This should be used when the course * itself is deleted from Moodle. * * This deletes all information relating to that course from the index, including all child * contexts. * * This function is optional; if not supported it will return false and the information will * not be deleted from the search index. * * If an engine implements this function then, ideally, it should also implement * delete_index_for_context so that deletion of single activities/blocks also works. * * @param int $oldcourseid ID of course that has been deleted * @return bool True if implemented * @throws \core_search\engine_exception Engines may throw this exception for any problem */ public function delete_index_for_course(int $oldcourseid) { return false; } /** * Checks that the schema is the latest version. If the version stored in config does not match * the current, this function will attempt to upgrade the schema. * * @return bool|string True if schema is OK, a string if user needs to take action */ public function check_latest_schema() { if (empty($this->config->schemaversion)) { $currentversion = 0; } else { $currentversion = $this->config->schemaversion; } if ($currentversion < document::SCHEMA_VERSION) { return $this->update_schema((int)$currentversion, (int)document::SCHEMA_VERSION); } else { return true; } } /** * Usually called by the engine; marks that the schema has been updated. * * @param int $version Records the schema version now applied */ public function record_applied_schema_version($version) { set_config('schemaversion', $version, $this->pluginname); } /** * Requests the search engine to upgrade the schema. The engine should update the schema if * possible/necessary, and should ensure that record_applied_schema_version is called as a * result. * * If it is not possible to upgrade the schema at the moment, it can do nothing and return; the * function will be called again next time search is initialised. * * The default implementation just returns, with a DEBUG_DEVELOPER warning. * * @param int $oldversion Old schema version * @param int $newversion New schema version * @return bool|string True if schema is updated successfully, a string if it needs updating manually */ protected function update_schema($oldversion, $newversion) { debugging('Unable to update search engine schema: ' . $this->pluginname, DEBUG_DEVELOPER); return get_string('schemanotupdated', 'search'); } /** * Checks if this search engine supports groups. * * Note that returning true to this function causes the parameters to execute_query to be * passed differently! * * In order to implement groups and return true to this function, the search engine should: * * 1. Handle the fields ->separategroupscontexts and ->usergroups in the $accessinfo parameter * to execute_query (ideally, using these to automatically restrict search results). * 2. Support the optional groupids parameter in the $filter parameter for execute_query to * restrict results to only those where the stored groupid matches the given value. * * @return bool True if this engine supports searching by group id field */ public function supports_group_filtering() { return false; } /** * Obtain a list of results orders (and names for them) that are supported by this * search engine in the given context. * * By default, engines sort by relevance only. * * @param \context $context Context that the user requested search from * @return array Array from order name => display text */ public function get_supported_orders(\context $context) { return ['relevance' => get_string('order_relevance', 'search')]; } /** * Checks if the search engine supports searching by user. * * If it returns true to this function, the search engine should support the 'userids' option * in the $filters value passed to execute_query(), returning only items where the userid in * the search document matches one of those user ids. * * @return bool True if the search engine supports searching by user */ public function supports_users() { return false; } /** * Checks if the search engine supports adding documents in a batch. * * If it returns true to this function, the search engine must implement the add_document_batch * function. * * @return bool True if the search engine supports adding documents in a batch */ public function supports_add_document_batch(): bool { return false; } /** * Gets the maximum number of documents to send together in batch mode. * * Only relevant if the engine returns true to {@see supports_add_document_batch}. * * Can be overridden by search engine if required. * * @var int Number of documents to send together in batch mode, default 100. */ public function get_batch_max_documents(): int { return 100; } /** * Gets the maximum size of document content to be included in a shared batch (if the * document is bigger then it will be sent on its own; batching does not provide a performance * improvement for big documents anyway). * * Only relevant if the engine returns true to {@see supports_add_document_batch}. * * Can be overridden by search engine if required. * * @return int Max size in bytes, default 1MB */ 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; } } classes/manager.php 0000644 00000233324 15152012435 0010331 0 ustar 00 <?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/>. /** * Search subsystem manager. * * @package core_search * @copyright Prateek Sachan {@link http://prateeksachan.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die; require_once($CFG->dirroot . '/lib/accesslib.php'); /** * Search subsystem manager. * * @package core_search * @copyright Prateek Sachan {@link http://prateeksachan.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class manager { /** * @var int Text contents. */ const TYPE_TEXT = 1; /** * @var int File contents. */ const TYPE_FILE = 2; /** * @var int User can not access the document. */ const ACCESS_DENIED = 0; /** * @var int User can access the document. */ const ACCESS_GRANTED = 1; /** * @var int The document was deleted. */ const ACCESS_DELETED = 2; /** * @var int Maximum number of results that will be retrieved from the search engine. */ const MAX_RESULTS = 100; /** * @var int Number of results per page. */ const DISPLAY_RESULTS_PER_PAGE = 10; /** * @var int The id to be placed in owneruserid when there is no owner. */ const NO_OWNER_ID = 0; /** * @var float If initial query takes longer than N seconds, this will be shown in cron log. */ const DISPLAY_LONG_QUERY_TIME = 5.0; /** * @var float Adds indexing progress within one search area to cron log every N seconds. */ const DISPLAY_INDEXING_PROGRESS_EVERY = 30.0; /** * @var int Context indexing: normal priority. */ const INDEX_PRIORITY_NORMAL = 100; /** * @var int Context indexing: low priority for reindexing. */ const INDEX_PRIORITY_REINDEXING = 50; /** * @var string Core search area category for all results. */ const SEARCH_AREA_CATEGORY_ALL = 'core-all'; /** * @var string Core search area category for course content. */ const SEARCH_AREA_CATEGORY_COURSE_CONTENT = 'core-course-content'; /** * @var string Core search area category for courses. */ const SEARCH_AREA_CATEGORY_COURSES = 'core-courses'; /** * @var string Core search area category for users. */ const SEARCH_AREA_CATEGORY_USERS = 'core-users'; /** * @var string Core search area category for results that do not fit into any of existing categories. */ const SEARCH_AREA_CATEGORY_OTHER = 'core-other'; /** * @var \core_search\base[] Enabled search areas. */ protected static $enabledsearchareas = null; /** * @var \core_search\base[] All system search areas. */ protected static $allsearchareas = null; /** * @var \core_search\area_category[] A list of search area categories. */ protected static $searchareacategories = null; /** * @var \core_search\manager */ protected static $instance = null; /** * @var array IDs (as keys) of course deletions in progress in this requuest, if any. */ protected static $coursedeleting = []; /** * @var \core_search\engine */ protected $engine = null; /** * Note: This should be removed once possible (see MDL-60644). * * @var float Fake current time for use in PHPunit tests */ protected static $phpunitfaketime = 0; /** * @var int Result count when used with mock results for Behat tests. */ protected $behatresultcount = 0; /** * Constructor, use \core_search\manager::instance instead to get a class instance. * * @param \core_search\base The search engine to use */ public function __construct($engine) { $this->engine = $engine; } /** * @var int Record time of each successful schema check, but not more than once per 10 minutes. */ const SCHEMA_CHECK_TRACKING_DELAY = 10 * 60; /** * @var int Require a new schema check at least every 4 hours. */ const SCHEMA_CHECK_REQUIRED_EVERY = 4 * 3600; /** * Returns an initialised \core_search instance. * * While constructing the instance, checks on the search schema may be carried out. The $fast * 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(bool $fast = false, bool $query = false) { global $CFG; // One per request, this should be purged during testing. if (static::$instance !== null) { return static::$instance; } if (empty($CFG->searchengine)) { throw new \core_search\engine_exception('enginenotselected', 'search'); } if (!$engine = static::search_engine_instance($query)) { throw new \core_search\engine_exception('enginenotfound', 'search', '', $CFG->searchengine); } // Get time now and at last schema check. $now = (int)self::get_current_time(); $lastschemacheck = get_config($engine->get_plugin_name(), 'lastschemacheck'); // On pages where performance matters, tell the engine to skip schema checks. $skipcheck = false; if ($fast && $now < $lastschemacheck + self::SCHEMA_CHECK_REQUIRED_EVERY) { $skipcheck = true; $engine->skip_schema_check(); } if (!$engine->is_installed()) { throw new \core_search\engine_exception('enginenotinstalled', 'search', '', $CFG->searchengine); } $serverstatus = $engine->is_server_ready(); if ($serverstatus !== true) { // Skip this error in Behat when faking seach results. if (!defined('BEHAT_SITE_RUNNING') || !get_config('core_search', 'behat_fakeresult')) { // Clear the record of successful schema checks since it might have failed. unset_config('lastschemacheck', $engine->get_plugin_name()); // Error message with no details as this is an exception that any user may find if the server crashes. throw new \core_search\engine_exception('engineserverstatus', 'search'); } } // If we did a successful schema check, record this, but not more than once per 10 minutes // (to avoid updating the config db table/cache too often in case it gets called frequently). if (!$skipcheck && $now >= $lastschemacheck + self::SCHEMA_CHECK_TRACKING_DELAY) { set_config('lastschemacheck', $now, $engine->get_plugin_name()); } static::$instance = new \core_search\manager($engine); return static::$instance; } /** * Returns whether global search is enabled or not. * * @return bool */ public static function is_global_search_enabled() { global $CFG; return !empty($CFG->enableglobalsearch); } /** * Tests if global search is configured to be equivalent to the front page course search. * * @return bool */ public static function can_replace_course_search(): bool { global $CFG; // Assume we can replace front page search. $canreplace = true; // Global search must be enabled. if (!static::is_global_search_enabled()) { $canreplace = false; } // Users must be able to search the details of all courses that they can see, // even if they do not have access to them. if (empty($CFG->searchincludeallcourses)) { $canreplace = false; } // Course search must be enabled. if ($canreplace) { $areaid = static::generate_areaid('core_course', 'course'); $enabledareas = static::get_search_areas_list(true); $canreplace = isset($enabledareas[$areaid]); } return $canreplace; } /** * Returns the search URL for course search * * @return moodle_url */ public static function get_course_search_url() { if (self::can_replace_course_search()) { $searchurl = '/search/index.php'; } else { $searchurl = '/course/search.php'; } return new \moodle_url($searchurl); } /** * Returns whether indexing is enabled or not (you can enable indexing even when search is not * enabled at the moment, so as to have it ready for students). * * @return bool True if indexing is enabled. */ public static function is_indexing_enabled() { global $CFG; return !empty($CFG->enableglobalsearch) || !empty($CFG->searchindexwhendisabled); } /** * 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(bool $query = false) { global $CFG; 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 null; } if ($alternate) { return new $classname(true); } else { // Use the constructor with no parameters for compatibility. return new $classname(); } } /** * Returns the search engine. * * @return \core_search\engine */ public function get_engine() { return $this->engine; } /** * Returns a search area class name. * * @param string $areaid * @return string */ protected static function get_area_classname($areaid) { list($componentname, $areaname) = static::extract_areaid_parts($areaid); return '\\' . $componentname . '\\search\\' . $areaname; } /** * Returns a new area search indexer instance. * * @param string $areaid * @return \core_search\base|bool False if the area is not available. */ public static function get_search_area($areaid) { // We have them all here. if (!empty(static::$allsearchareas[$areaid])) { return static::$allsearchareas[$areaid]; } $classname = static::get_area_classname($areaid); if (class_exists($classname) && static::is_search_area($classname)) { return new $classname(); } return false; } /** * Return the list of available search areas. * * @param bool $enabled Return only the enabled ones. * @return \core_search\base[] */ public static function get_search_areas_list($enabled = false) { // Two different arrays, we don't expect these arrays to be big. if (static::$allsearchareas !== null) { if (!$enabled) { return static::$allsearchareas; } else { return static::$enabledsearchareas; } } static::$allsearchareas = array(); static::$enabledsearchareas = array(); $searchclasses = \core_component::get_component_classes_in_namespace(null, 'search'); foreach ($searchclasses as $classname => $classpath) { $areaname = substr(strrchr($classname, '\\'), 1); $componentname = strstr($classname, '\\', 1); if (!static::is_search_area($classname)) { continue; } $areaid = static::generate_areaid($componentname, $areaname); $searchclass = new $classname(); static::$allsearchareas[$areaid] = $searchclass; if ($searchclass->is_enabled()) { static::$enabledsearchareas[$areaid] = $searchclass; } } if ($enabled) { return static::$enabledsearchareas; } return static::$allsearchareas; } /** * Return search area category instance by category name. * * @param string $name Category name. If name is not valid will return default category. * * @return \core_search\area_category */ public static function get_search_area_category_by_name($name) { if (key_exists($name, self::get_search_area_categories())) { return self::get_search_area_categories()[$name]; } else { return self::get_search_area_categories()[self::get_default_area_category_name()]; } } /** * Return a list of existing search area categories. * * @return \core_search\area_category[] */ public static function get_search_area_categories() { if (!isset(static::$searchareacategories)) { $categories = self::get_core_search_area_categories(); // Go through all existing search areas and get categories they are assigned to. $areacategories = []; foreach (self::get_search_areas_list() as $searcharea) { foreach ($searcharea->get_category_names() as $categoryname) { if (!key_exists($categoryname, $areacategories)) { $areacategories[$categoryname] = []; } $areacategories[$categoryname][] = $searcharea; } } // Populate core categories by areas. foreach ($areacategories as $name => $searchareas) { if (key_exists($name, $categories)) { $categories[$name]->set_areas($searchareas); } else { throw new \coding_exception('Unknown core search area category ' . $name); } } // Get additional categories. $additionalcategories = self::get_additional_search_area_categories(); foreach ($additionalcategories as $additionalcategory) { if (!key_exists($additionalcategory->get_name(), $categories)) { $categories[$additionalcategory->get_name()] = $additionalcategory; } } // Remove categories without areas. foreach ($categories as $key => $category) { if (empty($category->get_areas())) { unset($categories[$key]); } } // Sort categories by order. uasort($categories, function($category1, $category2) { return $category1->get_order() <=> $category2->get_order(); }); static::$searchareacategories = $categories; } return static::$searchareacategories; } /** * Get list of core search area categories. * * @return \core_search\area_category[] */ protected static function get_core_search_area_categories() { $categories = []; $categories[self::SEARCH_AREA_CATEGORY_ALL] = new area_category( self::SEARCH_AREA_CATEGORY_ALL, get_string('core-all', 'search'), 0, self::get_search_areas_list(true) ); $categories[self::SEARCH_AREA_CATEGORY_COURSE_CONTENT] = new area_category( self::SEARCH_AREA_CATEGORY_COURSE_CONTENT, get_string('core-course-content', 'search'), 1 ); $categories[self::SEARCH_AREA_CATEGORY_COURSES] = new area_category( self::SEARCH_AREA_CATEGORY_COURSES, get_string('core-courses', 'search'), 2 ); $categories[self::SEARCH_AREA_CATEGORY_USERS] = new area_category( self::SEARCH_AREA_CATEGORY_USERS, get_string('core-users', 'search'), 3 ); $categories[self::SEARCH_AREA_CATEGORY_OTHER] = new area_category( self::SEARCH_AREA_CATEGORY_OTHER, get_string('core-other', 'search'), 4 ); return $categories; } /** * Gets a list of additional search area categories. * * @return \core_search\area_category[] */ protected static function get_additional_search_area_categories() { $additionalcategories = []; // Allow plugins to add custom search area categories. if ($pluginsfunction = get_plugins_with_function('search_area_categories')) { foreach ($pluginsfunction as $plugintype => $plugins) { foreach ($plugins as $pluginfunction) { $plugincategories = $pluginfunction(); // We're expecting a list of valid area categories. if (is_array($plugincategories)) { foreach ($plugincategories as $plugincategory) { if (self::is_valid_area_category($plugincategory)) { $additionalcategories[] = $plugincategory; } else { throw new \coding_exception('Invalid search area category!'); } } } else { throw new \coding_exception($pluginfunction . ' should return a list of search area categories!'); } } } } return $additionalcategories; } /** * Check if provided instance of area category is valid. * * @param mixed $areacategory Area category instance. Potentially could be anything. * * @return bool */ protected static function is_valid_area_category($areacategory) { return $areacategory instanceof area_category; } /** * Clears all static caches. * * @return void */ public static function clear_static() { static::$enabledsearchareas = null; static::$allsearchareas = null; static::$instance = null; static::$searchareacategories = null; static::$coursedeleting = []; static::$phpunitfaketime = null; base_block::clear_static(); engine::clear_users_cache(); } /** * Generates an area id from the componentname and the area name. * * There should not be any naming conflict as the area name is the * class name in component/classes/search/. * * @param string $componentname * @param string $areaname * @return void */ public static function generate_areaid($componentname, $areaname) { return $componentname . '-' . $areaname; } /** * Returns all areaid string components (component name and area name). * * @param string $areaid * @return array Component name (Frankenstyle) and area name (search area class name) */ public static function extract_areaid_parts($areaid) { return explode('-', $areaid); } /** * Parse a search area id and get plugin name and config name prefix from it. * * @param string $areaid Search area id. * @return array Where the first element is a plugin name and the second is config names prefix. */ public static function parse_areaid($areaid) { $parts = self::extract_areaid_parts($areaid); if (empty($parts[1])) { throw new \coding_exception('Trying to parse invalid search area id ' . $areaid); } $component = $parts[0]; $area = $parts[1]; if (strpos($component, 'core') === 0) { $plugin = 'core_search'; $configprefix = str_replace('-', '_', $areaid); } else { $plugin = $component; $configprefix = 'search_' . $area; } return [$plugin, $configprefix]; } /** * Returns information about the areas which the user can access. * * The returned value is a stdClass object with the following fields: * - everything (bool, true for admin only) * - usercontexts (indexed by area identifier then context * - separategroupscontexts (contexts within which group restrictions apply) * - visiblegroupscontextsareas (overrides to the above when the same contexts also have * 'visible groups' for certain search area ids - hopefully rare) * - usergroups (groups which the current user belongs to) * * The areas can be limited by course id and context id. If specifying context ids, results * are limited to the exact context ids specified and not their children (for example, giving * the course context id would result in including search items with the course context id, and * not anything from a context inside the course). For performance, you should also specify * course id(s) when using context ids. * * @param array|false $limitcourseids An array of course ids to limit the search to. False for no limiting. * @param array|false $limitcontextids An array of context ids to limit the search to. False for no limiting. * @return \stdClass Object as described above */ protected function get_areas_user_accesses($limitcourseids = false, $limitcontextids = false) { global $DB, $USER; // All results for admins (unless they have chosen to limit results). Eventually we could // add a new capability for managers. if (is_siteadmin() && !$limitcourseids && !$limitcontextids) { return (object)array('everything' => true); } $areasbylevel = array(); // Split areas by context level so we only iterate only once through courses and cms. $searchareas = static::get_search_areas_list(true); foreach ($searchareas as $areaid => $unused) { $classname = static::get_area_classname($areaid); $searcharea = new $classname(); foreach ($classname::get_levels() as $level) { $areasbylevel[$level][$areaid] = $searcharea; } } // This will store area - allowed contexts relations. $areascontexts = array(); // Initialise two special-case arrays for storing other information related to the contexts. $separategroupscontexts = array(); $visiblegroupscontextsareas = array(); $usergroups = array(); if (empty($limitcourseids) && !empty($areasbylevel[CONTEXT_SYSTEM])) { // We add system context to all search areas working at this level. Here each area is fully responsible of // the access control as we can not automate much, we can not even check guest access as some areas might // want to allow guests to retrieve data from them. $systemcontextid = \context_system::instance()->id; if (!$limitcontextids || in_array($systemcontextid, $limitcontextids)) { foreach ($areasbylevel[CONTEXT_SYSTEM] as $areaid => $searchclass) { $areascontexts[$areaid][$systemcontextid] = $systemcontextid; } } } if (!empty($areasbylevel[CONTEXT_USER])) { if ($usercontext = \context_user::instance($USER->id, IGNORE_MISSING)) { if (!$limitcontextids || in_array($usercontext->id, $limitcontextids)) { // Extra checking although only logged users should reach this point, guest users have a valid context id. foreach ($areasbylevel[CONTEXT_USER] as $areaid => $searchclass) { $areascontexts[$areaid][$usercontext->id] = $usercontext->id; } } } } if (is_siteadmin()) { $allcourses = $this->get_all_courses($limitcourseids); } else { $allcourses = $mycourses = $this->get_my_courses((bool)get_config('core', 'searchallavailablecourses')); if (self::include_all_courses()) { $allcourses = $this->get_all_courses($limitcourseids); } } if (empty($limitcourseids) || in_array(SITEID, $limitcourseids)) { $allcourses[SITEID] = get_course(SITEID); if (isset($mycourses)) { $mycourses[SITEID] = get_course(SITEID); } } // Keep a list of included course context ids (needed for the block calculation below). $coursecontextids = []; $modulecms = []; foreach ($allcourses as $course) { if (!empty($limitcourseids) && !in_array($course->id, $limitcourseids)) { // Skip non-included courses. continue; } $coursecontext = \context_course::instance($course->id); $hasgrouprestrictions = false; if (!empty($areasbylevel[CONTEXT_COURSE]) && (!$limitcontextids || in_array($coursecontext->id, $limitcontextids))) { // Add the course contexts the user can view. foreach ($areasbylevel[CONTEXT_COURSE] as $areaid => $searchclass) { if (!empty($mycourses[$course->id]) || \core_course_category::can_view_course_info($course)) { $areascontexts[$areaid][$coursecontext->id] = $coursecontext->id; } } } // Skip module context if a user can't access related course. if (isset($mycourses) && !key_exists($course->id, $mycourses)) { continue; } $coursecontextids[] = $coursecontext->id; // Info about the course modules. $modinfo = get_fast_modinfo($course); if (!empty($areasbylevel[CONTEXT_MODULE])) { // Add the module contexts the user can view (cm_info->uservisible). foreach ($areasbylevel[CONTEXT_MODULE] as $areaid => $searchclass) { // Removing the plugintype 'mod_' prefix. $modulename = substr($searchclass->get_component_name(), 4); $modinstances = $modinfo->get_instances_of($modulename); foreach ($modinstances as $modinstance) { // Skip module context if not included in list of context ids. if ($limitcontextids && !in_array($modinstance->context->id, $limitcontextids)) { continue; } if ($modinstance->uservisible) { $contextid = $modinstance->context->id; $areascontexts[$areaid][$contextid] = $contextid; $modulecms[$modinstance->id] = $modinstance; if (!has_capability('moodle/site:accessallgroups', $modinstance->context) && ($searchclass instanceof base_mod) && $searchclass->supports_group_restriction()) { if ($searchclass->restrict_cm_access_by_group($modinstance)) { $separategroupscontexts[$contextid] = $contextid; $hasgrouprestrictions = true; } else { // Track a list of anything that has a group id (so might get // filtered) and doesn't want to be, in this context. if (!array_key_exists($contextid, $visiblegroupscontextsareas)) { $visiblegroupscontextsareas[$contextid] = array(); } $visiblegroupscontextsareas[$contextid][$areaid] = $areaid; } } } } } } // Insert group information for course (unless there aren't any modules restricted by // group for this user in this course, in which case don't bother). if ($hasgrouprestrictions) { $groups = groups_get_all_groups($course->id, $USER->id, 0, 'g.id'); foreach ($groups as $group) { $usergroups[$group->id] = $group->id; } } } // Chuck away all the 'visible groups contexts' data unless there is actually something // that does use separate groups in the same context (this data is only used as an // 'override' in cases where the search is restricting to separate groups). foreach ($visiblegroupscontextsareas as $contextid => $areas) { if (!array_key_exists($contextid, $separategroupscontexts)) { unset($visiblegroupscontextsareas[$contextid]); } } // Add all supported block contexts for course contexts that user can access, in a single query for performance. if (!empty($areasbylevel[CONTEXT_BLOCK]) && !empty($coursecontextids)) { // Get list of all block types we care about. $blocklist = []; foreach ($areasbylevel[CONTEXT_BLOCK] as $areaid => $searchclass) { $blocklist[$searchclass->get_block_name()] = true; } list ($blocknamesql, $blocknameparams) = $DB->get_in_or_equal(array_keys($blocklist)); // Get list of course contexts. list ($contextsql, $contextparams) = $DB->get_in_or_equal($coursecontextids); // Get list of block context (if limited). $blockcontextwhere = ''; $blockcontextparams = []; if ($limitcontextids) { list ($blockcontextsql, $blockcontextparams) = $DB->get_in_or_equal($limitcontextids); $blockcontextwhere = 'AND x.id ' . $blockcontextsql; } // Query all blocks that are within an included course, and are set to be visible, and // in a supported page type (basically just course view). This query could be // extended (or a second query added) to support blocks that are within a module // context as well, and we could add more page types if required. $blockrecs = $DB->get_records_sql(" SELECT x.*, bi.blockname AS blockname, bi.id AS blockinstanceid FROM {block_instances} bi JOIN {context} x ON x.instanceid = bi.id AND x.contextlevel = ? LEFT JOIN {block_positions} bp ON bp.blockinstanceid = bi.id AND bp.contextid = bi.parentcontextid AND bp.pagetype LIKE 'course-view-%' AND bp.subpage = '' AND bp.visible = 0 WHERE bi.parentcontextid $contextsql $blockcontextwhere AND bi.blockname $blocknamesql AND bi.subpagepattern IS NULL AND (bi.pagetypepattern = 'site-index' OR bi.pagetypepattern LIKE 'course-view-%' OR bi.pagetypepattern = 'course-*' OR bi.pagetypepattern = '*') AND bp.id IS NULL", array_merge([CONTEXT_BLOCK], $contextparams, $blockcontextparams, $blocknameparams)); $blockcontextsbyname = []; foreach ($blockrecs as $blockrec) { if (empty($blockcontextsbyname[$blockrec->blockname])) { $blockcontextsbyname[$blockrec->blockname] = []; } \context_helper::preload_from_record($blockrec); $blockcontextsbyname[$blockrec->blockname][] = \context_block::instance( $blockrec->blockinstanceid); } // Add the block contexts the user can view. foreach ($areasbylevel[CONTEXT_BLOCK] as $areaid => $searchclass) { if (empty($blockcontextsbyname[$searchclass->get_block_name()])) { continue; } foreach ($blockcontextsbyname[$searchclass->get_block_name()] as $context) { if (has_capability('moodle/block:view', $context)) { $areascontexts[$areaid][$context->id] = $context->id; } } } } // Return all the data. return (object)array('everything' => false, 'usercontexts' => $areascontexts, 'separategroupscontexts' => $separategroupscontexts, 'usergroups' => $usergroups, 'visiblegroupscontextsareas' => $visiblegroupscontextsareas); } /** * Returns requested page of documents plus additional information for paging. * * This function does not perform any kind of security checking for access, the caller code * should check that the current user have moodle/search:query capability. * * If a page is requested that is beyond the last result, the last valid page is returned in * results, and actualpage indicates which page was returned. * * @param stdClass $formdata * @param int $pagenum The 0 based page number. * @return object An object with 3 properties: * results => An array of \core_search\documents for the actual page. * totalcount => Number of records that are possibly available, to base paging on. * actualpage => The actual page returned. */ public function paged_search(\stdClass $formdata, $pagenum) { $out = new \stdClass(); if (self::is_search_area_categories_enabled() && !empty($formdata->cat)) { $cat = self::get_search_area_category_by_name($formdata->cat); if (empty($formdata->areaids)) { $formdata->areaids = array_keys($cat->get_areas()); } else { foreach ($formdata->areaids as $key => $areaid) { if (!key_exists($areaid, $cat->get_areas())) { unset($formdata->areaids[$key]); } } } } $perpage = static::DISPLAY_RESULTS_PER_PAGE; // Make sure we only allow request up to max page. $pagenum = min($pagenum, (static::MAX_RESULTS / $perpage) - 1); // Calculate the first and last document number for the current page, 1 based. $mindoc = ($pagenum * $perpage) + 1; $maxdoc = ($pagenum + 1) * $perpage; // Get engine documents, up to max. $docs = $this->search($formdata, $maxdoc); $resultcount = count($docs); if ($resultcount < $maxdoc) { // This means it couldn't give us results to max, so the count must be the max. $out->totalcount = $resultcount; } else { // Get the possible count reported by engine, and limit to our max. $out->totalcount = $this->engine->get_query_total_count(); if (defined('BEHAT_SITE_RUNNING') && $this->behatresultcount) { // Override results when using Behat mock results. $out->totalcount = $this->behatresultcount; } $out->totalcount = min($out->totalcount, static::MAX_RESULTS); } // Determine the actual page. if ($resultcount < $mindoc) { // We couldn't get the min docs for this page, so determine what page we can get. $out->actualpage = floor(($resultcount - 1) / $perpage); } else { $out->actualpage = $pagenum; } // Split the results to only return the page. $out->results = array_slice($docs, $out->actualpage * $perpage, $perpage, true); return $out; } /** * Returns documents from the engine based on the data provided. * * This function does not perform any kind of security checking, the caller code * should check that the current user have moodle/search:query capability. * * It might return the results from the cache instead. * * Valid formdata options include: * - q (query text) * - courseids (optional list of course ids to restrict) * - contextids (optional list of context ids to restrict) * - context (Moodle context object for location user searched from) * - order (optional ordering, one of the types supported by the search engine e.g. 'relevance') * - userids (optional list of user ids to restrict) * * @param \stdClass $formdata Query input data (usually from search form) * @param int $limit The maximum number of documents to return * @return \core_search\document[] */ public function search(\stdClass $formdata, $limit = 0) { // For Behat testing, the search results can be faked using a special step. if (defined('BEHAT_SITE_RUNNING')) { $fakeresult = get_config('core_search', 'behat_fakeresult'); if ($fakeresult) { // Clear config setting. unset_config('core_search', 'behat_fakeresult'); // Check query matches expected value. $details = json_decode($fakeresult); if ($formdata->q !== $details->query) { throw new \coding_exception('Unexpected search query: ' . $formdata->q); } // Create search documents from the JSON data. $docs = []; foreach ($details->results as $result) { $doc = new \core_search\document($result->itemid, $result->componentname, $result->areaname); foreach ((array)$result->fields as $field => $value) { $doc->set($field, $value); } foreach ((array)$result->extrafields as $field => $value) { $doc->set_extra($field, $value); } $area = $this->get_search_area($doc->get('areaid')); $doc->set_doc_url($area->get_doc_url($doc)); $doc->set_context_url($area->get_context_url($doc)); $docs[] = $doc; } // Store the mock count, and apply the limit to the returned results. $this->behatresultcount = count($docs); if ($this->behatresultcount > $limit) { $docs = array_slice($docs, 0, $limit); } return $docs; } } $limitcourseids = $this->build_limitcourseids($formdata); $limitcontextids = false; if (!empty($formdata->contextids)) { $limitcontextids = $formdata->contextids; } // Clears previous query errors. $this->engine->clear_query_error(); $contextinfo = $this->get_areas_user_accesses($limitcourseids, $limitcontextids); if (!$contextinfo->everything && !$contextinfo->usercontexts) { // User can not access any context. $docs = array(); } else { // If engine does not support groups, remove group information from the context info - // use the old format instead (true = admin, array = user contexts). if (!$this->engine->supports_group_filtering()) { $contextinfo = $contextinfo->everything ? true : $contextinfo->usercontexts; } // Execute the actual query. $docs = $this->engine->execute_query($formdata, $contextinfo, $limit); } return $docs; } /** * Search for top ranked result. * @param \stdClass $formdata search query data * @return array|document[] */ public function search_top(\stdClass $formdata): array { global $USER; // Return if the config value is set to 0. $maxtopresult = get_config('core', 'searchmaxtopresults'); if (empty($maxtopresult)) { return []; } // Only process if 'searchenablecategories' is set. if (self::is_search_area_categories_enabled() && !empty($formdata->cat)) { $cat = self::get_search_area_category_by_name($formdata->cat); $formdata->areaids = array_keys($cat->get_areas()); } else { return []; } $docs = $this->search($formdata); // Look for course, teacher and course content. $coursedocs = []; $courseteacherdocs = []; $coursecontentdocs = []; $otherdocs = []; foreach ($docs as $doc) { if ($doc->get('areaid') === 'core_course-course' && stripos($doc->get('title'), $formdata->q) !== false) { $coursedocs[] = $doc; } else if (strpos($doc->get('areaid'), 'course_teacher') !== false && stripos($doc->get('content'), $formdata->q) !== false) { $courseteacherdocs[] = $doc; } else if (strpos($doc->get('areaid'), 'mod_') !== false) { $coursecontentdocs[] = $doc; } else { $otherdocs[] = $doc; } } // Swap current courses to top. $enroledcourses = $this->get_my_courses(false); // Move current courses of the user to top. foreach ($enroledcourses as $course) { $completion = new \completion_info($course); if (!$completion->is_course_complete($USER->id)) { foreach ($coursedocs as $index => $doc) { $areaid = $doc->get('areaid'); if ($areaid == 'core_course-course' && $course->id == $doc->get('courseid')) { unset($coursedocs[$index]); array_unshift($coursedocs, $doc); } } } } $maxtopresult = get_config('core', 'searchmaxtopresults'); $result = array_merge($coursedocs, $courseteacherdocs, $coursecontentdocs, $otherdocs); return array_slice($result, 0, $maxtopresult); } /** * Build a list of course ids to limit the search based on submitted form data. * * @param \stdClass $formdata Submitted search form data. * * @return array|bool */ protected function build_limitcourseids(\stdClass $formdata) { $limitcourseids = false; if (!empty($formdata->mycoursesonly)) { $limitcourseids = array_keys($this->get_my_courses(false)); } if (!empty($formdata->courseids)) { if (empty($limitcourseids)) { $limitcourseids = $formdata->courseids; } else { $limitcourseids = array_intersect($limitcourseids, $formdata->courseids); } } return $limitcourseids; } /** * Merge separate index segments into one. */ public function optimize_index() { $this->engine->optimize(); } /** * Index all documents. * * @param bool $fullindex Whether we should reindex everything or not. * @param float $timelimit Time limit in seconds (0 = no time limit) * @param \progress_trace|null $progress Optional class for tracking progress * @throws \moodle_exception * @return bool Whether there was any updated document or not. */ public function index($fullindex = false, $timelimit = 0, \progress_trace $progress = null) { global $DB; // Cannot combine time limit with reindex. if ($timelimit && $fullindex) { throw new \coding_exception('Cannot apply time limit when reindexing'); } if (!$progress) { $progress = new \null_progress_trace(); } // Unlimited time. \core_php_time_limit::raise(); // Notify the engine that an index starting. $this->engine->index_starting($fullindex); $sumdocs = 0; $searchareas = $this->get_search_areas_list(true); if ($timelimit) { // If time is limited (and therefore we're not just indexing everything anyway), select // an order for search areas. The intention here is to avoid a situation where a new // large search area is enabled, and this means all our other search areas go out of // date while that one is being indexed. To do this, we order by the time we spent // indexing them last time we ran, meaning anything that took a very long time will be // done last. uasort($searchareas, function(\core_search\base $area1, \core_search\base $area2) { return (int)$area1->get_last_indexing_duration() - (int)$area2->get_last_indexing_duration(); }); // Decide time to stop. $stopat = self::get_current_time() + $timelimit; } foreach ($searchareas as $areaid => $searcharea) { $progress->output('Processing area: ' . $searcharea->get_visible_name()); // Notify the engine that an area is starting. $this->engine->area_index_starting($searcharea, $fullindex); $indexingstart = (int)self::get_current_time(); $elapsed = self::get_current_time(); // This is used to store this component config. list($componentconfigname, $varname) = $searcharea->get_config_var_name(); $prevtimestart = intval(get_config($componentconfigname, $varname . '_indexingstart')); if ($fullindex === true) { $referencestarttime = 0; // For full index, we delete any queued context index requests, as those will // obviously be met by the full index. $DB->delete_records('search_index_requests'); } else { $partial = get_config($componentconfigname, $varname . '_partial'); if ($partial) { // When the previous index did not complete all data, we start from the time of the // last document that was successfully indexed. (Note this will result in // re-indexing that one document, but we can't avoid that because there may be // other documents in the same second.) $referencestarttime = intval(get_config($componentconfigname, $varname . '_lastindexrun')); } else { $referencestarttime = $prevtimestart; } } // Getting the recordset from the area. $recordset = $searcharea->get_recordset_by_timestamp($referencestarttime); $initialquerytime = self::get_current_time() - $elapsed; if ($initialquerytime > self::DISPLAY_LONG_QUERY_TIME) { $progress->output('Initial query took ' . round($initialquerytime, 1) . ' seconds.', 1); } // Pass get_document as callback. $fileindexing = $this->engine->file_indexing_enabled() && $searcharea->uses_file_indexing(); $options = array('indexfiles' => $fileindexing, 'lastindexedtime' => $prevtimestart); if ($timelimit) { $options['stopat'] = $stopat; } $options['progress'] = $progress; $iterator = new skip_future_documents_iterator(new \core\dml\recordset_walk( $recordset, array($searcharea, 'get_document'), $options)); $result = $this->engine->add_documents($iterator, $searcharea, $options); $recordset->close(); $batchinfo = ''; if (count($result) === 6) { [$numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial, $batches] = $result; // Only show the batch count if we actually batched any requests. if ($batches !== $numdocs + $numdocsignored) { $batchinfo = ' (' . $batches . ' batch' . ($batches === 1 ? '' : 'es') . ')'; } } else if (count($result) === 5) { // Backward compatibility for engines that don't return a batch count. [$numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial] = $result; // Deprecated since Moodle 3.10 MDL-68690. // TODO: MDL-68776 This will be deleted in Moodle 4.2. debugging('engine::add_documents() should return $batches (5-value return is deprecated)', DEBUG_DEVELOPER); } else { throw new coding_exception('engine::add_documents() should return $partial (4-value return is deprecated)'); } if ($numdocs > 0) { $elapsed = round((self::get_current_time() - $elapsed), 1); $partialtext = ''; if ($partial) { $partialtext = ' (not complete; done to ' . userdate($lastindexeddoc, get_string('strftimedatetimeshort', 'langconfig')) . ')'; } $progress->output('Processed ' . $numrecords . ' records containing ' . $numdocs . ' documents' . $batchinfo . ', in ' . $elapsed . ' seconds' . $partialtext . '.', 1); } else { $progress->output('No new documents to index.', 1); } // Notify the engine this area is complete, and only mark times if true. if ($this->engine->area_index_complete($searcharea, $numdocs, $fullindex)) { $sumdocs += $numdocs; // Store last index run once documents have been committed to the search engine. set_config($varname . '_indexingstart', $indexingstart, $componentconfigname); set_config($varname . '_indexingend', (int)self::get_current_time(), $componentconfigname); set_config($varname . '_docsignored', $numdocsignored, $componentconfigname); set_config($varname . '_docsprocessed', $numdocs, $componentconfigname); set_config($varname . '_recordsprocessed', $numrecords, $componentconfigname); if ($lastindexeddoc > 0) { set_config($varname . '_lastindexrun', $lastindexeddoc, $componentconfigname); } if ($partial) { set_config($varname . '_partial', 1, $componentconfigname); } else { unset_config($varname . '_partial', $componentconfigname); } } else { $progress->output('Engine reported error.'); } if ($timelimit && (self::get_current_time() >= $stopat)) { $progress->output('Stopping indexing due to time limit.'); break; } } if ($sumdocs > 0) { $event = \core\event\search_indexed::create( array('context' => \context_system::instance())); $event->trigger(); } $this->engine->index_complete($sumdocs, $fullindex); return (bool)$sumdocs; } /** * Indexes or reindexes a specific context of the system, e.g. one course. * * The function returns an object with field 'complete' (true or false). * * This function supports partial indexing via the time limit parameter. If the time limit * expires, it will return values for $startfromarea and $startfromtime which can be passed * next time to continue indexing. * * @param \context $context Context to restrict index. * @param string $singleareaid If specified, indexes only the given area. * @param float $timelimit Time limit in seconds (0 = no time limit) * @param \progress_trace|null $progress Optional class for tracking progress * @param string $startfromarea Area to start from * @param int $startfromtime Timestamp to start from * @return \stdClass Object indicating success */ public function index_context($context, $singleareaid = '', $timelimit = 0, \progress_trace $progress = null, $startfromarea = '', $startfromtime = 0) { if (!$progress) { $progress = new \null_progress_trace(); } // Work out time to stop, if limited. if ($timelimit) { // Decide time to stop. $stopat = self::get_current_time() + $timelimit; } // No PHP time limit. \core_php_time_limit::raise(); // Notify the engine that an index starting. $this->engine->index_starting(false); $sumdocs = 0; // Get all search areas, in consistent order. $searchareas = $this->get_search_areas_list(true); ksort($searchareas); // Are we skipping past some that were handled previously? $skipping = $startfromarea ? true : false; foreach ($searchareas as $areaid => $searcharea) { // If we're only processing one area id, skip all the others. if ($singleareaid && $singleareaid !== $areaid) { continue; } // If we're skipping to a later area, continue through the loop. $referencestarttime = 0; if ($skipping) { if ($areaid !== $startfromarea) { continue; } // Stop skipping and note the reference start time. $skipping = false; $referencestarttime = $startfromtime; } $progress->output('Processing area: ' . $searcharea->get_visible_name()); $elapsed = self::get_current_time(); // Get the recordset of all documents from the area for this context. $recordset = $searcharea->get_document_recordset($referencestarttime, $context); if (!$recordset) { if ($recordset === null) { $progress->output('Skipping (not relevant to context).', 1); } else { $progress->output('Skipping (does not support context indexing).', 1); } continue; } // Notify the engine that an area is starting. $this->engine->area_index_starting($searcharea, false); // Work out search options. $options = []; $options['indexfiles'] = $this->engine->file_indexing_enabled() && $searcharea->uses_file_indexing(); if ($timelimit) { $options['stopat'] = $stopat; } // Construct iterator which will use get_document on the recordset results. $iterator = new \core\dml\recordset_walk($recordset, array($searcharea, 'get_document'), $options); // Use this iterator to add documents. $result = $this->engine->add_documents($iterator, $searcharea, $options); $batchinfo = ''; if (count($result) === 6) { [$numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial, $batches] = $result; // Only show the batch count if we actually batched any requests. if ($batches !== $numdocs + $numdocsignored) { $batchinfo = ' (' . $batches . ' batch' . ($batches === 1 ? '' : 'es') . ')'; } } else if (count($result) === 5) { // Backward compatibility for engines that don't return a batch count. [$numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial] = $result; // Deprecated since Moodle 3.10 MDL-68690. // TODO: MDL-68776 This will be deleted in Moodle 4.2 (as should the below bit). debugging('engine::add_documents() should return $batches (5-value return is deprecated)', DEBUG_DEVELOPER); } else { // Backward compatibility for engines that don't support partial adding. list($numrecords, $numdocs, $numdocsignored, $lastindexeddoc) = $result; debugging('engine::add_documents() should return $partial (4-value return is deprecated)', DEBUG_DEVELOPER); $partial = false; } if ($numdocs > 0) { $elapsed = round((self::get_current_time() - $elapsed), 3); $progress->output('Processed ' . $numrecords . ' records containing ' . $numdocs . ' documents' . $batchinfo . ', in ' . $elapsed . ' seconds' . ($partial ? ' (not complete)' : '') . '.', 1); } else { $progress->output('No documents to index.', 1); } // Notify the engine this area is complete, but don't store any times as this is not // part of the 'normal' search index. if (!$this->engine->area_index_complete($searcharea, $numdocs, false)) { $progress->output('Engine reported error.', 1); } if ($partial && $timelimit && (self::get_current_time() >= $stopat)) { $progress->output('Stopping indexing due to time limit.'); break; } } if ($sumdocs > 0) { $event = \core\event\search_indexed::create( array('context' => $context)); $event->trigger(); } $this->engine->index_complete($sumdocs, false); // Indicate in result whether we completed indexing, or only part of it. $result = new \stdClass(); if ($partial) { $result->complete = false; $result->startfromarea = $areaid; $result->startfromtime = $lastindexeddoc; } else { $result->complete = true; } return $result; } /** * Resets areas config. * * @throws \moodle_exception * @param string $areaid * @return void */ public function reset_config($areaid = false) { if (!empty($areaid)) { $searchareas = array(); if (!$searchareas[$areaid] = static::get_search_area($areaid)) { throw new \moodle_exception('errorareanotavailable', 'search', '', $areaid); } } else { // Only the enabled ones. $searchareas = static::get_search_areas_list(true); } foreach ($searchareas as $searcharea) { list($componentname, $varname) = $searcharea->get_config_var_name(); $config = $searcharea->get_config(); foreach ($config as $key => $value) { // We reset them all but the enable/disabled one. if ($key !== $varname . '_enabled') { set_config($key, 0, $componentname); } } } } /** * Deletes an area's documents or all areas documents. * * @param string $areaid The area id or false for all * @return void */ public function delete_index($areaid = false) { if (!empty($areaid)) { $this->engine->delete($areaid); $this->reset_config($areaid); } else { $this->engine->delete(); $this->reset_config(); } } /** * Deletes index by id. * * @param int Solr Document string $id */ public function delete_index_by_id($id) { $this->engine->delete_by_id($id); } /** * Returns search areas configuration. * * @param \core_search\base[] $searchareas * @return \stdClass[] $configsettings */ public function get_areas_config($searchareas) { $vars = array('indexingstart', 'indexingend', 'lastindexrun', 'docsignored', 'docsprocessed', 'recordsprocessed', 'partial'); $configsettings = []; foreach ($searchareas as $searcharea) { $areaid = $searcharea->get_area_id(); $configsettings[$areaid] = new \stdClass(); list($componentname, $varname) = $searcharea->get_config_var_name(); if (!$searcharea->is_enabled()) { // We delete all indexed data on disable so no info. foreach ($vars as $var) { $configsettings[$areaid]->{$var} = 0; } } else { foreach ($vars as $var) { $configsettings[$areaid]->{$var} = get_config($componentname, $varname .'_' . $var); } } // Formatting the time. if (!empty($configsettings[$areaid]->lastindexrun)) { $configsettings[$areaid]->lastindexrun = userdate($configsettings[$areaid]->lastindexrun); } else { $configsettings[$areaid]->lastindexrun = get_string('never'); } } return $configsettings; } /** * Triggers search_results_viewed event * * Other data required: * - q: The query string * - page: The page number * - title: Title filter * - areaids: Search areas filter * - courseids: Courses filter * - timestart: Time start filter * - timeend: Time end filter * * @since Moodle 3.2 * @param array $other Other info for the event. * @return \core\event\search_results_viewed */ public static function trigger_search_results_viewed($other) { $event = \core\event\search_results_viewed::create([ 'context' => \context_system::instance(), 'other' => $other ]); $event->trigger(); return $event; } /** * Checks whether a classname is of an actual search area. * * @param string $classname * @return bool */ protected static function is_search_area($classname) { if (is_subclass_of($classname, 'core_search\base')) { return (new \ReflectionClass($classname))->isInstantiable(); } return false; } /** * Requests that a specific context is indexed by the scheduled task. The context will be * added to a queue which is processed by the task. * * This is used after a restore to ensure that restored items are indexed, even though their * modified time will be older than the latest indexed. It is also used by the 'Gradual reindex' * admin feature from the search areas screen. * * @param \context $context Context to index within * @param string $areaid Area to index, '' = all areas * @param int $priority Priority (INDEX_PRIORITY_xx constant) */ public static function request_index(\context $context, $areaid = '', $priority = self::INDEX_PRIORITY_NORMAL) { global $DB; // Check through existing requests for this context or any parent context. list ($contextsql, $contextparams) = $DB->get_in_or_equal( $context->get_parent_context_ids(true)); $existing = $DB->get_records_select('search_index_requests', 'contextid ' . $contextsql, $contextparams, '', 'id, searcharea, partialarea, indexpriority'); foreach ($existing as $rec) { // If we haven't started processing the existing request yet, and it covers the same // area (or all areas) then that will be sufficient so don't add anything else. if ($rec->partialarea === '' && ($rec->searcharea === $areaid || $rec->searcharea === '')) { // If the existing request has the same (or higher) priority, no need to add anything. if ($rec->indexpriority >= $priority) { return; } // The existing request has lower priority. If it is exactly the same, then just // adjust the priority of the existing request. if ($rec->searcharea === $areaid) { $DB->set_field('search_index_requests', 'indexpriority', $priority, ['id' => $rec->id]); return; } // The existing request would cover this area but is a lower priority. We need to // add the new request even though that means we will index part of it twice. } } // No suitable existing request, so add a new one. $newrecord = [ 'contextid' => $context->id, 'searcharea' => $areaid, 'timerequested' => (int)self::get_current_time(), 'partialarea' => '', 'partialtime' => 0, 'indexpriority' => $priority ]; $DB->insert_record('search_index_requests', $newrecord); } /** * Processes outstanding index requests. This will take the first item from the queue (taking * account the indexing priority) and process it, continuing until an optional time limit is * reached. * * If there are no index requests, the function will do nothing. * * @param float $timelimit Time limit (0 = none) * @param \progress_trace|null $progress Optional progress indicator */ public function process_index_requests($timelimit = 0.0, \progress_trace $progress = null) { global $DB; if (!$progress) { $progress = new \null_progress_trace(); } $before = self::get_current_time(); if ($timelimit) { $stopat = $before + $timelimit; } while (true) { // Retrieve first request, using fully defined ordering. $requests = $DB->get_records('search_index_requests', null, 'indexpriority DESC, timerequested, contextid, searcharea', 'id, contextid, searcharea, partialarea, partialtime', 0, 1); if (!$requests) { // If there are no more requests, stop. break; } $request = reset($requests); // Calculate remaining time. $remainingtime = 0; $beforeindex = self::get_current_time(); if ($timelimit) { $remainingtime = $stopat - $beforeindex; // If the time limit expired already, stop now. (Otherwise we might accidentally // index with no time limit or a negative time limit.) if ($remainingtime <= 0) { break; } } // Show a message before each request, indicating what will be indexed. $context = \context::instance_by_id($request->contextid, IGNORE_MISSING); if (!$context) { $DB->delete_records('search_index_requests', ['id' => $request->id]); $progress->output('Skipped deleted context: ' . $request->contextid); continue; } $contextname = $context->get_context_name(); if ($request->searcharea) { $contextname .= ' (search area: ' . $request->searcharea . ')'; } $progress->output('Indexing requested context: ' . $contextname); // Actually index the context. $result = $this->index_context($context, $request->searcharea, $remainingtime, $progress, $request->partialarea, $request->partialtime); // Work out shared part of message. $endmessage = $contextname . ' (' . round(self::get_current_time() - $beforeindex, 1) . 's)'; // Update database table and continue/stop as appropriate. if ($result->complete) { // If we completed the request, remove it from the table. $DB->delete_records('search_index_requests', ['id' => $request->id]); $progress->output('Completed requested context: ' . $endmessage); } else { // If we didn't complete the request, store the partial details (how far it got). $DB->update_record('search_index_requests', ['id' => $request->id, 'partialarea' => $result->startfromarea, 'partialtime' => $result->startfromtime]); $progress->output('Ending requested context: ' . $endmessage); // The time limit must have expired, so stop looping. break; } } } /** * Gets information about the request queue, in the form of a plain object suitable for passing * to a template for rendering. * * @return \stdClass Information about queued index requests */ public function get_index_requests_info() { global $DB; $result = new \stdClass(); $result->total = $DB->count_records('search_index_requests'); $result->topten = $DB->get_records('search_index_requests', null, 'indexpriority DESC, timerequested, contextid, searcharea', 'id, contextid, timerequested, searcharea, partialarea, partialtime, indexpriority', 0, 10); foreach ($result->topten as $item) { $context = \context::instance_by_id($item->contextid); $item->contextlink = \html_writer::link($context->get_url(), s($context->get_context_name())); if ($item->searcharea) { $item->areaname = $this->get_search_area($item->searcharea)->get_visible_name(); } if ($item->partialarea) { $item->partialareaname = $this->get_search_area($item->partialarea)->get_visible_name(); } switch ($item->indexpriority) { case self::INDEX_PRIORITY_REINDEXING : $item->priorityname = get_string('priority_reindexing', 'search'); break; case self::INDEX_PRIORITY_NORMAL : $item->priorityname = get_string('priority_normal', 'search'); break; } } // Normalise array indices. $result->topten = array_values($result->topten); if ($result->total > 10) { $result->ellipsis = true; } return $result; } /** * Gets current time for use in search system. * * Note: This should be replaced with generic core functionality once possible (see MDL-60644). * * @return float Current time in seconds (with decimals) */ public static function get_current_time() { if (PHPUNIT_TEST && self::$phpunitfaketime) { return self::$phpunitfaketime; } return microtime(true); } /** * Check if search area categories functionality is enabled. * * @return bool */ public static function is_search_area_categories_enabled() { return !empty(get_config('core', 'searchenablecategories')); } /** * Check if all results category should be hidden. * * @return bool */ public static function should_hide_all_results_category() { return get_config('core', 'searchhideallcategory'); } /** * Returns default search area category name. * * @return string */ public static function get_default_area_category_name() { $default = get_config('core', 'searchdefaultcategory'); if (empty($default)) { $default = self::SEARCH_AREA_CATEGORY_ALL; } if ($default == self::SEARCH_AREA_CATEGORY_ALL && self::should_hide_all_results_category()) { $default = self::SEARCH_AREA_CATEGORY_COURSE_CONTENT; } return $default; } /** * Get a list of all courses limited by ids if required. * * @param array|false $limitcourseids An array of course ids to limit the search to. False for no limiting. * @return array */ protected function get_all_courses($limitcourseids) { global $DB; if ($limitcourseids) { list ($coursesql, $courseparams) = $DB->get_in_or_equal($limitcourseids); $coursesql = 'id ' . $coursesql; } else { $coursesql = ''; $courseparams = []; } // Get courses using the same list of fields from enrol_get_my_courses. return $DB->get_records_select('course', $coursesql, $courseparams, '', 'id, category, sortorder, shortname, fullname, idnumber, startdate, visible, ' . 'groupmode, groupmodeforce, cacherev'); } /** * Get a list of courses as user can access. * * @param bool $allaccessible Include courses user is not enrolled in, but can access. * @return array */ protected function get_my_courses($allaccessible) { return enrol_get_my_courses(array('id', 'cacherev'), 'id', 0, [], $allaccessible); } /** * Check if search all courses setting is enabled. * * @return bool */ public static function include_all_courses() { return !empty(get_config('core', 'searchincludeallcourses')); } /** * Cleans up non existing search area. * * 1. Remove all configs from {config_plugins} table. * 2. Delete all related indexed documents. * * @param string $areaid Search area id. */ public static function clean_up_non_existing_area($areaid) { global $DB; if (!empty(self::get_search_area($areaid))) { throw new \coding_exception("Area $areaid exists. Please use appropriate search area class to manipulate the data."); } $parts = self::parse_areaid($areaid); $plugin = $parts[0]; $configprefix = $parts[1]; foreach (base::get_settingnames() as $settingname) { $name = $configprefix. $settingname; $DB->delete_records('config_plugins', ['name' => $name, 'plugin' => $plugin]); } $engine = self::instance()->get_engine(); $engine->delete($areaid); } /** * Informs the search system that a context has been deleted. * * This will clear the data from the search index, where the search engine supports that. * * This function does not usually throw an exception (so as not to get in the way of the * context deletion finishing). * * This is called for all types of context deletion. * * @param \context $context Context object that has just been deleted */ public static function context_deleted(\context $context) { if (self::is_indexing_enabled()) { try { // Hold on, are we deleting a course? If so, and this context is part of the course, // then don't bother to send a delete because we delete the whole course at once // later. if (!empty(self::$coursedeleting)) { $coursecontext = $context->get_course_context(false); if ($coursecontext && array_key_exists($coursecontext->instanceid, self::$coursedeleting)) { // Skip further processing. return; } } $engine = self::instance()->get_engine(); $engine->delete_index_for_context($context->id); } catch (\moodle_exception $e) { debugging('Error deleting search index data for context ' . $context->id . ': ' . $e->getMessage()); } } } /** * Informs the search system that a course is about to be deleted. * * This prevents it from sending hundreds of 'delete context' updates for all the individual * contexts that are deleted. * * If you call this, you must call course_deleting_finish(). * * @param int $courseid Course id that is being deleted */ public static function course_deleting_start(int $courseid) { self::$coursedeleting[$courseid] = true; } /** * Informs the search engine that a course has now been deleted. * * This causes the search engine to actually delete the index for the whole course. * * @param int $courseid Course id that no longer exists */ public static function course_deleting_finish(int $courseid) { if (!array_key_exists($courseid, self::$coursedeleting)) { // Show a debug warning. It doesn't actually matter very much, as we will now delete // the course data anyhow. debugging('course_deleting_start not called before deletion of ' . $courseid, DEBUG_DEVELOPER); } unset(self::$coursedeleting[$courseid]); if (self::is_indexing_enabled()) { try { $engine = self::instance()->get_engine(); $engine->delete_index_for_course($courseid); } catch (\moodle_exception $e) { debugging('Error deleting search index data for course ' . $courseid . ': ' . $e->getMessage()); } } } } classes/document.php 0000644 00000054433 15152012435 0010537 0 ustar 00 <?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/>. /** * Document representation. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; use context; defined('MOODLE_INTERNAL') || die(); /** * Represents a document to index. * * Note that, if you are writting a search engine and you want to change \core_search\document * behaviour, you can overwrite this class, will be automatically loaded from \search_YOURENGINE\document. * * @package core_search * @copyright 2015 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class document implements \renderable, \templatable { /** * @var array $data The document data. */ protected $data = array(); /** * @var array Extra data needed to render the document. */ protected $extradata = array(); /** * @var \moodle_url Link to the document. */ protected $docurl = null; /** * @var \moodle_url Link to the document context. */ protected $contexturl = null; /** * @var \core_search\document_icon Document icon instance. */ protected $docicon = null; /** * @var int|null The content field filearea. */ protected $contentfilearea = null; /** * @var int|null The content field itemid. */ protected $contentitemid = null; /** * @var bool Should be set to true if document hasn't been indexed before. False if unknown. */ protected $isnew = false; /** * @var \stored_file[] An array of stored files to attach to the document. */ protected $files = array(); /** * Change list (for engine implementers): * 2017091700 - add optional field groupid * * @var int Schema version number (update if any change) */ const SCHEMA_VERSION = 2017091700; /** * All required fields any doc should contain. * * We have to choose a format to specify field types, using solr format as we have to choose one and solr is the * default search engine. * * Search engine plugins are responsible of setting their appropriate field types and map these naming to whatever format * they need. * * @var array */ protected static $requiredfields = array( 'id' => array( 'type' => 'string', 'stored' => true, 'indexed' => false ), 'itemid' => array( 'type' => 'int', 'stored' => true, 'indexed' => true ), 'title' => array( 'type' => 'text', 'stored' => true, 'indexed' => true, 'mainquery' => true ), 'content' => array( 'type' => 'text', 'stored' => true, 'indexed' => true, 'mainquery' => true ), 'contextid' => array( 'type' => 'int', 'stored' => true, 'indexed' => true ), 'areaid' => array( 'type' => 'string', 'stored' => true, 'indexed' => true ), 'type' => array( 'type' => 'int', 'stored' => true, 'indexed' => true ), 'courseid' => array( 'type' => 'int', 'stored' => true, 'indexed' => true ), 'owneruserid' => array( 'type' => 'int', 'stored' => true, 'indexed' => true ), 'modified' => array( 'type' => 'tdate', 'stored' => true, 'indexed' => true ), ); /** * All optional fields docs can contain. * * Although it matches solr fields format, this is just to define the field types. Search * engine plugins are responsible of setting their appropriate field types and map these * naming to whatever format they need. * * @var array */ protected static $optionalfields = array( 'userid' => array( 'type' => 'int', 'stored' => true, 'indexed' => true ), 'groupid' => array( 'type' => 'int', 'stored' => true, 'indexed' => true ), 'description1' => array( 'type' => 'text', 'stored' => true, 'indexed' => true, 'mainquery' => true ), 'description2' => array( 'type' => 'text', 'stored' => true, 'indexed' => true, 'mainquery' => true ) ); /** * Any fields that are engine specifc. These are fields that are solely used by a search engine plugin * for internal purposes. * * Field names should be prefixed with engine name to avoid potential conflict with core fields. * * Uses same format as fields above. * * @var array */ protected static $enginefields = array(); /** * We ensure that the document has a unique id across search areas. * * @param int $itemid An id unique to the search area * @param string $componentname The search area component Frankenstyle name * @param string $areaname The area name (the search area class name) * @return void */ public function __construct($itemid, $componentname, $areaname) { if (!is_numeric($itemid)) { throw new \coding_exception('The itemid should be an integer'); } $this->data['areaid'] = \core_search\manager::generate_areaid($componentname, $areaname); $this->data['id'] = $this->data['areaid'] . '-' . $itemid; $this->data['itemid'] = intval($itemid); } /** * Add a stored file to the document. * * @param \stored_file|int $file The file to add, or file id. * @return void */ public function add_stored_file($file) { if (is_numeric($file)) { $this->files[$file] = $file; } else { $this->files[$file->get_id()] = $file; } } /** * Returns the array of attached files. * * @return \stored_file[] */ public function get_files() { // The files array can contain stored file ids, so we need to get instances if asked. foreach ($this->files as $id => $listfile) { if (is_numeric($listfile)) { $fs = get_file_storage(); if ($file = $fs->get_file_by_id($id)) { $this->files[$id] = $file; } else { unset($this->files[$id]); // Index is out of date and referencing a file that does not exist. } } } return $this->files; } /** * Setter. * * Basic checkings to prevent common issues. * * If the field is a string tags will be stripped, if it is an integer or a date it * will be casted to a PHP integer. tdate fields values are expected to be timestamps. * * @throws \coding_exception * @param string $fieldname The field name * @param string|int $value The value to store * @return string|int The stored value */ public function set($fieldname, $value) { if (!empty(static::$requiredfields[$fieldname])) { $fielddata = static::$requiredfields[$fieldname]; } else if (!empty(static::$optionalfields[$fieldname])) { $fielddata = static::$optionalfields[$fieldname]; } else if (!empty(static::$enginefields[$fieldname])) { $fielddata = static::$enginefields[$fieldname]; } if (empty($fielddata)) { throw new \coding_exception('"' . $fieldname . '" field does not exist.'); } // tdate fields should be set as timestamps, later they might be converted to // a date format, it depends on the search engine. if (($fielddata['type'] === 'int' || $fielddata['type'] === 'tdate') && !is_numeric($value)) { throw new \coding_exception('"' . $fieldname . '" value should be an integer and its value is "' . $value . '"'); } // We want to be strict here, there might be engines that expect us to // provide them data with the proper type already set. if ($fielddata['type'] === 'int' || $fielddata['type'] === 'tdate') { $this->data[$fieldname] = intval($value); } else { // Remove disallowed Unicode characters. $value = \core_text::remove_unicode_non_characters($value); // Replace all groups of line breaks and spaces by single spaces. $this->data[$fieldname] = preg_replace("/\s+/u", " ", $value); if ($this->data[$fieldname] === null) { if (isset($this->data['id'])) { $docid = $this->data['id']; } else { $docid = '(unknown)'; } throw new \moodle_exception('error_indexing', 'search', '', null, '"' . $fieldname . '" value causes preg_replace error (may be caused by unusual characters) ' . 'in document with id "' . $docid . '"'); } } return $this->data[$fieldname]; } /** * Sets data to this->extradata * * This data can be retrieved using \core_search\document->get($fieldname). * * @param string $fieldname * @param string $value * @return void */ public function set_extra($fieldname, $value) { $this->extradata[$fieldname] = $value; } /** * Getter. * * Use self::is_set if you are not sure if this field is set or not * as otherwise it will trigger a \coding_exception * * @throws \coding_exception * @param string $field * @return string|int */ public function get($field) { if (isset($this->data[$field])) { return $this->data[$field]; } // Fallback to extra data. if (isset($this->extradata[$field])) { return $this->extradata[$field]; } throw new \coding_exception('Field "' . $field . '" is not set in the document'); } /** * Checks if a field is set. * * @param string $field * @return bool */ public function is_set($field) { return (isset($this->data[$field]) || isset($this->extradata[$field])); } /** * Set if this is a new document. False if unknown. * * @param bool $new */ public function set_is_new($new) { $this->isnew = (bool)$new; } /** * Returns if the document is new. False if unknown. * * @return bool */ public function get_is_new() { return $this->isnew; } /** * Returns all default fields definitions. * * @return array */ public static function get_default_fields_definition() { return static::$requiredfields + static::$optionalfields + static::$enginefields; } /** * Formats the timestamp preparing the time fields to be inserted into the search engine. * * By default it just returns a timestamp so any search engine could just store integers * and use integers comparison to get documents between x and y timestamps, but search * engines might be interested in using their own field formats. They can do it extending * this class in \search_xxx\document. * * @param int $timestamp * @return string */ public static function format_time_for_engine($timestamp) { return $timestamp; } /** * Formats a string value for the search engine. * * Search engines may overwrite this method to apply restrictions, like limiting the size. * The default behaviour is just returning the string. * * @param string $string * @return string */ public static function format_string_for_engine($string) { return $string; } /** * Formats a text value for the search engine. * * Search engines may overwrite this method to apply restrictions, like limiting the size. * The default behaviour is just returning the string. * * @param string $text * @return string */ public static function format_text_for_engine($text) { return $text; } /** * Returns a timestamp from the value stored in the search engine. * * By default it just returns a timestamp so any search engine could just store integers * and use integers comparison to get documents between x and y timestamps, but search * engines might be interested in using their own field formats. They should do it extending * this class in \search_xxx\document. * * @param string $time * @return int */ public static function import_time_from_engine($time) { return $time; } /** * Returns how text is returned from the search engine. * * @return int */ protected function get_text_format() { return FORMAT_PLAIN; } /** * Fills the document with data coming from the search engine. * * @throws \core_search\engine_exception * @param array $docdata * @return void */ public function set_data_from_engine($docdata) { $fields = static::$requiredfields + static::$optionalfields + static::$enginefields; foreach ($fields as $fieldname => $field) { // Optional params might not be there. if (isset($docdata[$fieldname])) { if ($field['type'] === 'tdate') { // Time fields may need a preprocessing. $this->set($fieldname, static::import_time_from_engine($docdata[$fieldname])); } else { // No way we can make this work if there is any multivalue field. if (is_array($docdata[$fieldname])) { throw new \core_search\engine_exception('multivaluedfield', 'search_solr', '', $fieldname); } $this->set($fieldname, $docdata[$fieldname]); } } } } /** * Sets the document url. * * @param \moodle_url $url * @return void */ public function set_doc_url(\moodle_url $url) { $this->docurl = $url; } /** * Gets the url to the doc. * * @return \moodle_url */ public function get_doc_url() { return $this->docurl; } /** * Sets document icon instance. * * @param \core_search\document_icon $docicon */ public function set_doc_icon(document_icon $docicon) { $this->docicon = $docicon; } /** * Gets document icon instance. * * @return \core_search\document_icon */ public function get_doc_icon() { return $this->docicon; } public function set_context_url(\moodle_url $url) { $this->contexturl = $url; } /** * Gets the url to the context. * * @return \moodle_url */ public function get_context_url() { return $this->contexturl; } /** * Returns the document ready to submit to the search engine. * * @throws \coding_exception * @return array */ public function export_for_engine() { // Set any unset defaults. $this->apply_defaults(); // We don't want to affect the document instance. $data = $this->data; // Apply specific engine-dependant formats and restrictions. foreach (static::$requiredfields as $fieldname => $field) { // We also check that we have everything we need. if (!isset($data[$fieldname])) { throw new \coding_exception('Missing "' . $fieldname . '" field in document with id "' . $this->data['id'] . '"'); } if ($field['type'] === 'tdate') { // Overwrite the timestamp with the engine dependant format. $data[$fieldname] = static::format_time_for_engine($data[$fieldname]); } else if ($field['type'] === 'string') { // Overwrite the string with the engine dependant format. $data[$fieldname] = static::format_string_for_engine($data[$fieldname]); } else if ($field['type'] === 'text') { // Overwrite the text with the engine dependant format. $data[$fieldname] = static::format_text_for_engine($data[$fieldname]); } } $fields = static::$optionalfields + static::$enginefields; foreach ($fields as $fieldname => $field) { if (!isset($data[$fieldname])) { continue; } if ($field['type'] === 'tdate') { // Overwrite the timestamp with the engine dependant format. $data[$fieldname] = static::format_time_for_engine($data[$fieldname]); } else if ($field['type'] === 'string') { // Overwrite the string with the engine dependant format. $data[$fieldname] = static::format_string_for_engine($data[$fieldname]); } else if ($field['type'] === 'text') { // Overwrite the text with the engine dependant format. $data[$fieldname] = static::format_text_for_engine($data[$fieldname]); } } return $data; } /** * Apply any defaults to unset fields before export. Called after document building, but before export. * * Sub-classes of this should make sure to call parent::apply_defaults(). */ protected function apply_defaults() { // Set the default type, TYPE_TEXT. if (!isset($this->data['type'])) { $this->data['type'] = manager::TYPE_TEXT; } } /** * Export the document data to be used as a template context. * * Adding more info than the required one as people might be interested in extending the template. * * Although content is a required field when setting up the document, it accepts '' (empty) values * as they may be the result of striping out HTML. * * SECURITY NOTE: It is the responsibility of the document to properly escape any text to be displayed. * The renderer will output the content without any further cleaning. * * @param renderer_base $output The renderer. * @return array */ public function export_for_template(\renderer_base $output) { global $USER; list($componentname, $areaname) = \core_search\manager::extract_areaid_parts($this->get('areaid')); $context = context::instance_by_id($this->get('contextid')); $searcharea = \core_search\manager::get_search_area($this->data['areaid']); $title = $this->is_set('title') ? $this->format_text($searcharea->get_document_display_title($this)) : ''; $data = [ 'componentname' => $componentname, 'areaname' => $areaname, 'courseurl' => course_get_url($this->get('courseid')), 'coursefullname' => format_string($this->get('coursefullname'), true, ['context' => $context->id]), 'modified' => userdate($this->get('modified')), 'title' => ($title !== '') ? $title : get_string('notitle', 'search'), 'docurl' => $this->get_doc_url(), 'content' => $this->is_set('content') ? $this->format_text($this->get('content')) : null, 'contexturl' => $this->get_context_url(), 'description1' => $this->is_set('description1') ? $this->format_text($this->get('description1')) : null, 'description2' => $this->is_set('description2') ? $this->format_text($this->get('description2')) : null, ]; // Now take any attached any files. $files = $this->get_files(); if (!empty($files)) { if (count($files) > 1) { $filenames = []; foreach ($files as $file) { $filenames[] = format_string($file->get_filename(), true, ['context' => $context->id]); } $data['multiplefiles'] = true; $data['filenames'] = $filenames; } else { $file = reset($files); $data['filename'] = format_string($file->get_filename(), true, ['context' => $context->id]); } } if ($this->is_set('userid')) { if ($this->get('userid') == $USER->id || (has_capability('moodle/user:viewdetails', $context) && has_capability('moodle/course:viewparticipants', $context))) { $data['userurl'] = new \moodle_url( '/user/view.php', ['id' => $this->get('userid'), 'course' => $this->get('courseid')] ); $data['userfullname'] = format_string($this->get('userfullname'), true, ['context' => $context->id]); } } if ($docicon = $this->get_doc_icon()) { $data['icon'] = $output->image_url($docicon->get_name(), $docicon->get_component()); } return $data; } /** * Formats a text string coming from the search engine. * * By default just return the text as it is: * - Search areas are responsible of sending just plain data, the search engine may * append HTML or markdown to it (highlighing for example). * - The view is responsible of shortening the text if it is too big * * @param string $text Text to format * @return string HTML text to be renderer */ protected function format_text($text) { return format_text($text, $this->get_text_format(), array('context' => $this->get('contextid'))); } } classes/document_icon.php 0000644 00000003614 15152012435 0011542 0 ustar 00 <?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/>. /** * Document icon class. * * @package core_search * @copyright 2018 Dmitrii Metelkin <dmitriim@catalyst-au.net> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_search; defined('MOODLE_INTERNAL') || die(); /** * Represents a document icon. * * @package core_search * @copyright 2018 Dmitrii Metelkin <dmitriim@catalyst-au.net> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class document_icon { /** * Icon file name. * @var string */ protected $name; /** Icon file component. * @var string */ protected $component; /** * Constructor. * * @param string $name Icon name. * @param string $component Icon component. */ public function __construct($name, $component = 'moodle') { $this->name = $name; $this->component = $component; } /** * Returns name of the icon file. * * @return string */ public function get_name() { return $this->name; } /** * Returns the component of the icon file. * * @return string */ public function get_component() { return $this->component; } }
| ver. 1.4 |
Github
|
.
| PHP 7.4.33 | ���֧ߧ֧�ѧ�ڧ� ����ѧߧڧ��: 0.01 |
proxy
|
phpinfo
|
���ѧ����ۧܧ�