1 <?php 2 // This file is part of Moodle - http://moodle.org/ 3 // 4 // Moodle is free software: you can redistribute it and/or modify 5 // it under the terms of the GNU General Public License as published by 6 // the Free Software Foundation, either version 3 of the License, or 7 // (at your option) any later version. 8 // 9 // Moodle is distributed in the hope that it will be useful, 10 // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 // GNU General Public License for more details. 13 // 14 // You should have received a copy of the GNU General Public License 15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>. 16 17 /** 18 * Class core_tag_index_builder 19 * 20 * @package core_tag 21 * @copyright 2016 Marina Glancy 22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 */ 24 25 defined('MOODLE_INTERNAL') || die(); 26 27 /** 28 * Helper to build tag index 29 * 30 * This can be used by components to implement tag area callbacks. This is especially 31 * useful for in-course content when we need to check and cache user's access to 32 * multiple courses. Course access and accessible items are stored in session cache 33 * with 15 minutes expiry time. 34 * 35 * Example of usage: 36 * 37 * $builder = new core_tag_index_builder($component, $itemtype, $sql, $params, $from, $limit); 38 * while ($item = $builder->has_item_that_needs_access_check()) { 39 * if (!$builder->can_access_course($item->courseid)) { 40 * $builder->set_accessible($item, false); 41 * } else { 42 * $accessible = true; // Check access and set $accessible respectively. 43 * $builder->set_accessible($item, $accessible); 44 * } 45 * } 46 * $items = $builder->get_items(); 47 * 48 * @package core_tag 49 * @copyright 2016 Marina Glancy 50 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 51 */ 52 class core_tag_index_builder { 53 54 /** @var string component specified in the constructor */ 55 protected $component; 56 57 /** @var string itemtype specified in the constructor */ 58 protected $itemtype; 59 60 /** @var string SQL statement */ 61 protected $sql; 62 63 /** @var array parameters for SQL statement */ 64 protected $params; 65 66 /** @var int index from which to return records */ 67 protected $from; 68 69 /** @var int maximum number of records to return */ 70 protected $limit; 71 72 /** @var array result of SQL query */ 73 protected $items; 74 75 /** @var array list of item ids ( array_keys($this->items) ) */ 76 protected $itemkeys; 77 78 /** @var string alias of the item id in the SQL result */ 79 protected $idfield = 'id'; 80 81 /** @var array cache of items accessibility (id => bool) */ 82 protected $accessibleitems; 83 84 /** @var array cache of courses accessibility (courseid => bool) */ 85 protected $courseaccess; 86 87 /** @var bool indicates that items cache was changed in this class and needs pushing to MUC */ 88 protected $cachechangedaccessible = false; 89 90 /** @var bool indicates that course accessibiity cache was changed in this class and needs pushing to MUC */ 91 protected $cachechangedcourse = false; 92 93 /** @var array cached courses (not pushed to MUC) */ 94 protected $courses; 95 96 /** 97 * Constructor. 98 * 99 * Specify the SQL query for retrieving the tagged items, SQL query must: 100 * - return the item id as the first field and make sure that it is unique in the result 101 * - provide ORDER BY that exclude any possibility of random results, if $fromctx was specified when searching 102 * for tagged items it is the best practice to make sure that items from this context are returned first. 103 * 104 * This query may also contain placeholders %COURSEFILTER% or %ITEMFILTER% that will be substituted with 105 * expressions excluding courses and/or filters that are already known as inaccessible. 106 * 107 * Example: "WHERE c.id %COURSEFILTER% AND cm.id %ITEMFILTER%" 108 * 109 * This query may contain fields to preload context if context is needed for formatting values. 110 * 111 * It is recommended to sort by course sortorder first, this way the items from the same course will be next to 112 * each other and the sequence of courses will the same in different tag areas. 113 * 114 * @param string $component component responsible for tagging 115 * @param string $itemtype type of item that is being tagged 116 * @param string $sql SQL query that would retrieve all relevant items without permission check 117 * @param array $params parameters for the query (must be named) 118 * @param int $from return a subset of records, starting at this point 119 * @param int $limit return a subset comprising this many records in total (this field is NOT optional) 120 */ 121 public function __construct($component, $itemtype, $sql, $params, $from, $limit) { 122 $this->component = preg_replace('/[^A-Za-z0-9_]/i', '', $component); 123 $this->itemtype = preg_replace('/[^A-Za-z0-9_]/i', '', $itemtype); 124 $this->sql = $sql; 125 $this->params = $params; 126 $this->from = $from; 127 $this->limit = $limit; 128 $this->courses = array(); 129 } 130 131 /** 132 * Substitute %COURSEFILTER% with an expression filtering out courses where current user does not have access 133 */ 134 protected function prepare_sql_courses() { 135 global $DB; 136 if (!preg_match('/\\%COURSEFILTER\\%/', $this->sql)) { 137 return; 138 } 139 $this->init_course_access(); 140 $unaccessiblecourses = array_filter($this->courseaccess, function($item) { 141 return !$item; 142 }); 143 $idx = 0; 144 while (preg_match('/^([^\\0]*?)\\%COURSEFILTER\\%([^\\0]*)$/', $this->sql, $matches)) { 145 list($sql, $params) = $DB->get_in_or_equal(array_keys($unaccessiblecourses), 146 SQL_PARAMS_NAMED, 'ca_'.($idx++).'_', false, 0); 147 $this->sql = $matches[1].' '.$sql.' '.$matches[2]; 148 $this->params += $params; 149 } 150 } 151 152 /** 153 * Substitute %ITEMFILTER% with an expression filtering out items where current user does not have access 154 */ 155 protected function prepare_sql_items() { 156 global $DB; 157 if (!preg_match('/\\%ITEMFILTER\\%/', $this->sql)) { 158 return; 159 } 160 $this->init_items_access(); 161 $unaccessibleitems = array_filter($this->accessibleitems, function($item) { 162 return !$item; 163 }); 164 $idx = 0; 165 while (preg_match('/^([^\\0]*?)\\%ITEMFILTER\\%([^\\0]*)$/', $this->sql, $matches)) { 166 list($sql, $params) = $DB->get_in_or_equal(array_keys($unaccessibleitems), 167 SQL_PARAMS_NAMED, 'ia_'.($idx++).'_', false, 0); 168 $this->sql = $matches[1].' '.$sql.' '.$matches[2]; 169 $this->params += $params; 170 } 171 } 172 173 /** 174 * Ensures that SQL query was executed and $this->items is filled 175 */ 176 protected function retrieve_items() { 177 global $DB; 178 if ($this->items !== null) { 179 return; 180 } 181 $this->prepare_sql_courses(); 182 $this->prepare_sql_items(); 183 $this->items = $DB->get_records_sql($this->sql, $this->params); 184 $this->itemkeys = array_keys($this->items); 185 if ($this->items) { 186 // Find the name of the first key of the item - usually 'id' but can be something different. 187 // This must be a unique identifier of the item. 188 $firstitem = reset($this->items); 189 $firstitemarray = (array)$firstitem; 190 $this->idfield = key($firstitemarray); 191 } 192 } 193 194 /** 195 * Returns the filtered records from SQL query result. 196 * 197 * This function can only be executed after $builder->has_item_that_needs_access_check() returns null 198 * 199 * 200 * @return array 201 */ 202 public function get_items() { 203 global $DB, $CFG; 204 if (is_siteadmin()) { 205 $this->sql = preg_replace('/\\%COURSEFILTER\\%/', '<>0', $this->sql); 206 $this->sql = preg_replace('/\\%ITEMFILTER\\%/', '<>0', $this->sql); 207 return $DB->get_records_sql($this->sql, $this->params, $this->from, $this->limit); 208 } 209 if ($CFG->debugdeveloper && $this->has_item_that_needs_access_check()) { 210 debugging('Caller must ensure that has_item_that_needs_access_check() does not return anything ' 211 . 'before calling get_items(). The item list may be incomplete', DEBUG_DEVELOPER); 212 } 213 $this->retrieve_items(); 214 $this->save_caches(); 215 $idx = 0; 216 $items = array(); 217 foreach ($this->itemkeys as $id) { 218 if (!array_key_exists($id, $this->accessibleitems) || !$this->accessibleitems[$id]) { 219 continue; 220 } 221 if ($idx >= $this->from) { 222 $items[$id] = $this->items[$id]; 223 } 224 $idx++; 225 if ($idx >= $this->from + $this->limit) { 226 break; 227 } 228 } 229 return $items; 230 } 231 232 /** 233 * Returns the first row from the SQL result that we don't know whether it is accessible by user or not. 234 * 235 * This will return null when we have necessary number of accessible items to return in {@link get_items()} 236 * 237 * After analyzing you may decide to mark not only this record but all similar as accessible or not accessible. 238 * For example, if you already call get_fast_modinfo() to check this item's accessibility, why not mark all 239 * items in the same course as accessible or not accessible. 240 * 241 * Helpful methods: {@link set_accessible()} and {@link walk()} 242 * 243 * @return null|object 244 */ 245 public function has_item_that_needs_access_check() { 246 if (is_siteadmin()) { 247 return null; 248 } 249 $this->retrieve_items(); 250 $counter = 0; // Counter for accessible items. 251 foreach ($this->itemkeys as $id) { 252 if (!array_key_exists($id, $this->accessibleitems)) { 253 return (object)(array)$this->items[$id]; 254 } 255 $counter += $this->accessibleitems[$id] ? 1 : 0; 256 if ($counter >= $this->from + $this->limit) { 257 // We found enough accessible items fot get_items() method, do not look any further. 258 return null; 259 } 260 } 261 return null; 262 } 263 264 /** 265 * Walk through the array of items and call $callable for each of them 266 * @param callable $callable 267 */ 268 public function walk($callable) { 269 $this->retrieve_items(); 270 array_walk($this->items, $callable); 271 } 272 273 /** 274 * Marks record or group of records as accessible (or not accessible) 275 * 276 * @param int|std_Class $identifier either record id of the item that needs to be set accessible 277 * @param bool $accessible whether to mark as accessible or not accessible (default true) 278 */ 279 public function set_accessible($identifier, $accessible = true) { 280 if (is_object($identifier)) { 281 $identifier = (int)($identifier->{$this->idfield}); 282 } 283 $this->init_items_access(); 284 if (is_int($identifier)) { 285 $accessible = (int)(bool)$accessible; 286 if (!array_key_exists($identifier, $this->accessibleitems) || 287 $this->accessibleitems[$identifier] != $accessible) { 288 $this->accessibleitems[$identifier] = $accessible; 289 $this->cachechangedaccessible; 290 } 291 } else { 292 throw new coding_exception('Argument $identifier must be either int or object'); 293 } 294 } 295 296 /** 297 * Retrieves a course record (only fields id,visible,fullname,shortname,cacherev). 298 * 299 * This method is useful because it also caches results and preloads course context. 300 * 301 * @param int $courseid 302 */ 303 public function get_course($courseid) { 304 global $DB; 305 if (!array_key_exists($courseid, $this->courses)) { 306 $ctxquery = context_helper::get_preload_record_columns_sql('ctx'); 307 $sql = "SELECT c.id,c.visible,c.fullname,c.shortname,c.cacherev, $ctxquery 308 FROM {course} c JOIN {context} ctx ON ctx.contextlevel = ? AND ctx.instanceid=c.id 309 WHERE c.id = ?"; 310 $params = array(CONTEXT_COURSE, $courseid); 311 $this->courses[$courseid] = $DB->get_record_sql($sql, $params); 312 context_helper::preload_from_record($this->courses[$courseid]); 313 } 314 return $this->courses[$courseid]; 315 } 316 317 /** 318 * Ensures that we read the course access from the cache. 319 */ 320 protected function init_course_access() { 321 if ($this->courseaccess === null) { 322 $this->courseaccess = cache::make('core', 'tagindexbuilder')->get('courseaccess') ?: []; 323 } 324 } 325 326 /** 327 * Ensures that we read the items access from the cache. 328 */ 329 protected function init_items_access() { 330 if ($this->accessibleitems === null) { 331 $this->accessibleitems = cache::make('core', 'tagindexbuilder')->get($this->component.'__'.$this->itemtype) ?: []; 332 } 333 } 334 335 /** 336 * Checks if current user has access to the course 337 * 338 * This method calls global function {@link can_access_course} and caches results 339 * 340 * @param int $courseid 341 * @return bool 342 */ 343 public function can_access_course($courseid) { 344 $this->init_course_access(); 345 if (!array_key_exists($courseid, $this->courseaccess)) { 346 $this->courseaccess[$courseid] = can_access_course($this->get_course($courseid)) ? 1 : 0; 347 $this->cachechangedcourse = true; 348 } 349 return $this->courseaccess[$courseid]; 350 } 351 352 /** 353 * Saves course/items caches if needed 354 */ 355 protected function save_caches() { 356 if ($this->cachechangedcourse) { 357 cache::make('core', 'tagindexbuilder')->set('courseaccess', $this->courseaccess); 358 $this->cachechangedcourse = false; 359 } 360 if ($this->cachechangedaccessible) { 361 cache::make('core', 'tagindexbuilder')->set($this->component.'__'.$this->itemtype, 362 $this->accessibleitems); 363 $this->cachechangedaccessible = false; 364 } 365 } 366 367 /** 368 * Resets all course/items session caches - useful in unittests when we change users and enrolments. 369 */ 370 public static function reset_caches() { 371 cache_helper::purge_by_event('resettagindexbuilder'); 372 } 373 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body