MDL-39846 implement new event dispatching and base event class
[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
41 * @property-read int $level log level
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
48 * @property-read mixed $extra array or scalar, can not contain objects
49 * @property-read int $realuserid who really did this?
50 * @property-read string $origin
51 * @property-read int $timecreated
52 */
53abstract class base {
54 /** @var array event data */
55 protected $data;
56
57 /** @var \context of this event */
58 protected $context;
59
60 /** @var bool indicates if event was already triggered */
61 private $triggered;
62
63 /** @var bool indicates if event was restored from storage */
64 private $restored;
65
66 /** @var array list of event properties */
67 private static $fields = array(
68 'eventname', 'component', 'action', 'object', 'objectid', 'crud', 'level', 'contextid',
69 'contextlevel', 'contextinstanceid', 'userid', 'courseid', 'relateduserid', 'extra',
70 'realuserid', 'origin', 'timecreated');
71
72 /** @var array simple record cache */
73 protected $cachedrecords = array();
74
75 /**
76 * Private constructor, use create() or restore() methods instead.
77 */
78 private final function __construct() {
79 $this->data = array_fill_keys(self::$fields, null);
80 }
81
82 /**
83 * Create new event.
84 *
85 * The optional data keys as:
86 * 1/ objectid - the id of the object specified in class name
87 * 2/ context - the context of this event
88 * 3/ extra - the extra data describing the event, can not contain objects
89 * 4/ relateduserid - the id of user which is somehow related to this event
90 *
91 * @param array $data
92 * @return \core\event\base returns instance of new event
93 *
94 * @throws \coding_exception
95 */
96 public static final function create(array $data = null) {
97 global $PAGE, $USER;
98
99 $data = (array)$data;
100
101 /** @var \core\event\base $event */
102 $event = new static();
103 $event->triggered = false;
104 $event->restored = false;
105
106 $classname = get_class($event);
107 $parts = explode('\\', $classname);
108 if (count($parts) !== 3 or $parts[1] !== 'event') {
109 throw new \coding_exception("Invalid event class name '$classname', it must be defined in component\\event\\ namespace");
110 }
111 $event->data['eventname'] = '\\'.$classname;
112 $event->data['component'] = $parts[0];
113
114 $pos = strrpos($parts[2], '_');
115 if ($pos === false) {
116 throw new \coding_exception("Invalid event class name '$classname', there must be at least one underscore separating object and action words");
117 }
118 $event->data['object'] = substr($parts[2], 0, $pos);
119 $event->data['action'] = substr($parts[2], $pos+1);
120
121 // Do not let developers to something crazy.
122 if (debugging('', DEBUG_DEVELOPER)) {
123 $keys = array('eventname', 'component', 'action', 'object', 'realuserid', 'origin', 'timecreated');
124 foreach ($keys as $key) {
125 if (array_key_exists($key, $data)) {
126 debugging("Data key '$key' is no allowed in event \\core\\event\\base::create() method, it is set automatically.", DEBUG_DEVELOPER);
127 }
128 }
129 $keys = array('crud', 'level');
130 foreach ($keys as $key) {
131 if (array_key_exists($key, $data)) {
132 debugging("Data key '$key' is no allowed in event \\core\\event\\base::create() method, you need to set it in init method.", DEBUG_DEVELOPER);
133 }
134 }
135 }
136 unset($data['eventname']);
137 unset($data['component']);
138 unset($data['action']);
139 unset($data['object']);
140 unset($data['crud']);
141 unset($data['level']);
142 unset($data['realuserid']);
143 unset($data['origin']);
144 unset($data['timecreated']);
145
146 // Set optional data.
147 $event->data['objectid'] = isset($data['objectid']) ? $data['objectid'] : null;
148 $event->data['courseid'] = isset($data['courseid']) ? $data['courseid'] : null;
149 $event->data['userid'] = isset($data['userid']) ? $data['userid'] : $USER->id;
150 $event->data['extra'] = isset($data['extra']) ? $data['extra'] : null;
151 $event->data['relateduserid'] = isset($data['relateduserid']) ? $data['relateduserid'] : null;
152
153 $event->context = null;
154 if (isset($data['context'])) {
155 $event->context = $data['context'];
156 } else if (isset($data['contextid'])) {
157 $event->context = \context::instance_by_id($data['contextid']);
158 } else if ($event->data['courseid']) {
159 $event->context = \context_course::instance($event->data['courseid']);
160 } else if (isset($PAGE)) {
161 $event->context = $PAGE->context;
162 }
163 if (!$event->context) {
164 $event->context = \context_system::instance();
165 }
166 unset($data['context']);
167 $event->data['contextid'] = $event->context->id;
168 $event->data['contextlevel'] = $event->context->contextlevel;
169 $event->data['contextinstanceid'] = $event->context->instanceid;
170
171 if (!isset($event->data['courseid'])) {
172 if ($coursecontext = $event->context->get_course_context(false)) {
173 $event->data['courseid'] = $coursecontext->id;
174 } else {
175 $event->data['courseid'] = 0;
176 }
177 }
178
179 if (!array_key_exists('relateduserid', $data) and $event->context->contextlevel == CONTEXT_USER) {
180 $event->data['relateduserid'] = $event->context->instanceid;
181 }
182
183 if (CLI_SCRIPT) {
184 $event->data['origin'] = 'cli';
185 } else if (AJAX_SCRIPT) {
186 $event->data['origin'] = 'ajax:'.getremoteaddr();
187 } else {
188 $event->data['origin'] = 'web:'.getremoteaddr();
189 // TODO: detect web services somehow, for now it is logged separately.
190 }
191
192 if (debugging('', DEBUG_DEVELOPER)) {
193 foreach (array_keys($data) as $key) {
194 if (!in_array($key, self::$fields)) {
195 debugging("Unsupported event data field '$key' detected.");
196 }
197 }
198 }
199
200 $event->init();
201
202 return $event;
203 }
204
205 /**
206 * Override in subclass.
207 *
208 * Set all required data properties:
209 * 1/ crud
210 * 2/ level
211 *
212 * @return void
213 */
214 protected abstract function init();
215
216 /**
217 * Restore event from existing historic data.
218 *
219 * @param array $data
220 * @return bool|\core\event\base
221 */
222 public static final function restore(array $data = null) {
223 $classname = $data['eventname'];
224 $component = $data['component'];
225 $action = $data['action'];
226 $object = $data['object'];
227
228 // Security: make 100% sure this really is an event class.
229 if ($classname !== "\\{$component}\\event\\{$object}_{$action}") {
230 return false;
231 }
232
233 if (!class_exists($classname)) {
234 return false;
235 }
236 $event = new $classname();
237 if (!($event instanceof \core\event\base)) {
238 return false;
239 }
240
241 $event->triggered = true;
242 $event->restored = true;
243
244 foreach (self::$fields as $key) {
245 if (array_key_exists($key, $data)) {
246 $event->data[$key] = $data[$key];
247 } else {
248 debugging("Event restore data must contain key $key");
249 $event->data[$key] = null;
250 }
251 }
252
253 return $event;
254 }
255
256 /**
257 * Returns localised event name.
258 *
259 * Note: override in child class.
260 *
261 * @return string
262 */
263 public function get_name() {
264 return $this->data['eventname'];
265 }
266
267 /**
268 * Returns event context.
269 * @return \context
270 */
271 public function get_context() {
272 if (isset($this->context)) {
273 return $this->context;
274 }
275 $this->context = \context::instance_by_id($this->data['contextid'], false);
276 return $this->context;
277 }
278
279 /**
280 * Returns relevant URL, override in subclasses.
281 */
282 public function get_url() {
283 return null;
284 }
285
286 /**
287 * Return standardised event data as array.
288 *
289 * Useful especially for logging of events.
290 *
291 * @return array
292 */
293 public function get_data() {
294 return $this->data;
295 }
296
297 /**
298 * Does this event replace legacy event?
299 *
300 * @return null|string legacy event name
301 */
302 public function get_legacy_eventname() {
303 return null;
304 }
305
306 /**
307 * Legacy event data if get_legacy_eventname() is not empty.
308 *
309 * @return mixed
310 */
311 public function get_legacy_eventdata() {
312 return null;
313 }
314
315 /**
316 * Doest this event replace add_to_log() statement?
317 *
318 * @return null|array of parameters to be passed to legacy add_to_log() function.
319 */
320 public function get_legacy_logdata() {
321 return null;
322 }
323
324 /**
325 * Validate all properties right before triggering the event.
326 *
327 * This throws coding exceptions for fatal problems and debugging for minor problems.
328 *
329 * @throws \coding_exception
330 */
331 protected final function validate_before_trigger() {
332 if (empty($this->data['crud'])) {
333 throw new \coding_exception('crud must be specified in init() method of each method');
334 }
335 if (empty($this->data['level'])) {
336 throw new \coding_exception('level must be specified in init() method of each method');
337 }
338
339 if (debugging('', DEBUG_DEVELOPER)) {
340 if (!in_array($this->data['crud'], array('c', 'r', 'u', 'd'), true)) {
341 debugging("Invalid event crud value specified.", DEBUG_DEVELOPER);
342 }
343 // Ideally these should be coding exceptions, but we need to skip these for performance reasons
344 // on production servers.
345 if (self::$fields !== array_keys($this->data)) {
346 debugging('Number of event data fields must not be changed in event classes', DEBUG_DEVELOPER);
347 }
348
349 $encoded = json_encode($this->data['extra']);
350 if ($encoded === false or $this->data['extra'] !== json_decode($encoded, true)) {
351 debugging('Extra event data must be compatible with json encoding', DEBUG_DEVELOPER);
352 }
353 }
354 }
355
356 /**
357 * Trigger event.
358 */
359 public final function trigger() {
360 global $CFG;
361
362 if ($this->restored) {
363 throw new \coding_exception('Can not trigger restored event');
364 }
365 if ($this->triggered) {
366 throw new \coding_exception('Can not trigger event twice');
367 }
368
369 $this->triggered = true;
370
371 $this->validate_before_trigger();
372
373 if (!empty($CFG->loglifetime)) {
374 if ($data = $this->get_legacy_logdata()) {
375 call_user_func_array('add_to_log', $data);
376 }
377 }
378
379 \core\event\manager::dispatch($this);
380
381 if ($legacyeventname = $this->get_legacy_eventname()) {
382 events_trigger($legacyeventname, $this->get_legacy_eventdata());
383 }
384 }
385
386 /**
387 * Was this event already triggered.
388 *
389 * Note: restored events are considered to be triggered too.
390 *
391 * @return bool
392 */
393 public function is_triggered() {
394 return $this->triggered;
395 }
396
397 /**
398 * Was this evetn restored?
399 *
400 * @return bool
401 */
402 public function is_restored() {
403 return $this->restored;
404 }
405
406 /**
407 * Add cached data that will be most probably used in event observers.
408 *
409 * This is used to improve performance, but it is required for data
410 * thar was just deleted.
411 *
412 * @param string $tablename
413 * @param \stdClass $record
414 */
415 public function add_cached_record($tablename, $record) {
416 global $DB;
417
418 // NOTE: this might use some kind of MUC cache,
419 // hopefully we will not run out of memory here...
420 if (debugging('', DEBUG_DEVELOPER)) {
421 if (!$DB->get_manager()->table_exists($tablename)) {
422 debugging("Invalid table name '$tablename' specified, database table does not exist.");
423 }
424 }
425 $this->cachedrecords[$tablename][$record->id] = $record;
426 }
427
428 /**
429 * Returns cached record or fetches data from database if not cached.
430 *
431 * @param string $tablename
432 * @param int $id
433 * @return \stdClass
434 */
435 public function get_cached_record($tablename, $id) {
436 global $DB;
437
438 if (isset($this->cachedrecords[$tablename][$id])) {
439 return $this->cachedrecords[$tablename][$id];
440 }
441
442 $record = $DB->get_record($tablename, array('id'=>$id));
443 $this->cachedrecords[$tablename][$id] = $record;
444
445 return $record;
446 }
447
448 /**
449 * Magic getter for read only access.
450 *
451 * Note: we must not allow modification of data from outside,
452 * after trigger() the data MUST NOT CHANGE!!!
453 *
454 * @param string $name
455 * @return mixed
456 *
457 * @throws \coding_exception
458 */
459 public function __get($name) {
460 if (array_key_exists($name, $this->data)) {
461 return $this->data[$name];
462 }
463
464 throw new \coding_exception("Accessing non-existent property $name from event class");
465 }
466}