MDL-60880 core_search: Allow search of specific context (back-end)

Adds back-end support for restricting searches to specified context
ids (for example so it is possible to search only a specific forum).
This commit is contained in:
sam marshall 2017-11-22 14:31:32 +00:00
parent 5f54a8760f
commit cfa00fc565
8 changed files with 221 additions and 27 deletions

View File

@ -350,14 +350,22 @@ class manager {
* information and there will be a performance benefit on passing only some contexts
* instead of the whole context array set.
*
* 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 bool|array Indexed by area identifier (component + area name). Returns true if the user can see everything.
*/
protected function get_areas_user_accesses($limitcourseids = false) {
protected function get_areas_user_accesses($limitcourseids = false, $limitcontextids = false) {
global $DB, $USER;
// All results for admins. Eventually we could add a new capability for managers.
if (is_siteadmin()) {
// 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 true;
}
@ -382,23 +390,42 @@ class manager {
// want to allow guests to retrieve data from them.
$systemcontextid = \context_system::instance()->id;
foreach ($areasbylevel[CONTEXT_SYSTEM] as $areaid => $searchclass) {
$areascontexts[$areaid][$systemcontextid] = $systemcontextid;
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)) {
// 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 (!$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;
}
}
}
}
// Get the courses where the current user has access.
$courses = enrol_get_my_courses(array('id', 'cacherev'), 'id', 0, [],
(bool)get_config('core', 'searchallavailablecourses'));
if (is_siteadmin()) {
// Admins have access to all courses regardless of enrolment.
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.
$courses = $DB->get_records_select('course', $coursesql, $courseparams, '',
'id, category, sortorder, shortname, fullname, idnumber, startdate, visible, ' .
'groupmode, groupmodeforce, cacherev');
} else {
// Get the courses where the current user has access.
$courses = enrol_get_my_courses(array('id', 'cacherev'), 'id', 0, [],
(bool)get_config('core', 'searchallavailablecourses'));
}
if (empty($limitcourseids) || in_array(SITEID, $limitcourseids)) {
$courses[SITEID] = get_course(SITEID);
@ -419,7 +446,8 @@ class manager {
// Info about the course modules.
$modinfo = get_fast_modinfo($course);
if (!empty($areasbylevel[CONTEXT_COURSE])) {
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 ($course->visible || has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
@ -438,6 +466,10 @@ class manager {
$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) {
$areascontexts[$areaid][$modinstance->context->id] = $modinstance->context->id;
}
@ -458,6 +490,14 @@ class manager {
// 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
@ -472,6 +512,7 @@ class manager {
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'
@ -479,7 +520,7 @@ class manager {
OR bi.pagetypepattern = 'course-*'
OR bi.pagetypepattern = '*')
AND bp.id IS NULL",
array_merge([CONTEXT_BLOCK], $contextparams, $blocknameparams));
array_merge([CONTEXT_BLOCK], $contextparams, $blockcontextparams, $blocknameparams));
$blockcontextsbyname = [];
foreach ($blockrecs as $blockrec) {
if (empty($blockcontextsbyname[$blockrec->blockname])) {
@ -569,8 +610,13 @@ class manager {
*
* It might return the results from the cache instead.
*
* @param stdClass $formdata
* @param int $limit The maximum number of documents to return
* Valid formdata options include:
* - q (query text)
* - courseids (optional list of course ids to restrict)
* - contextids (optional list of context 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) {
@ -613,10 +659,15 @@ class manager {
$limitcourseids = $formdata->courseids;
}
$limitcontextids = false;
if (!empty($formdata->contextids)) {
$limitcontextids = $formdata->contextids;
}
// Clears previous query errors.
$this->engine->clear_query_error();
$areascontexts = $this->get_areas_user_accesses($limitcourseids);
$areascontexts = $this->get_areas_user_accesses($limitcourseids, $limitcontextids);
if (!$areascontexts) {
// User can not access any context.
$docs = array();

View File

@ -137,6 +137,11 @@ class engine extends \core_search\engine {
// Create the query object.
$query = $this->create_user_query($filters, $usercontexts);
// 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));
@ -242,7 +247,7 @@ class engine extends \core_search\engine {
*
* @param stdClass $filters Containing query and filters.
* @param array $usercontexts Contexts where the user has access. True if the user can access all contexts.
* @return SolrDisMaxQuery
* @return \SolrDisMaxQuery|null Query object or null if they can't get any results
*/
protected function create_user_query($filters, $usercontexts) {
global $USER;
@ -305,7 +310,7 @@ class engine extends \core_search\engine {
}
if (empty($allcontexts)) {
// This means there are no valid contexts for them, so they get no results.
return array();
return null;
}
$query->addFilterQuery('contextid:(' . implode(' OR ', $allcontexts) . ')');
}

View File

@ -209,7 +209,7 @@ class search_solr_engine_testcase extends advanced_testcase {
// Based on core_mocksearch\search\indexer.
$this->assertEquals($USER->id, $results[0]->get('userid'));
$this->assertEquals(\context_system::instance()->id, $results[0]->get('contextid'));
$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');
@ -758,4 +758,91 @@ class search_solr_engine_testcase extends advanced_testcase {
$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-mycourse'];
$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);
}
/**
* 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);
}
}

View File

@ -106,6 +106,7 @@ class search_base_testcase extends advanced_testcase {
// 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);

View File

@ -33,7 +33,7 @@ class mock_search_area extends \core_search\base {
* Multiple context level so we can test get_areas_user_accesses.
* @var int[]
*/
protected static $levels = [CONTEXT_SYSTEM, CONTEXT_USER];
protected static $levels = [CONTEXT_COURSE, CONTEXT_USER];
/**
* To make things easier, base class required config stuff.

View File

@ -72,8 +72,8 @@ class testable_core_search extends \core_search\manager {
*
* @return array
*/
public function get_areas_user_accesses($limitcourseids = false) {
return parent::get_areas_user_accesses($limitcourseids);
public function get_areas_user_accesses($limitcourseids = false, $limitcontextids = false) {
return parent::get_areas_user_accesses($limitcourseids, $limitcontextids);
}
/**

View File

@ -118,7 +118,7 @@ class core_search_generator extends component_generator_base {
}
if (!isset($options->contextid)) {
$info->contextid = \context_system::instance()->id;
$info->contextid = \context_course::instance(SITEID)->id;
} else {
$info->contextid = $options->contextid;
}

View File

@ -442,6 +442,7 @@ class search_manager_testcase extends advanced_testcase {
$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();
@ -473,14 +474,13 @@ class search_manager_testcase extends advanced_testcase {
$this->assertTrue($search->get_areas_user_accesses());
$sitectx = \context_course::instance(SITEID);
$systemctxid = \context_system::instance()->id;
// Can access the frontpage ones.
$this->setUser($noaccess);
$contexts = $search->get_areas_user_accesses();
$this->assertEquals(array($frontpageforumcontext->id => $frontpageforumcontext->id), $contexts[$this->forumpostareaid]);
$this->assertEquals(array($sitectx->id => $sitectx->id), $contexts[$this->mycoursesareaid]);
$mockctxs = array($noaccessctx->id => $noaccessctx->id, $systemctxid => $systemctxid);
$mockctxs = array($noaccessctx->id => $noaccessctx->id, $frontpagectx->id => $frontpagectx->id);
$this->assertEquals($mockctxs, $contexts[$mockareaid]);
$this->setUser($teacher);
@ -490,7 +490,8 @@ class search_manager_testcase extends advanced_testcase {
$this->assertEquals($frontpageandcourse1, $contexts[$this->forumpostareaid]);
$this->assertEquals(array($sitectx->id => $sitectx->id, $course1ctx->id => $course1ctx->id),
$contexts[$this->mycoursesareaid]);
$mockctxs = array($teacherctx->id => $teacherctx->id, $systemctxid => $systemctxid);
$mockctxs = array($teacherctx->id => $teacherctx->id,
$frontpagectx->id => $frontpagectx->id, $course1ctx->id => $course1ctx->id);
$this->assertEquals($mockctxs, $contexts[$mockareaid]);
$this->setUser($student);
@ -498,7 +499,8 @@ class search_manager_testcase extends advanced_testcase {
$this->assertEquals($frontpageandcourse1, $contexts[$this->forumpostareaid]);
$this->assertEquals(array($sitectx->id => $sitectx->id, $course1ctx->id => $course1ctx->id),
$contexts[$this->mycoursesareaid]);
$mockctxs = array($studentctx->id => $studentctx->id, $systemctxid => $systemctxid);
$mockctxs = array($studentctx->id => $studentctx->id,
$frontpagectx->id => $frontpagectx->id, $course1ctx->id => $course1ctx->id);
$this->assertEquals($mockctxs, $contexts[$mockareaid]);
// Hide the activity.
@ -532,6 +534,32 @@ class search_manager_testcase extends advanced_testcase {
$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->mycoursesareaid]);
// Test context limited search with no course limit.
$contexts = $search->get_areas_user_accesses(false,
[$frontpageforumcontext->id, $course2ctx->id]);
$this->assertEquals([$frontpageforumcontext->id => $frontpageforumcontext->id],
$contexts[$this->forumpostareaid]);
$this->assertEquals([$course2ctx->id => $course2ctx->id],
$contexts[$this->mycoursesareaid]);
// Test context limited search with course limit.
$contexts = $search->get_areas_user_accesses([$course1->id, $course2->id],
[$frontpageforumcontext->id, $course2ctx->id]);
$this->assertArrayNotHasKey($this->forumpostareaid, $contexts);
$this->assertEquals([$course2ctx->id => $course2ctx->id],
$contexts[$this->mycoursesareaid]);
// Single context and course.
$contexts = $search->get_areas_user_accesses([$course1->id], [$context1->id]);
$this->assertEquals([$context1->id => $context1->id], $contexts[$this->forumpostareaid]);
$this->assertArrayNotHasKey($this->mycoursesareaid, $contexts);
// 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]);
$this->assertEquals([$context1->id => $context1->id], $contexts[$this->forumpostareaid]);
$this->assertArrayNotHasKey($this->mycoursesareaid, $contexts);
}
/**
@ -540,6 +568,8 @@ class search_manager_testcase extends advanced_testcase {
* @return void
*/
public function test_search_user_accesses_blocks() {
global $DB;
$this->resetAfterTest();
$this->setAdminUser();
@ -631,6 +661,26 @@ class search_manager_testcase extends advanced_testcase {
$this->setUser($student1);
$limitedcontexts = $search->get_areas_user_accesses([$course3->id]);
$this->assertEquals($contexts['block_html-content'], $limitedcontexts['block_html-content']);
// Get block context ids for the blocks that appear.
global $DB;
$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]]);
$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]]);
$this->assertCount(1, $contexts['block_html-content']);
}
/**