Commit 089ebc85 authored by Nathan Nguyen's avatar Nathan Nguyen
Browse files

MDL-60917 core_search: add top result section

parent e746dc75
......@@ -641,6 +641,24 @@ if ($hassiteconfig) {
new lang_string('searchhideallcategory_desc', 'admin'),
0));
// Top result options.
$temp->add(new admin_setting_heading('searchtopresults', new lang_string('searchtopresults', 'admin'), ''));
// Max Top results.
$options = range(0, 10);
$temp->add(new admin_setting_configselect('searchmaxtopresults',
new lang_string('searchmaxtopresults', 'admin'),
new lang_string('searchmaxtopresults_desc', 'admin'),
3, $options));
// Teacher roles.
$options = [];
foreach (get_all_roles() as $role) {
$options[$role->id] = $role->shortname;
}
$temp->add(new admin_setting_configmultiselect('searchteacherroles',
new lang_string('searchteacherroles', 'admin'),
new lang_string('searchteacherroles_desc', 'admin'),
[], $options));
$temp->add(new admin_setting_heading('searchmanagement', new lang_string('searchmanagement', 'admin'),
new lang_string('searchmanagement_desc', 'admin')));
......
......@@ -1148,6 +1148,11 @@ $string['searchhideallcategory'] = 'Hide All results category';
$string['searchhideallcategory_desc'] = 'If checked, the category with all results will be hidden on the search result screen.';
$string['searchdefaultcategory'] = 'Default search category';
$string['searchdefaultcategory_desc'] = 'Results from the selected search area category will be displayed by default.';
$string['searchtopresults'] = 'Top Results';
$string['searchmaxtopresults'] = 'Maximum top results';
$string['searchmaxtopresults_desc'] = 'Specify the maximum number of top results';
$string['searchteacherroles'] = 'Teacher roles';
$string['searchteacherroles_desc'] = 'Please select all teacher roles for indexing course teacher';
$string['searchallavailablecoursesdesc'] = 'If set to search within enrolled courses only, course information (name and summary) and course content will only be searched in courses which the user is enrolled in. Otherwise, course information and course content will be searched in all courses which the user can access, such as courses with guest access enabled.';
$string['searchincludeallcourses'] = 'Include all visible courses';
$string['searchincludeallcourses_desc'] = 'If enabled, search results will include course information (name and summary) of courses which are visible to the user, even if they don\'t have access to the course content.';
......
......@@ -32,6 +32,7 @@ $string['authorname'] = 'Author name';
$string['back'] = 'Back';
$string['beadmin'] = 'You need to be an admin user to use this page.';
$string['commenton'] = 'Comment on';
$string['content:courserole'] = '{$a->role} in {$a->course}';
$string['confirm_delete'] = 'Are you sure you want to delete the index for {$a}? Until the search area is indexed, users will not get search results from this area.';
$string['confirm_indexall'] = 'Are you sure you want to update indexed contents now? If a large amount of content needs indexing, this can take a long time. For live servers, you should normally leave indexing to the \'Global search indexing\' scheduled task.';
$string['confirm_reindexall'] = 'Are you sure you want to reindex all site contents now? If your site contains a large amount of content, this will take a long time, and users may not get full search results until it completes.';
......@@ -115,6 +116,7 @@ $string['search:message_received'] = 'Messages - received';
$string['search:message_sent'] = 'Messages - sent';
$string['search:mycourse'] = 'My courses';
$string['search:course'] = 'Courses';
$string['search:course_teacher'] = 'Course Teacher';
$string['search:section'] = 'Course sections';
$string['search:user'] = 'Users';
$string['searcharea'] = 'Search area';
......@@ -132,6 +134,7 @@ $string['thesewordsmustappear'] = 'These words must appear';
$string['thesewordsmustnotappear'] = 'These words must not appear';
$string['title'] = 'Title';
$string['tofetchtheseresults'] = 'to fetch these results';
$string['topresults'] = 'Top Results';
$string['totalsize'] = 'Total size';
$string['totime'] = 'Modified before';
$string['type'] = 'Type';
......
......@@ -1056,6 +1056,68 @@ class manager {
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.
*
......
......@@ -93,6 +93,25 @@ class renderer extends \plugin_renderer_base {
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.
*
......
......@@ -190,6 +190,10 @@ if ($errorstr = $search->get_engine()->get_query_error()) {
$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([
......
<?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 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
*/
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 search_top_result_testcase 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']);
$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);
}
}
<?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/>.
/**
* Index teachers in a course
*
* @package core_user
* @author Nathan Nguyen <nathannguyen@catalyst-au.net>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_user\search;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/user/lib.php');
/**
* Search for user role assignment in a course
*
* @package core_user
* @author Nathan Nguyen <nathannguyen@catalyst-au.net>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class course_teacher extends \core_search\base {
/**
* The context levels the search implementation is working on.
*
* @var array
*/
protected static $levels = [CONTEXT_COURSE];
/**
* 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 'course_teacher';
}
/**
* Returns recordset containing required data attributes for indexing.
*
* @param number $modifiedfrom
* @param \context|null $context Optional context to restrict scope of returned results
* @return \moodle_recordset|null Recordset (or null if no results)
*/
public function get_document_recordset($modifiedfrom = 0, \context $context = null) {
global $DB;
$teacherroleids = get_config('core', 'searchteacherroles');
// Only index teacher roles.
if (!empty($teacherroleids)) {
$teacherroleids = explode(',', $teacherroleids);
list($insql, $inparams) = $DB->get_in_or_equal($teacherroleids, SQL_PARAMS_NAMED);
} else {
// Do not index at all.
list($insql, $inparams) = [' = :roleid', ['roleid' => 0]];
}
$params = [
'coursecontext' => CONTEXT_COURSE,
'modifiedfrom' => $modifiedfrom
];
$params = array_merge($params, $inparams);
$recordset = $DB->get_recordset_sql("
SELECT u.*, ra.contextid, r.shortname as roleshortname, ra.id as itemid, ra.timemodified as timeassigned
FROM {role_assignments} ra
JOIN {context} ctx
ON ctx.id = ra.contextid
AND ctx.contextlevel = :coursecontext
JOIN {user} u
ON u.id = ra.userid
JOIN {role} r
ON r.id = ra.roleid
WHERE ra.timemodified >= :modifiedfrom AND r.id $insql
ORDER BY ra.timemodified ASC", $params);
return $recordset;
}
/**
* Returns document instances for each record in the recordset.
*
* @param \stdClass $record
* @param array $options
* @return \core_search\document
*/
public function get_document($record, $options = array()) {
$context = \context::instance_by_id($record->contextid);
// Content.
if ($context->contextlevel == CONTEXT_COURSE) {
$course = get_course($context->instanceid);
$contentdata = new \stdClass();
$contentdata->role = ucfirst($record->roleshortname);
$contentdata->course = $course->fullname;
$content = get_string('content:courserole', 'core_search', $contentdata);
} else {
return false;
}
$doc = \core_search\document_factory::instance($record->itemid, $this->componentname, $this->areaname);
// Assigning properties to our document.
$doc->set('title', content_to_text(fullname($record), false));
$doc->set('contextid', $context->id);
$doc->set('courseid', $context->instanceid);
$doc->set('itemid', $record->itemid);
$doc->set('modified', $record->timeassigned);
$doc->set('owneruserid', \core_search\manager::NO_OWNER_ID);
$doc->set('userid', $record->id);
$doc->set('content', $content);
// Check if this document should be considered new.
if (isset($options['lastindexedtime']) && $options['lastindexedtime'] < $record->timeassigned) {
$doc->set_is_new(true);
}
return $doc;
}
/**
* Checking whether I can access a document
*
* @param int $id user id
* @return int
*/
public function check_access($id) {
$user = $this->get_user($id);
if (!$user || $user->deleted) {
return \core_search\manager::ACCESS_DELETED;
}
if (user_can_view_profile($user)) {
return \core_search\manager::ACCESS_GRANTED;
}
return \core_search\manager::ACCESS_DENIED;
}
/**
* Returns a url to the document context.
*
* @param \core_search\document $doc
* @return \moodle_url
*/
public function get_context_url(\core_search\document $doc) {
$user = $this->get_user($doc->get('itemid'));
$courseid = $doc->get('courseid');
return new \moodle_url('/user/view.php', array('id' => $user->id, 'course' => $courseid));
}
/**
* Returns the user fullname to display as document title
*
* @param \core_search\document $doc
* @return string User fullname
*/
public function get_document_display_title(\core_search\document $doc) {
$user = $this->get_user($doc->get('itemid'));
return fullname($user);
}
/**
* Get user based on role assignment id
*
* @param int $itemid role assignment id
* @return mixed
*/
private function get_user($itemid) {
global $DB;
$sql = "SELECT u.*
FROM {user} u
JOIN {role_assignments} ra
ON ra.userid = u.id
WHERE ra.id = :raid";
return $DB->get_record_sql($sql, array('raid' => $itemid));
}
/**
* Returns a list of category names associated with the area.
*
* @return array
*/
public function get_category_names() {
return [\core_search\manager::SEARCH_AREA_CATEGORY_ALL, \core_search\manager::SEARCH_AREA_CATEGORY_USERS];
}
/**
* Link to the teacher in the course
*
* @param \core_search\document $doc the document
* @return \moodle_url
*/
public function get_doc_url(\core_search\document $doc) {
return $this->get_context_url($doc);
}
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment