. /** * 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; /** * Constructor * * @param stdClass $feedback feedback object, in case of the template * this is the current feedback the template is accessed from * @param cm_info $cm course module object corresponding to the $feedback * @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 $userid id of the user - if specified only non-anonymous replies will be returned. If not * specified only anonymous replies will be returned and the $completedid is mandatory. */ public function __construct($feedback, $cm, $courseid, $iscompleted = false, $completedid = null, $userid = null) { global $DB; // Make sure courseid is always set for site feedback and never for course feedback. if ($feedback->course == SITEID) { $courseid = $courseid ?: SITEID; } else { $courseid = 0; } parent::__construct($feedback, $cm, $courseid, 0); if ($iscompleted) { // Retrieve information about the completion. $this->iscompleted = true; $params = array('feedback' => $feedback->id); if (!$userid && !$completedid) { throw new coding_exception('Either $completedid or $userid must be specified for completed feedbacks'); } if ($completedid) { $params['id'] = $completedid; } if ($userid) { // 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'] = $userid; } $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; } /** * Returns the temporary completion record for the current user or guest session * * @return stdClass|false record from feedback_completedtmp or false if not found */ protected function get_current_completed_tmp() { global $USER, $DB; if ($this->completedtmp === null) { $params = array('feedback' => $this->get_feedback()->id); if ($courseid = $this->get_courseid()) { $params['courseid'] = $courseid; } if (isloggedin() && !isguestuser()) { $params['userid'] = $USER->id; } 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, * null if dependency is broken or dependent question is not answered. */ 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) { 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); } } /** * 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) { $completedtmp = $this->get_current_completed_tmp(); if ($completedtmp) { $this->valuestmp = $DB->get_records_menu('feedback_valuetmp', ['completed' => $completedtmp->id], '', 'item, value'); } else { $this->valuestmp = array(); } } if ($item) { return array_key_exists($item->id, $this->valuestmp) ? $this->valuestmp[$item->id] : null; } return $this->valuestmp; } /** * 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) { if ($this->completed) { $this->values = $DB->get_records_menu('feedback_value', ['completed' => $this->completed->id], '', 'item, value'); } else { $this->values = array(); } } 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 $USER, $DB; $record = (object)['feedback' => $this->feedback->id]; if ($this->get_courseid()) { $record->courseid = $this->get_courseid(); } if (isloggedin() && !isguestuser()) { $record->userid = $USER->id; } 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; } /** * 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 $USER, $SESSION, $DB; $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(), $USER); } 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() && !isguestuser() && $completion->is_enabled($this->cm) && $this->feedback->completionsubmit) { $completion->update_state($this->cm, COMPLETION_COMPLETE); } } /** * 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 */ protected function find_last_completed() { global $USER, $DB; if (isloggedin() || isguestuser()) { // 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' => $USER->id); if ($this->get_courseid()) { $params['courseid'] = $this->get_courseid(); } $this->completed = $DB->get_record('feedback_completed', $params); return $this->completed; } /** * Checks if current 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; $context = context_module::instance($this->cm->id); if (has_capability('mod/feedback:complete', $context)) { return true; } if (!empty($CFG->feedback_allowfullanonymous) AND $this->feedback->course == SITEID AND $this->feedback->anonymous == FEEDBACK_ANONYMOUS_YES AND (!isloggedin() OR isguestuser())) { // 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; } }