diff --git a/mod/book/classes/search/chapter.php b/mod/book/classes/search/chapter.php new file mode 100644 index 00000000000..da0be812a2a --- /dev/null +++ b/mod/book/classes/search/chapter.php @@ -0,0 +1,161 @@ +. + +/** + * Search area for mod_book chapters. + * + * @package mod_book + * @copyright 2016 Eric Merrill {@link http://www.merrilldigital.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_book\search; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Search area for mod_book chapters. + * + * @package mod_book + * @copyright 2016 Eric Merrill {@link http://www.merrilldigital.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class chapter extends \core_search\area\base_mod { + /** + * @var array Cache of book records. + */ + protected $bookscache = array(); + + /** + * Returns a recordset with all required chapter information. + * + * @param int $modifiedfrom + * @return moodle_recordset + */ + public function get_recordset_by_timestamp($modifiedfrom = 0) { + global $DB; + + $sql = 'SELECT c.*, b.id AS bookid, b.course AS courseid + FROM {book_chapters} c + JOIN {book} b ON b.id = c.bookid + WHERE c.timemodified >= ? ORDER BY c.timemodified ASC'; + return $DB->get_recordset_sql($sql, array($modifiedfrom)); + } + + /** + * Returns the document for a particular chapter. + * + * @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 + */ + public function get_document($record, $options = array()) { + try { + $cm = $this->get_cm('book', $record->bookid, $record->courseid); + $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', $record->title); + $doc->set('content', content_to_text($record->content, $record->contentformat)); + $doc->set('contextid', $context->id); + $doc->set('courseid', $record->courseid); + $doc->set('owneruserid', \core_search\manager::NO_OWNER_ID); + $doc->set('modified', $record->timemodified); + + // Check if this document should be considered new. + if (isset($options['lastindexedtime']) && ($options['lastindexedtime'] < $record->timecreated)) { + // If the document was created after the last index time, it must be new. + $doc->set_is_new(true); + } + + return $doc; + } + + /** + * Can the current user see the document. + * + * @param int $id The internal search area entity id. + * @return bool True if the user can see it, false otherwise + */ + public function check_access($id) { + global $DB; + + try { + $chapter = $DB->get_record('book_chapters', array('id' => $id), '*', MUST_EXIST); + if (!isset($this->bookscache[$chapter->bookid])) { + $this->bookscache[$chapter->bookid] = $DB->get_record('book', array('id' => $chapter->bookid), '*', MUST_EXIST); + } + $book = $this->bookscache[$chapter->bookid]; + $cminfo = $this->get_cm('book', $chapter->bookid, $book->course); + } 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; + } + + $context = \context_module::instance($cminfo->id); + + if (!has_capability('mod/book:read', $context)) { + return \core_search\manager::ACCESS_DENIED; + } + + // See if the user can see chapter if it is hidden. + if ($chapter->hidden && !has_capability('mod/book:viewhiddenchapters', $context)) { + return \core_search\manager::ACCESS_DENIED; + } + + return \core_search\manager::ACCESS_GRANTED; + } + + /** + * Returns a url to the chapter. + * + * @param \core_search\document $doc + * @return \moodle_url + */ + public function get_doc_url(\core_search\document $doc) { + $contextmodule = \context::instance_by_id($doc->get('contextid')); + $params = array('id' => $contextmodule->instanceid, 'chapterid' => $doc->get('itemid')); + return new \moodle_url('/mod/book/view.php', $params); + } + + /** + * Returns a url to the book. + * + * @param \core_search\document $doc + * @return \moodle_url + */ + public function get_context_url(\core_search\document $doc) { + $contextmodule = \context::instance_by_id($doc->get('contextid')); + return new \moodle_url('/mod/book/view.php', array('id' => $contextmodule->instanceid)); + } +} diff --git a/mod/book/lang/en/book.php b/mod/book/lang/en/book.php index 7fa36143e61..57d374ff8a9 100644 --- a/mod/book/lang/en/book.php +++ b/mod/book/lang/en/book.php @@ -56,6 +56,7 @@ $string['hidechapter'] = 'Hide chapter "{$a}"'; $string['movechapterup'] = 'Move chapter up "{$a}"'; $string['movechapterdown'] = 'Move chapter down "{$a}"'; $string['search:activity'] = 'Book activities'; +$string['search:chapter'] = 'Book chapters'; $string['showchapter'] = 'Show chapter "{$a}"'; $string['subchapter'] = 'Subchapter'; $string['navimages'] = 'Images'; diff --git a/mod/book/tests/search_test.php b/mod/book/tests/search_test.php new file mode 100644 index 00000000000..3f6d79c3a79 --- /dev/null +++ b/mod/book/tests/search_test.php @@ -0,0 +1,160 @@ +. + +/** + * Book search unit tests. + * + * @package mod_book + * @category test + * @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(); + +global $CFG; +require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php'); + +/** + * Provides the unit tests for book search. + * + * @package mod_book + * @category test + * @copyright 2016 Eric Merrill {@link http://www.merrilldigital.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mod_book_search_testcase extends advanced_testcase { + + /** + * @var string Area id + */ + protected $bookchapterareaid = null; + + public function setUp() { + $this->resetAfterTest(true); + set_config('enableglobalsearch', true); + + $this->bookchapterareaid = \core_search\manager::generate_areaid('mod_book', 'chapter'); + + // 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(); + } + + /** + * Availability. + * + * @return void + */ + public function test_search_enabled() { + + $searcharea = \core_search\manager::get_search_area($this->bookchapterareaid); + list($componentname, $varname) = $searcharea->get_config_var_name(); + + // Enabled by default once global search is enabled. + $this->assertTrue($searcharea->is_enabled()); + + set_config($varname . '_enabled', false, $componentname); + $this->assertFalse($searcharea->is_enabled()); + + set_config($varname . '_enabled', true, $componentname); + $this->assertTrue($searcharea->is_enabled()); + } + + /** + * Indexing chapter contents. + * + * @return void + */ + public function test_chapters_indexing() { + global $DB; + + // Returns the instance as long as the area is supported. + $searcharea = \core_search\manager::get_search_area($this->bookchapterareaid); + $this->assertInstanceOf('\mod_book\search\chapter', $searcharea); + + $course1 = self::getDataGenerator()->create_course(); + $book = $this->getDataGenerator()->create_module('book', array('course' => $course1->id)); + + $bookgenerator = $this->getDataGenerator()->get_plugin_generator('mod_book'); + $chapter1 = $bookgenerator->create_chapter(array('bookid' => $book->id, 'content' => 'Chapter1', 'title' => 'Title1')); + $chapter2 = $bookgenerator->create_chapter(array('bookid' => $book->id, 'content' => 'Chapter2', 'title' => 'Title2')); + + // All records. + $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); + + // Static caches are working. + $dbreads = $DB->perf_get_reads(); + $doc = $searcharea->get_document($record); + $this->assertEquals($dbreads, $DB->perf_get_reads()); + $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(2, $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(); + } + + /** + * Document contents. + * + * @return void + */ + public function test_check_access() { + global $DB; + + // Returns the instance as long as the area is supported. + $searcharea = \core_search\manager::get_search_area($this->bookchapterareaid); + $this->assertInstanceOf('\mod_book\search\chapter', $searcharea); + + $user1 = self::getDataGenerator()->create_user(); + $course1 = self::getDataGenerator()->create_course(); + $this->getDataGenerator()->enrol_user($user1->id, $course1->id, 'student'); + + $book = $this->getDataGenerator()->create_module('book', array('course' => $course1->id)); + $bookgenerator = $this->getDataGenerator()->get_plugin_generator('mod_book'); + + $chapter = array('bookid' => $book->id, 'content' => 'Chapter1', 'title' => 'Title1'); + $chapter1 = $bookgenerator->create_chapter($chapter); + $chapter['content'] = 'Chapter2'; + $chapter['title'] = 'Title2'; + $chapter['hidden'] = 1; + $chapter2 = $bookgenerator->create_chapter($chapter); + + $this->setAdminUser(); + $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($chapter1->id)); + $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($chapter2->id)); + + $this->setUser($user1); + + $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($chapter1->id)); + $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($chapter2->id)); + + $this->assertEquals(\core_search\manager::ACCESS_DELETED, $searcharea->check_access($chapter2->id + 10)); + } +}