MDL-57023 tool_usertours: Ensure that the tour key is unique
[moodle.git] / admin / tool / usertours / classes / tour.php
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/>.
17 /**
18  * Tour class.
19  *
20  * @package    tool_usertours
21  * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 namespace tool_usertours;
27 defined('MOODLE_INTERNAL') || die();
29 /**
30  * Tour class.
31  *
32  * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
33  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
34  */
35 class tour {
37     /**
38      * The tour is currently disabled
39      *
40      * @var DISABLED
41      */
42     const DISABLED = 0;
44     /**
45      * The tour is currently disabled
46      *
47      * @var DISABLED
48      */
49     const ENABLED = 1;
51     /**
52      * The user preference value to indicate the time of completion of the tour for a user.
53      *
54      * @var TOUR_LAST_COMPLETED_BY_USER
55      */
56     const TOUR_LAST_COMPLETED_BY_USER   = 'tool_usertours_tour_completion_time_';
58     /**
59      * The user preference value to indicate the time that a user last requested to see the tour.
60      *
61      * @var TOUR_REQUESTED_BY_USER
62      */
63     const TOUR_REQUESTED_BY_USER        = 'tool_usertours_tour_reset_time_';
65     /**
66      * @var $id The tour ID.
67      */
68     protected $id;
70     /**
71      * @var $name The tour name.
72      */
73     protected $name;
75     /**
76      * @var $description The tour description.
77      */
78     protected $description;
80     /**
81      * @var $pathmatch The tour pathmatch.
82      */
83     protected $pathmatch;
85     /**
86      * @var $enabled The tour enabled state.
87      */
88     protected $enabled;
90     /**
91      * @var $sortorder The sort order.
92      */
93     protected $sortorder;
95     /**
96      * @var $dirty Whether the current view of the tour has been modified.
97      */
98     protected $dirty = false;
100     /**
101      * @var $config The configuration object for the tour.
102      */
103     protected $config;
105     /**
106      * @var $filtervalues The filter configuration object for the tour.
107      */
108     protected $filtervalues;
110     /**
111      * @var $steps  The steps in this tour.
112      */
113     protected $steps = [];
115     /**
116      * Create an instance of the specified tour.
117      *
118      * @param   int         $id         The ID of the tour to load.
119      * @return  tour
120      */
121     public static function instance($id) {
122         $tour = new self();
123         return $tour->fetch($id);
124     }
126     /**
127      * Create an instance of tour from its provided DB record.
128      *
129      * @param   stdClass    $record     The record of the tour to load.
130      * @param   boolean     $clean      Clean the values.
131      * @return  tour
132      */
133     public static function load_from_record($record, $clean = false) {
134         $tour = new self();
135         return $tour->reload_from_record($record, $clean);
136     }
138     /**
139      * Fetch the specified tour into the current object.
140      *
141      * @param   int         $id         The ID of the tour to fetch.
142      * @return  tour
143      */
144     protected function fetch($id) {
145         global $DB;
147         return $this->reload_from_record(
148             $DB->get_record('tool_usertours_tours', array('id' => $id), '*', MUST_EXIST)
149         );
150     }
152     /**
153      * Reload the current tour from database.
154      *
155      * @return  tour
156      */
157     protected function reload() {
158         return $this->fetch($this->id);
159     }
161     /**
162      * Reload the tour into the current object.
163      *
164      * @param   stdClass    $record     The record to reload.
165      * @param   boolean     $clean      Clean the values.
166      * @return  tour
167      */
168     protected function reload_from_record($record, $clean = false) {
169         $this->id           = $record->id;
170         if (!property_exists($record, 'description')) {
171             if (property_exists($record, 'comment')) {
172                 $record->description = $record->comment;
173                 unset($record->comment);
174             }
175         }
176         if ($clean) {
177             $this->name         = clean_param($record->name, PARAM_TEXT);
178             $this->description  = clean_text($record->description);
179         } else {
180             $this->name         = $record->name;
181             $this->description  = $record->description;
182         }
183         $this->pathmatch    = $record->pathmatch;
184         $this->enabled      = $record->enabled;
185         if (isset($record->sortorder)) {
186             $this->sortorder = $record->sortorder;
187         }
188         $this->config       = json_decode($record->configdata);
189         $this->dirty        = false;
190         $this->steps        = [];
192         return $this;
193     }
195     /**
196      * Fetch all steps in the tour.
197      *
198      * @return  stdClass[]
199      */
200     public function get_steps() {
201         if (empty($this->steps)) {
202             $this->steps = helper::get_steps($this->id);
203         }
205         return $this->steps;
206     }
208     /**
209      * Count the number of steps in the tour.
210      *
211      * @return  int
212      */
213     public function count_steps() {
214         return count($this->get_steps());
215     }
217     /**
218      * The ID of the tour.
219      *
220      * @return  int
221      */
222     public function get_id() {
223         return $this->id;
224     }
226     /**
227      * The name of the tour.
228      *
229      * @return  string
230      */
231     public function get_name() {
232         return $this->name;
233     }
235     /**
236      * Set the name of the tour to the specified value.
237      *
238      * @param   string      $value      The new name.
239      * @return  $this
240      */
241     public function set_name($value) {
242         $this->name = clean_param($value, PARAM_TEXT);
243         $this->dirty = true;
245         return $this;
246     }
248     /**
249      * The description associated with the tour.
250      *
251      * @return  string
252      */
253     public function get_description() {
254         return $this->description;
255     }
257     /**
258      * Set the description of the tour to the specified value.
259      *
260      * @param   string      $value      The new description.
261      * @return  $this
262      */
263     public function set_description($value) {
264         $this->description = clean_text($value);
265         $this->dirty = true;
267         return $this;
268     }
270     /**
271      * The path match for the tour.
272      *
273      * @return  string
274      */
275     public function get_pathmatch() {
276         return $this->pathmatch;
277     }
279     /**
280      * Set the patchmatch of the tour to the specified value.
281      *
282      * @param   string      $value      The new patchmatch.
283      * @return  $this
284      */
285     public function set_pathmatch($value) {
286         $this->pathmatch = $value;
287         $this->dirty = true;
289         return $this;
290     }
292     /**
293      * The enabled state of the tour.
294      *
295      * @return  int
296      */
297     public function get_enabled() {
298         return $this->enabled;
299     }
301     /**
302      * Whether the tour is currently enabled.
303      *
304      * @return  boolean
305      */
306     public function is_enabled() {
307         return ($this->enabled == self::ENABLED);
308     }
310     /**
311      * Set the enabled state of the tour to the specified value.
312      *
313      * @param   boolean     $value      The new state.
314      * @return  $this
315      */
316     public function set_enabled($value) {
317         $this->enabled = $value;
318         $this->dirty = true;
320         return $this;
321     }
323     /**
324      * The link to view this tour.
325      *
326      * @return  moodle_url
327      */
328     public function get_view_link() {
329         return helper::get_view_tour_link($this->id);
330     }
332     /**
333      * The link to edit this tour.
334      *
335      * @return  moodle_url
336      */
337     public function get_edit_link() {
338         return helper::get_edit_tour_link($this->id);
339     }
341     /**
342      * The link to reset the state of this tour for all users.
343      *
344      * @return  moodle_url
345      */
346     public function get_reset_link() {
347         return helper::get_reset_tour_for_all_link($this->id);
348     }
350     /**
351      * The link to export this tour.
352      *
353      * @return  moodle_url
354      */
355     public function get_export_link() {
356         return helper::get_export_tour_link($this->id);
357     }
359     /**
360      * The link to remove this tour.
361      *
362      * @return  moodle_url
363      */
364     public function get_delete_link() {
365         return helper::get_delete_tour_link($this->id);
366     }
368     /**
369      * Prepare this tour for saving to the database.
370      *
371      * @return  object
372      */
373     public function to_record() {
374         return (object) array(
375             'id'            => $this->id,
376             'name'          => $this->name,
377             'description'   => $this->description,
378             'pathmatch'     => $this->pathmatch,
379             'enabled'       => $this->enabled,
380             'sortorder'     => $this->sortorder,
381             'configdata'    => json_encode($this->config),
382         );
383     }
385     /**
386      * Get the current sortorder for this tour.
387      *
388      * @return  int
389      */
390     public function get_sortorder() {
391         return (int) $this->sortorder;
392     }
394     /**
395      * Whether this tour is the first tour.
396      *
397      * @return  boolean
398      */
399     public function is_first_tour() {
400         return ($this->get_sortorder() === 0);
401     }
403     /**
404      * Whether this tour is the last tour.
405      *
406      * @param   int         $tourcount  The pre-fetched count of tours
407      * @return  boolean
408      */
409     public function is_last_tour($tourcount = null) {
410         if ($tourcount === null) {
411             $tourcount = helper::count_tours();
412         }
413         return ($this->get_sortorder() === ($tourcount - 1));
414     }
416     /**
417      * Set the sortorder for this tour.
418      *
419      * @param   int         $value      The new sortorder to use.
420      * @return  $this
421      */
422     public function set_sortorder($value) {
423         $this->sortorder = $value;
424         $this->dirty = true;
426         return $this;
427     }
429     /**
430      * Calculate the next sort-order value.
431      *
432      * @return  int
433      */
434     protected function calculate_sortorder() {
435         $this->sortorder = helper::count_tours();
437         return $this;
438     }
440     /**
441      * Get the link to move this tour up in the sortorder.
442      *
443      * @return  moodle_url
444      */
445     public function get_moveup_link() {
446         return helper::get_move_tour_link($this->get_id(), helper::MOVE_UP);
447     }
449     /**
450      * Get the link to move this tour down in the sortorder.
451      *
452      * @return  moodle_url
453      */
454     public function get_movedown_link() {
455         return helper::get_move_tour_link($this->get_id(), helper::MOVE_DOWN);
456     }
458     /**
459      * Get the value of the specified configuration item.
460      *
461      * @param   string      $key        The configuration key to set.
462      * @param   mixed       $default    The default value to use if a value was not found.
463      * @return  mixed
464      */
465     public function get_config($key = null, $default = null) {
466         if ($this->config === null) {
467             $this->config = (object) array();
468         }
469         if ($key === null) {
470             return $this->config;
471         }
473         if (property_exists($this->config, $key)) {
474             return $this->config->$key;
475         }
477         if ($default !== null) {
478             return $default;
479         }
481         return configuration::get_default_value($key);
482     }
484     /**
485      * Set the configuration item as specified.
486      *
487      * @param   string      $key        The configuration key to set.
488      * @param   mixed       $value      The new value for the configuration item.
489      * @return  $this
490      */
491     public function set_config($key, $value) {
492         if ($this->config === null) {
493             $this->config = (object) array();
494         }
495         $this->config->$key = $value;
496         $this->dirty = true;
498         return $this;
499     }
501     /**
502      * Save the tour and it's configuration to the database.
503      *
504      * @param   boolean     $force      Whether to force writing to the database.
505      * @return  $this
506      */
507     public function persist($force = false) {
508         global $DB;
510         if (!$this->dirty && !$force) {
511             return $this;
512         }
514         if ($this->id) {
515             $record = $this->to_record();
516             $DB->update_record('tool_usertours_tours', $record);
517         } else {
518             $this->calculate_sortorder();
519             $record = $this->to_record();
520             unset($record->id);
521             $this->id = $DB->insert_record('tool_usertours_tours', $record);
522         }
524         $this->reload();
526         // Notify the cache that a tour has changed.
527         cache::notify_tour_change();
529         return $this;
530     }
532     /**
533      * Remove this step.
534      */
535     public function remove() {
536         global $DB;
538         if ($this->id === null) {
539             // Nothing to delete - this tour has not been persisted.
540             return null;
541         }
543         // Delete all steps associated with this tour.
544         // Note, although they are currently just DB records, there may be other components in the future.
545         foreach ($this->get_steps() as $step) {
546             $step->remove();
547         }
549         // Remove the configuration for the tour.
550         $DB->delete_records('tool_usertours_tours', array('id' => $this->id));
552         helper::reset_tour_sortorder();
554         return null;
555     }
557     /**
558      * Reset the sortorder for all steps in the tour.
559      *
560      * @return  $this
561      */
562     public function reset_step_sortorder() {
563         global $DB;
564         $steps = $DB->get_records('tool_usertours_steps', array('tourid' => $this->id), 'sortorder ASC', 'id');
566         $index = 0;
567         foreach ($steps as $step) {
568             $DB->set_field('tool_usertours_steps', 'sortorder', $index, array('id' => $step->id));
569             $index++;
570         }
572         // Notify of a change to the step configuration.
573         // Note: Do not notify of a tour change here. This is only a step change for a tour.
574         cache::notify_step_change($this->get_id());
576         return $this;
577     }
579     /**
580      * Whether this tour should be displayed to the user.
581      *
582      * @return  boolean
583      */
584     public function should_show_for_user() {
585         if (!$this->is_enabled()) {
586             // The tour is disabled - it should not be shown.
587             return false;
588         }
590         if ($tourcompletiondate = get_user_preferences(self::TOUR_LAST_COMPLETED_BY_USER . $this->get_id(), null)) {
591             if ($tourresetdate = get_user_preferences(self::TOUR_REQUESTED_BY_USER . $this->get_id(), null)) {
592                 if ($tourresetdate >= $tourcompletiondate) {
593                     return true;
594                 }
595             }
596             $lastmajorupdate = $this->get_config('majorupdatetime', time());
597             if ($tourcompletiondate > $lastmajorupdate) {
598                 // The user has completed the tour since the last major update.
599                 return false;
600             }
601         }
603         return true;
604     }
606     /**
607      * Get the key for this tour.
608      * This is used in the session cookie to determine whether the user has seen this tour before.
609      */
610     public function get_tour_key() {
611         global $USER;
613         $tourtime = $this->get_config('majorupdatetime', null);
615         if ($tourtime === null) {
616             // This tour has no majorupdate time.
617             // Set one now to prevent repeated displays to the user.
618             $this->set_config('majorupdatetime', time());
619             $this->persist();
620             $tourtime = $this->get_config('majorupdatetime', null);
621         }
623         if ($userresetdate = get_user_preferences(self::TOUR_REQUESTED_BY_USER . $this->get_id(), null)) {
624             $tourtime = max($tourtime, $userresetdate);
625         }
627         return sprintf('tool_usertours_%d_%d_%s', $USER->id, $this->get_id(), $tourtime);
628     }
630     /**
631      * Reset the requested by user date.
632      *
633      * @return  $this
634      */
635     public function request_user_reset() {
636         set_user_preference(self::TOUR_REQUESTED_BY_USER . $this->get_id(), time());
638         return $this;
639     }
641     /**
642      * Mark this tour as completed for this user.
643      *
644      * @return  $this
645      */
646     public function mark_user_completed() {
647         set_user_preference(self::TOUR_LAST_COMPLETED_BY_USER . $this->get_id(), time());
649         return $this;
650     }
652     /**
653      * Update a tour giving it a new major update time.
654      * This will ensure that it is displayed to all users, even those who have already seen it.
655      *
656      * @return  $this
657      */
658     public function mark_major_change() {
659         global $DB;
661         // Clear old reset and completion notes.
662         $DB->delete_records('user_preferences', ['name' => self::TOUR_LAST_COMPLETED_BY_USER . $this->get_id()]);
663         $DB->delete_records('user_preferences', ['name' => self::TOUR_REQUESTED_BY_USER . $this->get_id()]);
664         $this->set_config('majorupdatetime', time());
665         $this->persist();
667         return $this;
668     }
670     /**
671      * Add the step configuration to the form.
672      *
673      * @param   MoodleQuickForm $mform      The form to add configuration to.
674      * @return  $this
675      */
676     public function add_config_to_form(\MoodleQuickForm &$mform) {
677         $options = configuration::get_placement_options();
678         $mform->addElement('select', 'placement', get_string('placement', 'tool_usertours'), $options);
679         $mform->addHelpButton('placement', 'placement', 'tool_usertours');
681         $this->add_config_field_to_form($mform, 'orphan');
682         $this->add_config_field_to_form($mform, 'backdrop');
683         $this->add_config_field_to_form($mform, 'reflex');
685         return $this;
686     }
688     /**
689      * Add the specified step field configuration to the form.
690      *
691      * @param   MoodleQuickForm $mform      The form to add configuration to.
692      * @param   string          $key        The key to add.
693      * @return  $this
694      */
695     protected function add_config_field_to_form(\MoodleQuickForm &$mform, $key) {
696         $options = [
697             true    => get_string('yes'),
698             false   => get_string('no'),
699         ];
700         $mform->addElement('select', $key, get_string($key, 'tool_usertours'), $options);
701         $mform->setDefault($key, configuration::get_default_value($key));
702         $mform->addHelpButton($key, $key, 'tool_usertours');
704         return $this;
705     }
707     /**
708      * Prepare the configuration data for the moodle form.
709      *
710      * @return  object
711      */
712     public function prepare_data_for_form() {
713         $data = $this->to_record();
714         foreach (configuration::get_defaultable_keys() as $key) {
715             $data->$key = $this->get_config($key, configuration::get_default_value($key));
716         }
718         return $data;
719     }
721     /**
722      * Get the configured filter values.
723      *
724      * @param   string      $filter     The filter to retrieve values for.
725      * @return  array
726      */
727     public function get_filter_values($filter) {
728         if ($allvalues = (array) $this->get_config('filtervalues')) {
729             if (isset($allvalues[$filter])) {
730                 return $allvalues[$filter];
731             }
732         }
734         return [];
735     }
737     /**
738      * Set the values for the specified filter.
739      *
740      * @param   string      $filter     The filter to set.
741      * @param   array       $values     The values to set.
742      * @return  $this
743      */
744     public function set_filter_values($filter, array $values = []) {
745         $allvalues = (array) $this->get_config('filtervalues', []);
746         $allvalues[$filter] = $values;
748         return $this->set_config('filtervalues', $allvalues);
749     }
751     /**
752      * Check whether this tour matches all filters.
753      *
754      * @param   context     $context    The context to check
755      * @return  bool
756      */
757     public function matches_all_filters(\context $context) {
758         $filters = helper::get_all_filters();
760         // All filters must match.
761         // If any one filter fails to match, we return false.
762         foreach ($filters as $filterclass) {
763             if (!$filterclass::filter_matches($this, $context)) {
764                 return false;
765             }
766         }
768         return true;
769     }