. /** * Contains class mod_feedback_completion * * @package mod_feedback * @copyright 2016 Marina Glancy * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Collects information and methods about feedback completion (either complete.php or show_entries.php) * * @package mod_feedback * @copyright 2016 Marina Glancy * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class mod_feedback_completion extends mod_feedback_structure { /** @var stdClass */ protected $completed; /** @var stdClass */ protected $completedtmp = null; /** @var stdClass[] */ protected $valuestmp = null; /** @var stdClass[] */ protected $values = null; /** @var bool */ protected $iscompleted = false; /** @var mod_feedback_complete_form the form used for completing the feedback */ protected $form = null; /** @var bool true when the feedback has been completed during the request */ protected $justcompleted = false; /** @var int the next page the user should jump after processing the form */ protected $jumpto = null; /** * Constructor * * @param stdClass $feedback feedback object * @param cm_info $cm course module object corresponding to the $feedback * (at least one of $feedback or $cm is required) * @param int $courseid current course (for site feedbacks only) * @param bool $iscompleted has feedback been already completed? If yes either completedid or userid must be specified. * @param int $completedid id in the table feedback_completed, may be omitted if userid is specified * but it is highly recommended because the same user may have multiple responses to the same feedback * for different courses * @param int $nonanonymouseuserid - Return only anonymous results or specified user's results. * If null only anonymous replies will be returned and the $completedid is mandatory. * If specified only non-anonymous replies of $nonanonymouseuserid will be returned. * @param int $userid User id to use for all capability checks, etc. Set to 0 for current user (default). */ public function __construct($feedback, $cm, $courseid, $iscompleted = false, $completedid = null, $nonanonymouseuserid = null, $userid = 0) { global $DB; parent::__construct($feedback, $cm, $courseid, 0, $userid); // Make sure courseid is always set for site feedback. if ($this->feedback->course == SITEID && !$this->courseid) { $this->courseid = SITEID; } if ($iscompleted) { // Retrieve information about the completion. $this->iscompleted = true; $params = array('feedback' => $this->feedback->id); if (!$nonanonymouseuserid && !$completedid) { throw new coding_exception('Either $completedid or $nonanonymouseuserid must be specified for completed feedbacks'); } if ($completedid) { $params['id'] = $completedid; } if ($nonanonymouseuserid) { // We must respect the anonymousity of the reply that the user saw when they were completing the feedback, // not the current state that may have been changed later by the teacher. $params['anonymous_response'] = FEEDBACK_ANONYMOUS_NO; $params['userid'] = $nonanonymouseuserid; } $this->completed = $DB->get_record('feedback_completed', $params, '*', MUST_EXIST); $this->courseid = $this->completed->courseid; } } /** * Returns a record from 'feedback_completed' table * @return stdClass */ public function get_completed() { return $this->completed; } /** * Check if the feedback was just completed. * * @return bool true if the feedback was just completed. * @since Moodle 3.3 */ public function just_completed() { return $this->justcompleted; } /** * Return the jumpto property. * * @return int the next page to jump. * @since Moodle 3.3 */ public function get_jumpto() { return $this->jumpto; } /** * Returns the temporary completion record for the current user or guest session * * @return stdClass|false record from feedback_completedtmp or false if not found */ public function get_current_completed_tmp() { global $DB, $USER; if ($this->completedtmp === null) { $params = array('feedback' => $this->get_feedback()->id); if ($courseid = $this->get_courseid()) { $params['courseid'] = $courseid; } if ((isloggedin() || $USER->id != $this->userid) && !isguestuser($this->userid)) { $params['userid'] = $this->userid; } else { $params['guestid'] = sesskey(); } $this->completedtmp = $DB->get_record('feedback_completedtmp', $params); } return $this->completedtmp; } /** * Can the current user see the item, if dependency is met? * * @param stdClass $item * @return bool whether user can see item or not, * true if there is no dependency or dependency is met, * false if dependent question is visible or broken * and further it is either not answered or the dependency is not met, * null if dependency is broken. */ protected function can_see_item($item) { if (empty($item->dependitem)) { return true; } if ($this->dependency_has_error($item)) { return null; } $allitems = $this->get_items(); $ditem = $allitems[$item->dependitem]; $itemobj = feedback_get_item_class($ditem->typ); if ($this->iscompleted) { $value = $this->get_values($ditem); } else { $value = $this->get_values_tmp($ditem); } if ($value === null) { // Cyclic dependencies are no problem here, since they will throw an dependency error above. if ($this->can_see_item($ditem) === false) { return false; } return null; } return $itemobj->compare_value($ditem, $value, $item->dependvalue) ? true : false; } /** * Dependency condition has an error * @param stdClass $item * @return bool */ protected function dependency_has_error($item) { if (empty($item->dependitem)) { // No dependency - no error. return false; } $allitems = $this->get_items(); if (!array_key_exists($item->dependitem, $allitems)) { // Looks like dependent item has been removed. return true; } $itemids = array_keys($allitems); $index1 = array_search($item->dependitem, $itemids); $index2 = array_search($item->id, $itemids); if ($index1 >= $index2) { // Dependent item is after the current item in the feedback. return true; } for ($i = $index1 + 1; $i < $index2; $i++) { if ($allitems[$itemids[$i]]->typ === 'pagebreak') { return false; } } // There are no page breaks between dependent items. return true; } /** * Returns a value stored for this item in the feedback (temporary or not, depending on the mode) * @param stdClass $item * @return string */ public function get_item_value($item) { if ($this->iscompleted) { return $this->get_values($item); } else { return $this->get_values_tmp($item); } } /** * Retrieves responses from an unfinished attempt. * * @return array the responses (from the feedback_valuetmp table) * @since Moodle 3.3 */ public function get_unfinished_responses() { global $DB; $responses = array(); $completedtmp = $this->get_current_completed_tmp(); if ($completedtmp) { $responses = $DB->get_records('feedback_valuetmp', ['completed' => $completedtmp->id]); } return $responses; } /** * Returns all temporary values for this feedback or just a value for an item * @param stdClass $item * @return array */ protected function get_values_tmp($item = null) { global $DB; if ($this->valuestmp === null) { $this->valuestmp = array(); $responses = $this->get_unfinished_responses(); foreach ($responses as $r) { $this->valuestmp[$r->item] = $r->value; } } if ($item) { return array_key_exists($item->id, $this->valuestmp) ? $this->valuestmp[$item->id] : null; } return $this->valuestmp; } /** * Retrieves responses from an finished attempt. * * @return array the responses (from the feedback_value table) * @since Moodle 3.3 */ public function get_finished_responses() { global $DB; $responses = array(); if ($this->completed) { $responses = $DB->get_records('feedback_value', ['completed' => $this->completed->id]); } return $responses; } /** * Returns all completed values for this feedback or just a value for an item * @param stdClass $item * @return array */ protected function get_values($item = null) { global $DB; if ($this->values === null) { $this->values = array(); $responses = $this->get_finished_responses(); foreach ($responses as $r) { $this->values[$r->item] = $r->value; } } if ($item) { return array_key_exists($item->id, $this->values) ? $this->values[$item->id] : null; } return $this->values; } /** * Splits the feedback items into pages * * Items that we definitely know at this stage as not applicable are excluded. * Items that are dependent on something that has not yet been answered are * still present, as well as items with broken dependencies. * * @return array array of arrays of items */ public function get_pages() { $pages = [[]]; // The first page always exists. $items = $this->get_items(); foreach ($items as $item) { if ($item->typ === 'pagebreak') { $pages[] = []; } else if ($this->can_see_item($item) !== false) { $pages[count($pages) - 1][] = $item; } } return $pages; } /** * Returns the last page that has items with the value (i.e. not label) which have been answered * as well as the first page that has items with the values that have not been answered. * * Either of the two return values may be null if there are no answered page or there are no * unanswered pages left respectively. * * Two pages may not be directly following each other because there may be empty pages * or pages with information texts only between them * * @return array array of two elements [$lastcompleted, $firstincompleted] */ protected function get_last_completed_page() { $completed = []; $incompleted = []; $pages = $this->get_pages(); foreach ($pages as $pageidx => $pageitems) { foreach ($pageitems as $item) { if ($item->hasvalue) { if ($this->get_values_tmp($item) !== null) { $completed[$pageidx] = true; } else { $incompleted[$pageidx] = true; } } } } $completed = array_keys($completed); $incompleted = array_keys($incompleted); // If some page has both completed and incompleted items it is considered incompleted. $completed = array_diff($completed, $incompleted); // If the completed page follows an incompleted page, it does not count. $firstincompleted = $incompleted ? min($incompleted) : null; if ($firstincompleted !== null) { $completed = array_filter($completed, function($a) use ($firstincompleted) { return $a < $firstincompleted; }); } $lastcompleted = $completed ? max($completed) : null; return [$lastcompleted, $firstincompleted]; } /** * Get the next page for the feedback * * This is normally $gopage+1 but may be bigger if there are empty pages or * pages without visible questions. * * This method can only be called when questions on the current page are * already answered, otherwise it may be inaccurate. * * @param int $gopage current page * @param bool $strictcheck when gopage is the user-input value, make sure we do not jump over unanswered questions * @return int|null the index of the next page or null if this is the last page */ public function get_next_page($gopage, $strictcheck = true) { if ($strictcheck) { list($lastcompleted, $firstincompleted) = $this->get_last_completed_page(); if ($firstincompleted !== null && $firstincompleted <= $gopage) { return $firstincompleted; } } $pages = $this->get_pages(); for ($pageidx = $gopage + 1; $pageidx < count($pages); $pageidx++) { if (!empty($pages[$pageidx])) { return $pageidx; } } // No further pages in the feedback have any visible items. return null; } /** * Get the previous page for the feedback * * This is normally $gopage-1 but may be smaller if there are empty pages or * pages without visible questions. * * @param int $gopage current page * @param bool $strictcheck when gopage is the user-input value, make sure we do not jump over unanswered questions * @return int|null the index of the next page or null if this is the first page with items */ public function get_previous_page($gopage, $strictcheck = true) { if (!$gopage) { // If we are already on the first (0) page, there is definitely no previous page. return null; } $pages = $this->get_pages(); $rv = null; // Iterate through previous pages and find the closest one that has any items on it. for ($pageidx = $gopage - 1; $pageidx >= 0; $pageidx--) { if (!empty($pages[$pageidx])) { $rv = $pageidx; break; } } if ($rv === null) { // We are on the very first page that has items. return null; } if ($rv > 0 && $strictcheck) { // Check if this page is actually not past than first incompleted page. list($lastcompleted, $firstincompleted) = $this->get_last_completed_page(); if ($firstincompleted !== null && $firstincompleted < $rv) { return $firstincompleted; } } return $rv; } /** * Page index to resume the feedback * * When user abandones answering feedback and then comes back to it we should send him * to the first page after the last page he fully completed. * @return int */ public function get_resume_page() { list($lastcompleted, $firstincompleted) = $this->get_last_completed_page(); return $lastcompleted === null ? 0 : $this->get_next_page($lastcompleted, false); } /** * Creates a new record in the 'feedback_completedtmp' table for the current user/guest session * * @return stdClass record from feedback_completedtmp or false if not found */ protected function create_current_completed_tmp() { global $DB, $USER; $record = (object)['feedback' => $this->feedback->id]; if ($this->get_courseid()) { $record->courseid = $this->get_courseid(); } if ((isloggedin() || $USER->id != $this->userid) && !isguestuser($this->userid)) { $record->userid = $this->userid; } else { $record->guestid = sesskey(); } $record->timemodified = time(); $record->anonymous_response = $this->feedback->anonymous; $id = $DB->insert_record('feedback_completedtmp', $record); $this->completedtmp = $DB->get_record('feedback_completedtmp', ['id' => $id]); $this->valuestmp = null; return $this->completedtmp; } /** * If user has already completed the feedback, create the temproray values from last completed attempt * * @return stdClass record from feedback_completedtmp or false if not found */ public function create_completed_tmp_from_last_completed() { if (!$this->get_current_completed_tmp()) { $lastcompleted = $this->find_last_completed(); if ($lastcompleted) { $this->completedtmp = feedback_set_tmp_values($lastcompleted); } } return $this->completedtmp; } /** * Saves unfinished response to the temporary table * * This is called when user proceeds to the next/previous page in the complete form * and also right after the form submit. * After the form submit the {@link save_response()} is called to * move response from temporary table to completion table. * * @param stdClass $data data from the form mod_feedback_complete_form */ public function save_response_tmp($data) { global $DB; if (!$completedtmp = $this->get_current_completed_tmp()) { $completedtmp = $this->create_current_completed_tmp(); } else { $currentime = time(); $DB->update_record('feedback_completedtmp', ['id' => $completedtmp->id, 'timemodified' => $currentime]); $completedtmp->timemodified = $currentime; } // Find all existing values. $existingvalues = $DB->get_records_menu('feedback_valuetmp', ['completed' => $completedtmp->id], '', 'item, id'); // Loop through all feedback items and save the ones that are present in $data. $allitems = $this->get_items(); foreach ($allitems as $item) { if (!$item->hasvalue) { continue; } $keyname = $item->typ . '_' . $item->id; if (!isset($data->$keyname)) { // This item is either on another page or dependency was not met - nothing to save. continue; } $newvalue = ['item' => $item->id, 'completed' => $completedtmp->id, 'course_id' => $completedtmp->courseid]; // Convert the value to string that can be stored in 'feedback_valuetmp' or 'feedback_value'. $itemobj = feedback_get_item_class($item->typ); $newvalue['value'] = $itemobj->create_value($data->$keyname); // Update or insert the value in the 'feedback_valuetmp' table. if (array_key_exists($item->id, $existingvalues)) { $newvalue['id'] = $existingvalues[$item->id]; $DB->update_record('feedback_valuetmp', $newvalue); } else { $DB->insert_record('feedback_valuetmp', $newvalue); } } // Reset valuestmp cache. $this->valuestmp = null; } /** * Saves the response * * The form data has already been stored in the temporary table in * {@link save_response_tmp()}. This function copies the values * from the temporary table to the completion table. * It is also responsible for sending email notifications when applicable. */ public function save_response() { global $SESSION, $DB, $USER; $feedbackcompleted = $this->find_last_completed(); $feedbackcompletedtmp = $this->get_current_completed_tmp(); if (feedback_check_is_switchrole()) { // We do not actually save anything if the role is switched, just delete temporary values. $this->delete_completedtmp(); return; } // Save values. $completedid = feedback_save_tmp_values($feedbackcompletedtmp, $feedbackcompleted); $this->completed = $DB->get_record('feedback_completed', array('id' => $completedid)); // Send email. if ($this->feedback->anonymous == FEEDBACK_ANONYMOUS_NO) { feedback_send_email($this->cm, $this->feedback, $this->cm->get_course(), $this->userid, $this->completed); } else { feedback_send_email_anonym($this->cm, $this->feedback, $this->cm->get_course()); } unset($SESSION->feedback->is_started); // Update completion state. $completion = new completion_info($this->cm->get_course()); if ((isloggedin() || $USER->id != $this->userid) && $completion->is_enabled($this->cm) && $this->cm->completion == COMPLETION_TRACKING_AUTOMATIC && $this->feedback->completionsubmit) { $completion->update_state($this->cm, COMPLETION_COMPLETE, $this->userid); } } /** * Deletes the temporary completed and all related temporary values */ protected function delete_completedtmp() { global $DB; if ($completedtmp = $this->get_current_completed_tmp()) { $DB->delete_records('feedback_valuetmp', ['completed' => $completedtmp->id]); $DB->delete_records('feedback_completedtmp', ['id' => $completedtmp->id]); $this->completedtmp = null; } } /** * Retrieves the last completion record for the current user * * @return stdClass record from feedback_completed or false if not found */ public function find_last_completed() { global $DB, $USER; if ((!isloggedin() && $USER->id == $this->userid) || isguestuser($this->userid)) { // Not possible to retrieve completed feedback for guests. return false; } if ($this->is_anonymous()) { // Not possible to retrieve completed anonymous feedback. return false; } $params = array('feedback' => $this->feedback->id, 'userid' => $this->userid, 'anonymous_response' => FEEDBACK_ANONYMOUS_NO ); if ($this->get_courseid()) { $params['courseid'] = $this->get_courseid(); } $this->completed = $DB->get_record('feedback_completed', $params); return $this->completed; } /** * Checks if user has capability to submit the feedback * * There is an exception for fully anonymous feedbacks when guests can complete * feedback without the proper capability. * * This should be followed by checking {@link can_submit()} because even if * user has capablity to complete, they may have already submitted feedback * and can not re-submit * * @return bool */ public function can_complete() { global $CFG, $USER; $context = context_module::instance($this->cm->id); if (has_capability('mod/feedback:complete', $context, $this->userid)) { return true; } if (!empty($CFG->feedback_allowfullanonymous) AND $this->feedback->course == SITEID AND $this->feedback->anonymous == FEEDBACK_ANONYMOUS_YES AND ((!isloggedin() && $USER->id == $this->userid) || isguestuser($this->userid))) { // Guests are allowed to complete fully anonymous feedback without having 'mod/feedback:complete' capability. return true; } return false; } /** * Checks if user is prevented from re-submission. * * This must be called after {@link can_complete()} * * @return bool */ public function can_submit() { if ($this->get_feedback()->multiple_submit == 0 ) { if ($this->is_already_submitted()) { return false; } } return true; } /** * Trigger module viewed event. * * @since Moodle 3.3 */ public function trigger_module_viewed() { $event = \mod_feedback\event\course_module_viewed::create_from_record($this->feedback, $this->cm, $this->cm->get_course()); $event->trigger(); } /** * Mark activity viewed for completion-tracking. * * @since Moodle 3.3 */ public function set_module_viewed() { global $CFG; require_once($CFG->libdir . '/completionlib.php'); $completion = new completion_info($this->cm->get_course()); $completion->set_module_viewed($this->cm, $this->userid); } /** * Process a page jump via the mod_feedback_complete_form. * * This function initializes the form and process the submission. * * @param int $gopage the current page * @param int $gopreviouspage if the user chose to go to the previous page * @return string the url to redirect the user (if any) * @since Moodle 3.3 */ public function process_page($gopage, $gopreviouspage = false) { global $CFG, $PAGE, $SESSION; $urltogo = null; // Save the form for later during the request. $this->create_completed_tmp_from_last_completed(); $this->form = new mod_feedback_complete_form(mod_feedback_complete_form::MODE_COMPLETE, $this, 'feedback_complete_form', array('gopage' => $gopage)); if ($this->form->is_cancelled()) { // Form was cancelled - return to the course page. $urltogo = course_get_url($this->courseid ?: $this->feedback->course); } else if ($this->form->is_submitted() && ($this->form->is_validated() || $gopreviouspage)) { // Form was submitted (skip validation for "Previous page" button). $data = $this->form->get_submitted_data(); if (!isset($SESSION->feedback->is_started) OR !$SESSION->feedback->is_started == true) { print_error('error', '', $CFG->wwwroot.'/course/view.php?id='.$this->courseid); } $this->save_response_tmp($data); if (!empty($data->savevalues) || !empty($data->gonextpage)) { if (($nextpage = $this->get_next_page($gopage)) !== null) { if ($PAGE->has_set_url()) { $urltogo = new moodle_url($PAGE->url, array('gopage' => $nextpage)); } $this->jumpto = $nextpage; } else { $this->save_response(); if (!$this->get_feedback()->page_after_submit) { \core\notification::success(get_string('entries_saved', 'feedback')); } $this->justcompleted = true; } } else if (!empty($gopreviouspage)) { $prevpage = intval($this->get_previous_page($gopage)); if ($PAGE->has_set_url()) { $urltogo = new moodle_url($PAGE->url, array('gopage' => $prevpage)); } $this->jumpto = $prevpage; } } return $urltogo; } /** * Render the form with the questions. * * @return string the form rendered * @since Moodle 3.3 */ public function render_items() { global $SESSION; // Print the items. $SESSION->feedback->is_started = true; return $this->form->render(); } }