MDL-58523 mod_feedback: deleting response should reset completion
[moodle.git] / mod / feedback / classes / completion.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  * Contains class mod_feedback_completion
19  *
20  * @package   mod_feedback
21  * @copyright 2016 Marina Glancy
22  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 defined('MOODLE_INTERNAL') || die();
27 /**
28  * Collects information and methods about feedback completion (either complete.php or show_entries.php)
29  *
30  * @package   mod_feedback
31  * @copyright 2016 Marina Glancy
32  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
33  */
34 class mod_feedback_completion extends mod_feedback_structure {
35     /** @var stdClass */
36     protected $completed;
37     /** @var stdClass */
38     protected $completedtmp = null;
39     /** @var stdClass[] */
40     protected $valuestmp = null;
41     /** @var stdClass[] */
42     protected $values = null;
43     /** @var bool */
44     protected $iscompleted = false;
45     /** @var mod_feedback_complete_form the form used for completing the feedback */
46     protected $form = null;
47     /** @var bool true when the feedback has been completed during the request */
48     protected $justcompleted = false;
49     /** @var int the next page the user should jump after processing the form */
50     protected $jumpto = null;
53     /**
54      * Constructor
55      *
56      * @param stdClass $feedback feedback object
57      * @param cm_info $cm course module object corresponding to the $feedback
58      *     (at least one of $feedback or $cm is required)
59      * @param int $courseid current course (for site feedbacks only)
60      * @param bool $iscompleted has feedback been already completed? If yes either completedid or userid must be specified.
61      * @param int $completedid id in the table feedback_completed, may be omitted if userid is specified
62      *     but it is highly recommended because the same user may have multiple responses to the same feedback
63      *     for different courses
64      * @param int $userid id of the user - if specified only non-anonymous replies will be returned. If not
65      *     specified only anonymous replies will be returned and the $completedid is mandatory.
66      */
67     public function __construct($feedback, $cm, $courseid, $iscompleted = false, $completedid = null, $userid = null) {
68         global $DB;
69         parent::__construct($feedback, $cm, $courseid, 0);
70         // Make sure courseid is always set for site feedback.
71         if ($this->feedback->course == SITEID && !$this->courseid) {
72             $this->courseid = SITEID;
73         }
74         if ($iscompleted) {
75             // Retrieve information about the completion.
76             $this->iscompleted = true;
77             $params = array('feedback' => $this->feedback->id);
78             if (!$userid && !$completedid) {
79                 throw new coding_exception('Either $completedid or $userid must be specified for completed feedbacks');
80             }
81             if ($completedid) {
82                 $params['id'] = $completedid;
83             }
84             if ($userid) {
85                 // We must respect the anonymousity of the reply that the user saw when they were completing the feedback,
86                 // not the current state that may have been changed later by the teacher.
87                 $params['anonymous_response'] = FEEDBACK_ANONYMOUS_NO;
88                 $params['userid'] = $userid;
89             }
90             $this->completed = $DB->get_record('feedback_completed', $params, '*', MUST_EXIST);
91             $this->courseid = $this->completed->courseid;
92         }
93     }
95     /**
96      * Returns a record from 'feedback_completed' table
97      * @return stdClass
98      */
99     public function get_completed() {
100         return $this->completed;
101     }
103     /**
104      * Check if the feedback was just completed.
105      *
106      * @return bool true if the feedback was just completed.
107      * @since  Moodle 3.3
108      */
109     public function just_completed() {
110         return $this->justcompleted;
111     }
113     /**
114      * Return the jumpto property.
115      *
116      * @return int the next page to jump.
117      * @since  Moodle 3.3
118      */
119     public function get_jumpto() {
120         return $this->jumpto;
121     }
123     /**
124      * Returns the temporary completion record for the current user or guest session
125      *
126      * @return stdClass|false record from feedback_completedtmp or false if not found
127      */
128     public function get_current_completed_tmp() {
129         global $USER, $DB;
130         if ($this->completedtmp === null) {
131             $params = array('feedback' => $this->get_feedback()->id);
132             if ($courseid = $this->get_courseid()) {
133                 $params['courseid'] = $courseid;
134             }
135             if (isloggedin() && !isguestuser()) {
136                 $params['userid'] = $USER->id;
137             } else {
138                 $params['guestid'] = sesskey();
139             }
140             $this->completedtmp = $DB->get_record('feedback_completedtmp', $params);
141         }
142         return $this->completedtmp;
143     }
145     /**
146      * Can the current user see the item, if dependency is met?
147      *
148      * @param stdClass $item
149      * @return bool whether user can see item or not,
150      *     null if dependency is broken or dependent question is not answered.
151      */
152     protected function can_see_item($item) {
153         if (empty($item->dependitem)) {
154             return true;
155         }
156         if ($this->dependency_has_error($item)) {
157             return null;
158         }
159         $allitems = $this->get_items();
160         $ditem = $allitems[$item->dependitem];
161         $itemobj = feedback_get_item_class($ditem->typ);
162         if ($this->iscompleted) {
163             $value = $this->get_values($ditem);
164         } else {
165             $value = $this->get_values_tmp($ditem);
166         }
167         if ($value === null) {
168             return null;
169         }
170         return $itemobj->compare_value($ditem, $value, $item->dependvalue) ? true : false;
171     }
173     /**
174      * Dependency condition has an error
175      * @param stdClass $item
176      * @return bool
177      */
178     protected function dependency_has_error($item) {
179         if (empty($item->dependitem)) {
180             // No dependency - no error.
181             return false;
182         }
183         $allitems = $this->get_items();
184         if (!array_key_exists($item->dependitem, $allitems)) {
185             // Looks like dependent item has been removed.
186             return true;
187         }
188         $itemids = array_keys($allitems);
189         $index1 = array_search($item->dependitem, $itemids);
190         $index2 = array_search($item->id, $itemids);
191         if ($index1 >= $index2) {
192             // Dependent item is after the current item in the feedback.
193             return true;
194         }
195         for ($i = $index1 + 1; $i < $index2; $i++) {
196             if ($allitems[$itemids[$i]]->typ === 'pagebreak') {
197                 return false;
198             }
199         }
200         // There are no page breaks between dependent items.
201         return true;
202     }
204     /**
205      * Returns a value stored for this item in the feedback (temporary or not, depending on the mode)
206      * @param stdClass $item
207      * @return string
208      */
209     public function get_item_value($item) {
210         if ($this->iscompleted) {
211             return $this->get_values($item);
212         } else {
213             return $this->get_values_tmp($item);
214         }
215     }
217     /**
218      * Retrieves responses from an unfinished attempt.
219      *
220      * @return array the responses (from the feedback_valuetmp table)
221      * @since  Moodle 3.3
222      */
223     public function get_unfinished_responses() {
224         global $DB;
225         $responses = array();
227         $completedtmp = $this->get_current_completed_tmp();
228         if ($completedtmp) {
229             $responses = $DB->get_records('feedback_valuetmp', ['completed' => $completedtmp->id]);
230         }
231         return $responses;
232     }
234     /**
235      * Returns all temporary values for this feedback or just a value for an item
236      * @param stdClass $item
237      * @return array
238      */
239     protected function get_values_tmp($item = null) {
240         global $DB;
241         if ($this->valuestmp === null) {
242             $this->valuestmp = array();
243             $responses = $this->get_unfinished_responses();
244             foreach ($responses as $r) {
245                 $this->valuestmp[$r->item] = $r->value;
246             }
247         }
248         if ($item) {
249             return array_key_exists($item->id, $this->valuestmp) ? $this->valuestmp[$item->id] : null;
250         }
251         return $this->valuestmp;
252     }
254     /**
255      * Retrieves responses from an finished attempt.
256      *
257      * @return array the responses (from the feedback_value table)
258      * @since  Moodle 3.3
259      */
260     public function get_finished_responses() {
261         global $DB;
262         $responses = array();
264         if ($this->completed) {
265             $responses = $DB->get_records('feedback_value', ['completed' => $this->completed->id]);
266         }
267         return $responses;
268     }
270     /**
271      * Returns all completed values for this feedback or just a value for an item
272      * @param stdClass $item
273      * @return array
274      */
275     protected function get_values($item = null) {
276         global $DB;
277         if ($this->values === null) {
278             $this->values = array();
279             $responses = $this->get_finished_responses();
280             foreach ($responses as $r) {
281                 $this->values[$r->item] = $r->value;
282             }
283         }
284         if ($item) {
285             return array_key_exists($item->id, $this->values) ? $this->values[$item->id] : null;
286         }
287         return $this->values;
288     }
290     /**
291      * Splits the feedback items into pages
292      *
293      * Items that we definitely know at this stage as not applicable are excluded.
294      * Items that are dependent on something that has not yet been answered are
295      * still present, as well as items with broken dependencies.
296      *
297      * @return array array of arrays of items
298      */
299     public function get_pages() {
300         $pages = [[]]; // The first page always exists.
301         $items = $this->get_items();
302         foreach ($items as $item) {
303             if ($item->typ === 'pagebreak') {
304                 $pages[] = [];
305             } else if ($this->can_see_item($item) !== false) {
306                 $pages[count($pages) - 1][] = $item;
307             }
308         }
309         return $pages;
310     }
312     /**
313      * Returns the last page that has items with the value (i.e. not label) which have been answered
314      * as well as the first page that has items with the values that have not been answered.
315      *
316      * Either of the two return values may be null if there are no answered page or there are no
317      * unanswered pages left respectively.
318      *
319      * Two pages may not be directly following each other because there may be empty pages
320      * or pages with information texts only between them
321      *
322      * @return array array of two elements [$lastcompleted, $firstincompleted]
323      */
324     protected function get_last_completed_page() {
325         $completed = [];
326         $incompleted = [];
327         $pages = $this->get_pages();
328         foreach ($pages as $pageidx => $pageitems) {
329             foreach ($pageitems as $item) {
330                 if ($item->hasvalue) {
331                     if ($this->get_values_tmp($item) !== null) {
332                         $completed[$pageidx] = true;
333                     } else {
334                         $incompleted[$pageidx] = true;
335                     }
336                 }
337             }
338         }
339         $completed = array_keys($completed);
340         $incompleted = array_keys($incompleted);
341         // If some page has both completed and incompleted items it is considered incompleted.
342         $completed = array_diff($completed, $incompleted);
343         // If the completed page follows an incompleted page, it does not count.
344         $firstincompleted = $incompleted ? min($incompleted) : null;
345         if ($firstincompleted !== null) {
346             $completed = array_filter($completed, function($a) use ($firstincompleted) {
347                 return $a < $firstincompleted;
348             });
349         }
350         $lastcompleted = $completed ? max($completed) : null;
351         return [$lastcompleted, $firstincompleted];
352     }
354     /**
355      * Get the next page for the feedback
356      *
357      * This is normally $gopage+1 but may be bigger if there are empty pages or
358      * pages without visible questions.
359      *
360      * This method can only be called when questions on the current page are
361      * already answered, otherwise it may be inaccurate.
362      *
363      * @param int $gopage current page
364      * @param bool $strictcheck when gopage is the user-input value, make sure we do not jump over unanswered questions
365      * @return int|null the index of the next page or null if this is the last page
366      */
367     public function get_next_page($gopage, $strictcheck = true) {
368         if ($strictcheck) {
369             list($lastcompleted, $firstincompleted) = $this->get_last_completed_page();
370             if ($firstincompleted !== null && $firstincompleted <= $gopage) {
371                 return $firstincompleted;
372             }
373         }
374         $pages = $this->get_pages();
375         for ($pageidx = $gopage + 1; $pageidx < count($pages); $pageidx++) {
376             if (!empty($pages[$pageidx])) {
377                 return $pageidx;
378             }
379         }
380         // No further pages in the feedback have any visible items.
381         return null;
382     }
384     /**
385      * Get the previous page for the feedback
386      *
387      * This is normally $gopage-1 but may be smaller if there are empty pages or
388      * pages without visible questions.
389      *
390      * @param int $gopage current page
391      * @param bool $strictcheck when gopage is the user-input value, make sure we do not jump over unanswered questions
392      * @return int|null the index of the next page or null if this is the first page with items
393      */
394     public function get_previous_page($gopage, $strictcheck = true) {
395         if (!$gopage) {
396             // If we are already on the first (0) page, there is definitely no previous page.
397             return null;
398         }
399         $pages = $this->get_pages();
400         $rv = null;
401         // Iterate through previous pages and find the closest one that has any items on it.
402         for ($pageidx = $gopage - 1; $pageidx >= 0; $pageidx--) {
403             if (!empty($pages[$pageidx])) {
404                 $rv = $pageidx;
405                 break;
406             }
407         }
408         if ($rv === null) {
409             // We are on the very first page that has items.
410             return null;
411         }
412         if ($rv > 0 && $strictcheck) {
413             // Check if this page is actually not past than first incompleted page.
414             list($lastcompleted, $firstincompleted) = $this->get_last_completed_page();
415             if ($firstincompleted !== null && $firstincompleted < $rv) {
416                 return $firstincompleted;
417             }
418         }
419         return $rv;
420     }
422     /**
423      * Page index to resume the feedback
424      *
425      * When user abandones answering feedback and then comes back to it we should send him
426      * to the first page after the last page he fully completed.
427      * @return int
428      */
429     public function get_resume_page() {
430         list($lastcompleted, $firstincompleted) = $this->get_last_completed_page();
431         return $lastcompleted === null ? 0 : $this->get_next_page($lastcompleted, false);
432     }
434     /**
435      * Creates a new record in the 'feedback_completedtmp' table for the current user/guest session
436      *
437      * @return stdClass record from feedback_completedtmp or false if not found
438      */
439     protected function create_current_completed_tmp() {
440         global $USER, $DB;
441         $record = (object)['feedback' => $this->feedback->id];
442         if ($this->get_courseid()) {
443             $record->courseid = $this->get_courseid();
444         }
445         if (isloggedin() && !isguestuser()) {
446             $record->userid = $USER->id;
447         } else {
448             $record->guestid = sesskey();
449         }
450         $record->timemodified = time();
451         $record->anonymous_response = $this->feedback->anonymous;
452         $id = $DB->insert_record('feedback_completedtmp', $record);
453         $this->completedtmp = $DB->get_record('feedback_completedtmp', ['id' => $id]);
454         $this->valuestmp = null;
455         return $this->completedtmp;
456     }
458     /**
459      * If user has already completed the feedback, create the temproray values from last completed attempt
460      *
461      * @return stdClass record from feedback_completedtmp or false if not found
462      */
463     public function create_completed_tmp_from_last_completed() {
464         if (!$this->get_current_completed_tmp()) {
465             $lastcompleted = $this->find_last_completed();
466             if ($lastcompleted) {
467                 $this->completedtmp = feedback_set_tmp_values($lastcompleted);
468             }
469         }
470         return $this->completedtmp;
471     }
473     /**
474      * Saves unfinished response to the temporary table
475      *
476      * This is called when user proceeds to the next/previous page in the complete form
477      * and also right after the form submit.
478      * After the form submit the {@link save_response()} is called to
479      * move response from temporary table to completion table.
480      *
481      * @param stdClass $data data from the form mod_feedback_complete_form
482      */
483     public function save_response_tmp($data) {
484         global $DB;
485         if (!$completedtmp = $this->get_current_completed_tmp()) {
486             $completedtmp = $this->create_current_completed_tmp();
487         } else {
488             $currentime = time();
489             $DB->update_record('feedback_completedtmp',
490                     ['id' => $completedtmp->id, 'timemodified' => $currentime]);
491             $completedtmp->timemodified = $currentime;
492         }
494         // Find all existing values.
495         $existingvalues = $DB->get_records_menu('feedback_valuetmp',
496                 ['completed' => $completedtmp->id], '', 'item, id');
498         // Loop through all feedback items and save the ones that are present in $data.
499         $allitems = $this->get_items();
500         foreach ($allitems as $item) {
501             if (!$item->hasvalue) {
502                 continue;
503             }
504             $keyname = $item->typ . '_' . $item->id;
505             if (!isset($data->$keyname)) {
506                 // This item is either on another page or dependency was not met - nothing to save.
507                 continue;
508             }
510             $newvalue = ['item' => $item->id, 'completed' => $completedtmp->id, 'course_id' => $completedtmp->courseid];
512             // Convert the value to string that can be stored in 'feedback_valuetmp' or 'feedback_value'.
513             $itemobj = feedback_get_item_class($item->typ);
514             $newvalue['value'] = $itemobj->create_value($data->$keyname);
516             // Update or insert the value in the 'feedback_valuetmp' table.
517             if (array_key_exists($item->id, $existingvalues)) {
518                 $newvalue['id'] = $existingvalues[$item->id];
519                 $DB->update_record('feedback_valuetmp', $newvalue);
520             } else {
521                 $DB->insert_record('feedback_valuetmp', $newvalue);
522             }
523         }
525         // Reset valuestmp cache.
526         $this->valuestmp = null;
527     }
529     /**
530      * Saves the response
531      *
532      * The form data has already been stored in the temporary table in
533      * {@link save_response_tmp()}. This function copies the values
534      * from the temporary table to the completion table.
535      * It is also responsible for sending email notifications when applicable.
536      */
537     public function save_response() {
538         global $USER, $SESSION, $DB;
540         $feedbackcompleted = $this->find_last_completed();
541         $feedbackcompletedtmp = $this->get_current_completed_tmp();
543         if (feedback_check_is_switchrole()) {
544             // We do not actually save anything if the role is switched, just delete temporary values.
545             $this->delete_completedtmp();
546             return;
547         }
549         // Save values.
550         $completedid = feedback_save_tmp_values($feedbackcompletedtmp, $feedbackcompleted);
551         $this->completed = $DB->get_record('feedback_completed', array('id' => $completedid));
553         // Send email.
554         if ($this->feedback->anonymous == FEEDBACK_ANONYMOUS_NO) {
555             feedback_send_email($this->cm, $this->feedback, $this->cm->get_course(), $USER, $this->completed);
556         } else {
557             feedback_send_email_anonym($this->cm, $this->feedback, $this->cm->get_course());
558         }
560         unset($SESSION->feedback->is_started);
562         // Update completion state.
563         $completion = new completion_info($this->cm->get_course());
564         if (isloggedin() && !isguestuser() && $completion->is_enabled($this->cm) &&
565                 $this->cm->completion == COMPLETION_TRACKING_AUTOMATIC && $this->feedback->completionsubmit) {
566             $completion->update_state($this->cm, COMPLETION_COMPLETE);
567         }
568     }
570     /**
571      * Deletes the temporary completed and all related temporary values
572      */
573     protected function delete_completedtmp() {
574         global $DB;
576         if ($completedtmp = $this->get_current_completed_tmp()) {
577             $DB->delete_records('feedback_valuetmp', ['completed' => $completedtmp->id]);
578             $DB->delete_records('feedback_completedtmp', ['id' => $completedtmp->id]);
579             $this->completedtmp = null;
580         }
581     }
583     /**
584      * Retrieves the last completion record for the current user
585      *
586      * @return stdClass record from feedback_completed or false if not found
587      */
588     public function find_last_completed() {
589         global $USER, $DB;
590         if (!isloggedin() || isguestuser()) {
591             // Not possible to retrieve completed feedback for guests.
592             return false;
593         }
594         if ($this->is_anonymous()) {
595             // Not possible to retrieve completed anonymous feedback.
596             return false;
597         }
598         $params = array('feedback' => $this->feedback->id, 'userid' => $USER->id, 'anonymous_response' => FEEDBACK_ANONYMOUS_NO);
599         if ($this->get_courseid()) {
600             $params['courseid'] = $this->get_courseid();
601         }
602         $this->completed = $DB->get_record('feedback_completed', $params);
603         return $this->completed;
604     }
606     /**
607      * Checks if current user has capability to submit the feedback
608      *
609      * There is an exception for fully anonymous feedbacks when guests can complete
610      * feedback without the proper capability.
611      *
612      * This should be followed by checking {@link can_submit()} because even if
613      * user has capablity to complete, they may have already submitted feedback
614      * and can not re-submit
615      *
616      * @return bool
617      */
618     public function can_complete() {
619         global $CFG;
621         $context = context_module::instance($this->cm->id);
622         if (has_capability('mod/feedback:complete', $context)) {
623             return true;
624         }
626         if (!empty($CFG->feedback_allowfullanonymous)
627                     AND $this->feedback->course == SITEID
628                     AND $this->feedback->anonymous == FEEDBACK_ANONYMOUS_YES
629                     AND (!isloggedin() OR isguestuser())) {
630             // Guests are allowed to complete fully anonymous feedback without having 'mod/feedback:complete' capability.
631             return true;
632         }
634         return false;
635     }
637     /**
638      * Checks if user is prevented from re-submission.
639      *
640      * This must be called after {@link can_complete()}
641      *
642      * @return bool
643      */
644     public function can_submit() {
645         if ($this->get_feedback()->multiple_submit == 0 ) {
646             if ($this->is_already_submitted()) {
647                 return false;
648             }
649         }
650         return true;
651     }
653     /**
654      * Trigger module viewed event.
655      *
656      * @since Moodle 3.3
657      */
658     public function trigger_module_viewed() {
659         $event = \mod_feedback\event\course_module_viewed::create_from_record($this->feedback, $this->cm, $this->cm->get_course());
660         $event->trigger();
661     }
663     /**
664      * Mark activity viewed for completion-tracking.
665      *
666      * @since Moodle 3.3
667      */
668     public function set_module_viewed() {
669         global $CFG;
670         require_once($CFG->libdir . '/completionlib.php');
672         $completion = new completion_info($this->cm->get_course());
673         $completion->set_module_viewed($this->cm);
674     }
676     /**
677      * Process a page jump via the mod_feedback_complete_form.
678      *
679      * This function initializes the form and process the submission.
680      *
681      * @param  int $gopage         the current page
682      * @param  int $gopreviouspage if the user chose to go to the previous page
683      * @return string the url to redirect the user (if any)
684      * @since  Moodle 3.3
685      */
686     public function process_page($gopage, $gopreviouspage = false) {
687         global $CFG, $PAGE, $SESSION;
689         $urltogo = null;
691         // Save the form for later during the request.
692         $this->create_completed_tmp_from_last_completed();
693         $this->form = new mod_feedback_complete_form(mod_feedback_complete_form::MODE_COMPLETE,
694             $this, 'feedback_complete_form', array('gopage' => $gopage));
696         if ($this->form->is_cancelled()) {
697             // Form was cancelled - return to the course page.
698             $urltogo = course_get_url($this->courseid ?: $this->feedback->course);
699         } else if ($this->form->is_submitted() &&
700                 ($this->form->is_validated() || $gopreviouspage)) {
701             // Form was submitted (skip validation for "Previous page" button).
702             $data = $this->form->get_submitted_data();
703             if (!isset($SESSION->feedback->is_started) OR !$SESSION->feedback->is_started == true) {
704                 print_error('error', '', $CFG->wwwroot.'/course/view.php?id='.$this->courseid);
705             }
706             $this->save_response_tmp($data);
707             if (!empty($data->savevalues) || !empty($data->gonextpage)) {
708                 if (($nextpage = $this->get_next_page($gopage)) !== null) {
709                     if ($PAGE->has_set_url()) {
710                         $urltogo = new moodle_url($PAGE->url, array('gopage' => $nextpage));
711                     }
712                     $this->jumpto = $nextpage;
713                 } else {
714                     $this->save_response();
715                     if (!$this->get_feedback()->page_after_submit) {
716                         \core\notification::success(get_string('entries_saved', 'feedback'));
717                     }
718                     $this->justcompleted = true;
719                 }
720             } else if (!empty($gopreviouspage)) {
721                 $prevpage = intval($this->get_previous_page($gopage));
722                 if ($PAGE->has_set_url()) {
723                     $urltogo = new moodle_url($PAGE->url, array('gopage' => $prevpage));
724                 }
725                 $this->jumpto = $prevpage;
726             }
727         }
728         return $urltogo;
729     }
731     /**
732      * Render the form with the questions.
733      *
734      * @return string the form rendered
735      * @since Moodle 3.3
736      */
737     public function render_items() {
738         global $SESSION;
740         // Print the items.
741         $SESSION->feedback->is_started = true;
742         return $this->form->render();
743     }