MDL-39846 rename 'extra' event property to 'other'
[moodle.git] / lib / classes / event / base.php
CommitLineData
d8a1f426
PS
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
17namespace core\event;
18
19/**
20 * Base event class.
21 *
22 * @package core
23 * @copyright 2013 Petr Skoda {@link http://skodak.org}
24 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25 */
26
27
28/**
29 * All other event classes must extend this class.
30 *
31 * @package core
32 * @copyright 2013 Petr Skoda {@link http://skodak.org}
33 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
34 *
35 * @property-read string $eventname Name of the event (=== class name with leading \)
36 * @property-read string $component Full frankenstyle component name
37 * @property-read string $action what happened
38 * @property-read string $object what/who was object of the action (usually similar to database table name)
39 * @property-read int $objectid optional id of the object
40 * @property-read string $crud letter indicating event type
4b734e74 41 * @property-read int $level log level (number between 1 and 100)
d8a1f426
PS
42 * @property-read int $contextid
43 * @property-read int $contextlevel
44 * @property-read int $contextinstanceid
45 * @property-read int $userid who did this?
46 * @property-read int $courseid
47 * @property-read int $relateduserid
c4297815 48 * @property-read mixed $other array or scalar, can not contain objects
d8a1f426
PS
49 * @property-read int $timecreated
50 */
51abstract class base {
52 /** @var array event data */
53 protected $data;
54
4b734e74
PS
55 /** @var array the format is standardised by logging API */
56 protected $logdata;
57
d8a1f426
PS
58 /** @var \context of this event */
59 protected $context;
60
22626564
PS
61 /**
62 * @var bool indicates if event was already triggered,
63 * this prevents second attempt to trigger event.
64 */
d8a1f426
PS
65 private $triggered;
66
22626564
PS
67 /**
68 * @var bool indicates if event was already dispatched,
69 * this prevents direct calling of manager::dispatch($event).
70 */
71 private $dispatched;
72
73 /**
74 * @var bool indicates if event was restored from storage,
75 * this prevents triggering of restored events.
76 */
d8a1f426
PS
77 private $restored;
78
79 /** @var array list of event properties */
80 private static $fields = array(
81 'eventname', 'component', 'action', 'object', 'objectid', 'crud', 'level', 'contextid',
c4297815 82 'contextlevel', 'contextinstanceid', 'userid', 'courseid', 'relateduserid', 'other',
4b734e74 83 'timecreated');
d8a1f426
PS
84
85 /** @var array simple record cache */
86 protected $cachedrecords = array();
87
88 /**
89 * Private constructor, use create() or restore() methods instead.
90 */
91 private final function __construct() {
92 $this->data = array_fill_keys(self::$fields, null);
93 }
94
95 /**
96 * Create new event.
97 *
98 * The optional data keys as:
99 * 1/ objectid - the id of the object specified in class name
100 * 2/ context - the context of this event
c4297815 101 * 3/ other - the other data describing the event, can not contain objects
d8a1f426
PS
102 * 4/ relateduserid - the id of user which is somehow related to this event
103 *
104 * @param array $data
105 * @return \core\event\base returns instance of new event
106 *
107 * @throws \coding_exception
108 */
109 public static final function create(array $data = null) {
110 global $PAGE, $USER;
111
112 $data = (array)$data;
113
114 /** @var \core\event\base $event */
115 $event = new static();
116 $event->triggered = false;
117 $event->restored = false;
22626564 118 $event->dispatched = false;
d8a1f426 119
4b734e74
PS
120 // Set automatic data.
121 $event->data['timecreated'] = time();
122
d8a1f426
PS
123 $classname = get_class($event);
124 $parts = explode('\\', $classname);
125 if (count($parts) !== 3 or $parts[1] !== 'event') {
126 throw new \coding_exception("Invalid event class name '$classname', it must be defined in component\\event\\ namespace");
127 }
128 $event->data['eventname'] = '\\'.$classname;
129 $event->data['component'] = $parts[0];
130
131 $pos = strrpos($parts[2], '_');
132 if ($pos === false) {
133 throw new \coding_exception("Invalid event class name '$classname', there must be at least one underscore separating object and action words");
134 }
135 $event->data['object'] = substr($parts[2], 0, $pos);
136 $event->data['action'] = substr($parts[2], $pos+1);
137
4b734e74 138 // Set optional data or use defaults.
d8a1f426
PS
139 $event->data['objectid'] = isset($data['objectid']) ? $data['objectid'] : null;
140 $event->data['courseid'] = isset($data['courseid']) ? $data['courseid'] : null;
141 $event->data['userid'] = isset($data['userid']) ? $data['userid'] : $USER->id;
c4297815 142 $event->data['other'] = isset($data['other']) ? $data['other'] : null;
d8a1f426
PS
143 $event->data['relateduserid'] = isset($data['relateduserid']) ? $data['relateduserid'] : null;
144
145 $event->context = null;
146 if (isset($data['context'])) {
147 $event->context = $data['context'];
148 } else if (isset($data['contextid'])) {
149 $event->context = \context::instance_by_id($data['contextid']);
150 } else if ($event->data['courseid']) {
151 $event->context = \context_course::instance($event->data['courseid']);
152 } else if (isset($PAGE)) {
153 $event->context = $PAGE->context;
154 }
155 if (!$event->context) {
156 $event->context = \context_system::instance();
157 }
d8a1f426
PS
158 $event->data['contextid'] = $event->context->id;
159 $event->data['contextlevel'] = $event->context->contextlevel;
160 $event->data['contextinstanceid'] = $event->context->instanceid;
161
162 if (!isset($event->data['courseid'])) {
163 if ($coursecontext = $event->context->get_course_context(false)) {
164 $event->data['courseid'] = $coursecontext->id;
165 } else {
166 $event->data['courseid'] = 0;
167 }
168 }
169
170 if (!array_key_exists('relateduserid', $data) and $event->context->contextlevel == CONTEXT_USER) {
171 $event->data['relateduserid'] = $event->context->instanceid;
172 }
173
c4297815 174 // Set static event data specific for child class, this should also validate other data.
4b734e74 175 $event->init();
d8a1f426 176
4b734e74 177 // Warn developers if they do something wrong.
d8a1f426 178 if (debugging('', DEBUG_DEVELOPER)) {
4b734e74
PS
179 static $automatickeys = array('eventname', 'component', 'action', 'object', 'timecreated');
180 static $initkeys = array('crud', 'level');
181
182 foreach ($data as $key => $ignored) {
183 if ($key === 'context') {
184 continue;
185
186 } else if (in_array($key, $automatickeys)) {
187 debugging("Data key '$key' is not allowed in \\core\\event\\base::create() method, it is set automatically");
188
189 } else if (in_array($key, $initkeys)) {
190 debugging("Data key '$key' is not allowed in \\core\\event\\base::create() method, you need to set it in init() method");
191
192 } else if (!in_array($key, self::$fields)) {
193 debugging("Data key '$key' does not exist in \\core\\event\\base");
d8a1f426
PS
194 }
195 }
196 }
197
d8a1f426
PS
198 return $event;
199 }
200
201 /**
202 * Override in subclass.
203 *
204 * Set all required data properties:
4b734e74
PS
205 * 1/ crud - letter [crud]
206 * 2/ level - number 1...100
207 *
05a970d3
PS
208 * TODO: MDL-37658
209 *
c4297815 210 * Optionally validate $this->data['other'].
d8a1f426
PS
211 *
212 * @return void
213 */
214 protected abstract function init();
215
4b734e74
PS
216 /**
217 * Returns localised general event name.
218 *
219 * Override in subclass, we can not make it static and abstract at the same time.
220 *
05a970d3
PS
221 * TODO: MDL-37658
222 *
4b734e74
PS
223 * @return string|\lang_string
224 */
225 public static function get_name() {
226 // Override in subclass with real lang string.
227 $parts = explode('\\', __CLASS__);
228 if (count($parts) !== 3) {
229 return 'unknown event';
230 }
231 return $parts[0].': '.str_replace('_', ' ', $parts[2]);
232 }
233
234 /**
235 * Returns localised description of what happened.
236 *
05a970d3
PS
237 * TODO: MDL-37658
238 *
4b734e74
PS
239 * @return string|\lang_string
240 */
241 public function get_description() {
242 return null;
243 }
244
245 /**
246 * Define whether a user can view the event or not.
247 *
248 * @param int|\stdClass $user_or_id ID of the user.
249 * @return bool True if the user can view the event, false otherwise.
250 */
251 public function can_view($user_or_id = null) {
252 return is_siteadmin($user_or_id);
253 }
254
d8a1f426
PS
255 /**
256 * Restore event from existing historic data.
257 *
258 * @param array $data
4b734e74 259 * @param array $logdata the format is standardised by logging API
d8a1f426
PS
260 * @return bool|\core\event\base
261 */
4b734e74 262 public static final function restore(array $data, array $logdata) {
d8a1f426
PS
263 $classname = $data['eventname'];
264 $component = $data['component'];
265 $action = $data['action'];
266 $object = $data['object'];
267
268 // Security: make 100% sure this really is an event class.
269 if ($classname !== "\\{$component}\\event\\{$object}_{$action}") {
270 return false;
271 }
272
273 if (!class_exists($classname)) {
274 return false;
275 }
276 $event = new $classname();
277 if (!($event instanceof \core\event\base)) {
278 return false;
279 }
280
d8a1f426 281 $event->restored = true;
22626564
PS
282 $event->triggered = true;
283 $event->dispatched = true;
4b734e74 284 $event->logdata = $logdata;
d8a1f426
PS
285
286 foreach (self::$fields as $key) {
4b734e74 287 if (!array_key_exists($key, $data)) {
d8a1f426 288 debugging("Event restore data must contain key $key");
4b734e74 289 $data[$key] = null;
d8a1f426
PS
290 }
291 }
4b734e74
PS
292 if (count($data) != count(self::$fields)) {
293 foreach ($data as $key => $value) {
294 if (!in_array($key, self::$fields)) {
295 debugging("Event restore data cannot contain key $key");
296 unset($data[$key]);
297 }
298 }
299 }
300 $event->data = $data;
d8a1f426
PS
301
302 return $event;
303 }
304
d8a1f426
PS
305 /**
306 * Returns event context.
307 * @return \context
308 */
309 public function get_context() {
310 if (isset($this->context)) {
311 return $this->context;
312 }
313 $this->context = \context::instance_by_id($this->data['contextid'], false);
314 return $this->context;
315 }
316
317 /**
318 * Returns relevant URL, override in subclasses.
4b734e74 319 * @return \moodle_url
d8a1f426
PS
320 */
321 public function get_url() {
322 return null;
323 }
324
325 /**
326 * Return standardised event data as array.
327 *
d8a1f426
PS
328 * @return array
329 */
330 public function get_data() {
331 return $this->data;
332 }
333
4b734e74
PS
334 /**
335 * Return auxiliary data that was stored in logs.
336 *
05a970d3
PS
337 * TODO: MDL-37658
338 *
4b734e74
PS
339 * @return array the format is standardised by logging API
340 */
341 public function get_logdata() {
342 return $this->logdata;
343 }
344
d8a1f426
PS
345 /**
346 * Does this event replace legacy event?
347 *
348 * @return null|string legacy event name
349 */
350 public function get_legacy_eventname() {
351 return null;
352 }
353
354 /**
355 * Legacy event data if get_legacy_eventname() is not empty.
356 *
357 * @return mixed
358 */
359 public function get_legacy_eventdata() {
360 return null;
361 }
362
363 /**
364 * Doest this event replace add_to_log() statement?
365 *
366 * @return null|array of parameters to be passed to legacy add_to_log() function.
367 */
368 public function get_legacy_logdata() {
369 return null;
370 }
371
372 /**
373 * Validate all properties right before triggering the event.
374 *
375 * This throws coding exceptions for fatal problems and debugging for minor problems.
376 *
377 * @throws \coding_exception
378 */
379 protected final function validate_before_trigger() {
380 if (empty($this->data['crud'])) {
381 throw new \coding_exception('crud must be specified in init() method of each method');
382 }
383 if (empty($this->data['level'])) {
384 throw new \coding_exception('level must be specified in init() method of each method');
385 }
386
387 if (debugging('', DEBUG_DEVELOPER)) {
d8a1f426
PS
388 // Ideally these should be coding exceptions, but we need to skip these for performance reasons
389 // on production servers.
4b734e74
PS
390
391 if (!in_array($this->data['crud'], array('c', 'r', 'u', 'd'), true)) {
392 debugging("Invalid event crud value specified.");
393 }
394 if (!is_number($this->data['level'])) {
395 debugging('Event property level must be a number');
396 }
d8a1f426 397 if (self::$fields !== array_keys($this->data)) {
4b734e74 398 debugging('Number of event data fields must not be changed in event classes');
d8a1f426 399 }
c4297815
PS
400 $encoded = json_encode($this->data['other']);
401 if ($encoded === false or $this->data['other'] !== json_decode($encoded, true)) {
402 debugging('other event data must be compatible with json encoding');
4b734e74
PS
403 }
404 if ($this->data['userid'] and !is_number($this->data['userid'])) {
405 debugging('Event property userid must be a number');
406 }
407 if ($this->data['courseid'] and !is_number($this->data['courseid'])) {
408 debugging('Event property courseid must be a number');
409 }
410 if ($this->data['objectid'] and !is_number($this->data['objectid'])) {
411 debugging('Event property objectid must be a number');
412 }
413 if ($this->data['relateduserid'] and !is_number($this->data['relateduserid'])) {
414 debugging('Event property relateduserid must be a number');
d8a1f426
PS
415 }
416 }
417 }
418
419 /**
420 * Trigger event.
421 */
422 public final function trigger() {
423 global $CFG;
424
425 if ($this->restored) {
426 throw new \coding_exception('Can not trigger restored event');
427 }
22626564 428 if ($this->triggered or $this->dispatched) {
d8a1f426
PS
429 throw new \coding_exception('Can not trigger event twice');
430 }
431
d8a1f426
PS
432 $this->validate_before_trigger();
433
22626564
PS
434 $this->triggered = true;
435
d8a1f426
PS
436 if (!empty($CFG->loglifetime)) {
437 if ($data = $this->get_legacy_logdata()) {
438 call_user_func_array('add_to_log', $data);
439 }
440 }
441
442 \core\event\manager::dispatch($this);
443
22626564
PS
444 $this->dispatched = true;
445
d8a1f426
PS
446 if ($legacyeventname = $this->get_legacy_eventname()) {
447 events_trigger($legacyeventname, $this->get_legacy_eventdata());
448 }
449 }
450
451 /**
22626564 452 * Was this event already triggered?
d8a1f426
PS
453 *
454 * @return bool
455 */
22626564 456 public final function is_triggered() {
d8a1f426
PS
457 return $this->triggered;
458 }
459
22626564
PS
460 /**
461 * Used from event manager to prevent direct access.
462 *
463 * @return bool
464 */
465 public final function is_dispatched() {
466 return $this->dispatched;
467 }
468
d8a1f426 469 /**
4b734e74 470 * Was this event restored?
d8a1f426
PS
471 *
472 * @return bool
473 */
22626564 474 public final function is_restored() {
d8a1f426
PS
475 return $this->restored;
476 }
477
478 /**
479 * Add cached data that will be most probably used in event observers.
480 *
481 * This is used to improve performance, but it is required for data
4b734e74 482 * that was just deleted.
d8a1f426
PS
483 *
484 * @param string $tablename
485 * @param \stdClass $record
486 */
487 public function add_cached_record($tablename, $record) {
488 global $DB;
489
490 // NOTE: this might use some kind of MUC cache,
491 // hopefully we will not run out of memory here...
492 if (debugging('', DEBUG_DEVELOPER)) {
493 if (!$DB->get_manager()->table_exists($tablename)) {
494 debugging("Invalid table name '$tablename' specified, database table does not exist.");
495 }
496 }
497 $this->cachedrecords[$tablename][$record->id] = $record;
498 }
499
500 /**
501 * Returns cached record or fetches data from database if not cached.
502 *
503 * @param string $tablename
504 * @param int $id
505 * @return \stdClass
506 */
507 public function get_cached_record($tablename, $id) {
508 global $DB;
509
510 if (isset($this->cachedrecords[$tablename][$id])) {
511 return $this->cachedrecords[$tablename][$id];
512 }
513
514 $record = $DB->get_record($tablename, array('id'=>$id));
515 $this->cachedrecords[$tablename][$id] = $record;
516
517 return $record;
518 }
519
520 /**
521 * Magic getter for read only access.
522 *
d8a1f426
PS
523 * @param string $name
524 * @return mixed
d8a1f426
PS
525 */
526 public function __get($name) {
527 if (array_key_exists($name, $this->data)) {
528 return $this->data[$name];
529 }
530
4b734e74 531 debugging("Accessing non-existent event property '$name'");
d8a1f426 532 }
605a8c33
PS
533
534 /**
535 * Magic setter.
536 *
537 * Note: we must not allow modification of data from outside,
538 * after trigger() the data MUST NOT CHANGE!!!
539 *
540 * @param string $name
541 * @param mixed $value
542 *
543 * @throws \coding_exception
544 */
545 public function __set($name, $value) {
546 throw new \coding_exception('Event properties must not be modified.');
547 }
548
549 /**
550 * Is data property set?
551 *
552 * @param string $name
553 * @return bool
554 */
555 public function __isset($name) {
556 return isset($this->data[$name]);
557 }
d8a1f426 558}