Commit a96faa49 authored by sam marshall's avatar sam marshall
Browse files

MDL-58957 Global search: Add block support to search manager

parent 557554f9
...@@ -6,6 +6,9 @@ information provided here is intended especially for developers. ...@@ -6,6 +6,9 @@ information provided here is intended especially for developers.
* The block_instances table now contains fields timecreated and timemodified. If third-party code * The block_instances table now contains fields timecreated and timemodified. If third-party code
creates or updates these rows (without using the standard API), it should be modified to set creates or updates these rows (without using the standard API), it should be modified to set
these fields as appropriate. these fields as appropriate.
* Blocks can now be included in Moodle global search, with some limitations (at present, the search
works only for blocks located directly on course pages or site home page). See the HTML block for
an example.
=== 3.3 === === 3.3 ===
......
...@@ -270,7 +270,7 @@ abstract class base { ...@@ -270,7 +270,7 @@ abstract class base {
* Can the current user see the document. * Can the current user see the document.
* *
* @param int $id The internal search area entity id. * @param int $id The internal search area entity id.
* @return bool True if the user can see it, false otherwise * @return int manager:ACCESS_xx constant
*/ */
abstract public function check_access($id); abstract public function check_access($id);
......
<?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.
*
* @return string SQL restriction (or multiple restrictions joined by AND), empty if none
*/
protected function get_indexing_restrictions() {
return "bi.configdata != ''";
}
/**
* Gets recordset of all records modified since given time.
*
* 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 Modified from time (>= this)
*/
public function get_recordset_by_timestamp($modifiedfrom = 0) {
global $DB;
$restrictions = $this->get_indexing_restrictions();
if ($restrictions) {
$restrictions = 'AND ' . $restrictions;
}
// Query for all entries in block_instances for this type of block, which were modified
// since the given date. Also find the course or module where the block is located.
// (Although this query supports both module and course context, currently only two page
// types are supported, which will both be at course context. The module support is present
// in case of extension to other page types later.)
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
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 (bi.pagetypepattern LIKE 'course-view-%'
OR bi.pagetypepattern IN ('site-index', 'course-*', '*')))
$restrictions
ORDER BY bi.timemodified ASC",
[CONTEXT_BLOCK, CONTEXT_MODULE, CONTEXT_COURSE, $modifiedfrom,
$this->get_block_name(), CONTEXT_COURSE]);
}
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('/', [], $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();
}
}
...@@ -294,6 +294,8 @@ class manager { ...@@ -294,6 +294,8 @@ class manager {
static::$enabledsearchareas = null; static::$enabledsearchareas = null;
static::$allsearchareas = null; static::$allsearchareas = null;
static::$instance = null; static::$instance = null;
base_block::clear_static();
} }
/** /**
...@@ -331,7 +333,7 @@ class manager { ...@@ -331,7 +333,7 @@ class manager {
* @return bool|array Indexed by area identifier (component + area name). Returns true if the user can see everything. * @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) {
global $CFG, $USER; global $DB, $USER;
// All results for admins. Eventually we could add a new capability for managers. // All results for admins. Eventually we could add a new capability for managers.
if (is_siteadmin()) { if (is_siteadmin()) {
...@@ -380,19 +382,23 @@ class manager { ...@@ -380,19 +382,23 @@ class manager {
$courses[SITEID] = get_course(SITEID); $courses[SITEID] = get_course(SITEID);
} }
// Keep a list of included course context ids (needed for the block calculation below).
$coursecontextids = [];
foreach ($courses as $course) { foreach ($courses as $course) {
if (!empty($limitcourseids) && !in_array($course->id, $limitcourseids)) { if (!empty($limitcourseids) && !in_array($course->id, $limitcourseids)) {
// Skip non-included courses. // Skip non-included courses.
continue; continue;
} }
$coursecontext = \context_course::instance($course->id);
$coursecontextids[] = $coursecontext->id;
// Info about the course modules. // Info about the course modules.
$modinfo = get_fast_modinfo($course); $modinfo = get_fast_modinfo($course);
if (!empty($areasbylevel[CONTEXT_COURSE])) { if (!empty($areasbylevel[CONTEXT_COURSE])) {
// Add the course contexts the user can view. // Add the course contexts the user can view.
$coursecontext = \context_course::instance($course->id);
foreach ($areasbylevel[CONTEXT_COURSE] as $areaid => $searchclass) { foreach ($areasbylevel[CONTEXT_COURSE] as $areaid => $searchclass) {
if ($course->visible || has_capability('moodle/course:viewhiddencourses', $coursecontext)) { if ($course->visible || has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
$areascontexts[$areaid][$coursecontext->id] = $coursecontext->id; $areascontexts[$areaid][$coursecontext->id] = $coursecontext->id;
...@@ -418,6 +424,63 @@ class manager { ...@@ -418,6 +424,63 @@ class manager {
} }
} }
// Add all supported block contexts, in a single query for performance.
if (!empty($areasbylevel[CONTEXT_BLOCK])) {
// 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);
// 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
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, $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 $areascontexts; return $areascontexts;
} }
......
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Unit tests for the base_block class.
*
* @package core_search
* @copyright 2017 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
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_testcase 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_recordset_by_timestamp() {
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);
// Add blocks by hacking table (because it's not a real block type).
// 1. 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];
$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);
// Get all the blocks.
$area = new block_mockblock\search\area();
$rs = $area->get_recordset_by_timestamp();
$results = [];
foreach ($rs as $rec) {
$results[] = $rec;
}
$rs->close();
// Only blocks 1, 3, 6, and 7 should be returned. Check all the fields for the first two.
$this->assertCount(4, $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);
// Repeat with a time restriction.
$rs = $area->get_recordset_by_timestamp(2);
$results = [];
foreach ($rs as $rec) {
$results[] = $rec;
}
$rs->close();
// Only block 3, 6, and 7 are returned.
$this->assertCount(3, $results);
$this->assertEquals($block3id, $results[0]->id);
$this->assertEquals($block6id, $results[1]->id);
$this->assertEquals($block7id, $results[2]->id);
}
/**
* 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('/', [], '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]);