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