moodle/mod/quiz/locallib.php
tjhunt cd06115fa7 Convert mod/quiz/view.php to roles, and further tidy up the code flow in there. This touches other files because I had to change a function in locallib that had an $isteacher parameter.
I have not converted the rest of the quiz, and I don't intend to. I am hoping Moodle.com will do it.

I hope this does not introduce any bugs, but view.php is so complicate (at least it is less complicated than it was) and the quiz has so many options, that it is hard to be sure. This needs lots of testing before 1.7 is released.
2006-08-25 16:03:54 +00:00

712 lines
25 KiB
PHP

<?php // $Id$
/**
* Library of functions used by the quiz module.
*
* This contains functions that are called from within the quiz module only
* Functions that are also called by core Moodle are in {@link lib.php}
* This script also loads the code in {@link questionlib.php} which holds
* the module-indpendent code for handling questions and which in turn
* initialises all the questiontype classes.
* @version $Id$
* @author Martin Dougiamas and many others. This has recently been completely
* rewritten by Alex Smith, Julian Sedding and Gustav Delius as part of
* the Serving Mathematics project
* {@link http://maths.york.ac.uk/serving_maths}
* @license http://www.gnu.org/copyleft/gpl.html GNU Public License
* @package quiz
*/
/**
* Include those library functions that are also used by core Moodle or other modules
*/
require_once("$CFG->dirroot/mod/quiz/lib.php");
//require_once($CFG->libdir.'/questionlib.php');
require_once("{$CFG->dirroot}/question/editlib.php");
/// CONSTANTS ///////////////////////////////////////////////////////////////////
/**#@+
* Options determining how the grades from individual attempts are combined to give
* the overall grade for a user
*/
define("QUIZ_GRADEHIGHEST", "1");
define("QUIZ_GRADEAVERAGE", "2");
define("QUIZ_ATTEMPTFIRST", "3");
define("QUIZ_ATTEMPTLAST", "4");
$QUIZ_GRADE_METHOD = array ( QUIZ_GRADEHIGHEST => get_string("gradehighest", "quiz"),
QUIZ_GRADEAVERAGE => get_string("gradeaverage", "quiz"),
QUIZ_ATTEMPTFIRST => get_string("attemptfirst", "quiz"),
QUIZ_ATTEMPTLAST => get_string("attemptlast", "quiz"));
/**#@-*/
/// FUNCTIONS RELATED TO ATTEMPTS /////////////////////////////////////////
/**
* Creates an object to represent a new attempt at a quiz
*
* Creates an attempt object to represent an attempt at the quiz by the current
* user starting at the current time. The ->id field is not set. The object is
* NOT written to the database.
* @return object The newly created attempt object.
* @param object $quiz The quiz to create an attempt for.
* @param integer $attemptnumber The sequence number for the attempt.
*/
function quiz_create_attempt($quiz, $attemptnumber) {
global $USER, $CFG;
if (!$attemptnumber > 1 or !$quiz->attemptonlast or !$attempt = get_record('quiz_attempts', 'quiz', $quiz->id, 'userid', $USER->id, 'attempt', $attemptnumber-1)) {
// we are not building on last attempt so create a new attempt
$attempt->quiz = $quiz->id;
$attempt->userid = $USER->id;
$attempt->preview = 0;
if ($quiz->shufflequestions) {
$attempt->layout = quiz_repaginate($quiz->questions, $quiz->questionsperpage, true);
} else {
$attempt->layout = $quiz->questions;
}
}
$timenow = time();
$attempt->attempt = $attemptnumber;
$attempt->sumgrades = 0.0;
$attempt->timestart = $timenow;
$attempt->timefinish = 0;
$attempt->timemodified = $timenow;
$attempt->uniqueid = question_new_attempt_uniqueid();
return $attempt;
}
/**
* Returns an unfinished attempt (if there is one) for the given
* user on the given quiz. This function does not return preview attempts.
*
* @param integer $quizid the id of the quiz.
* @param integer $userid the id of the user.
*
* @return mixed the unfinished attempt if there is one, false if not.
*/
function quiz_get_user_attempt_unfinished($quizid, $userid) {
return get_record_select("quiz_attempts", "quiz = $quizid AND userid = $userid AND timefinish = 0 AND preview = 0");
}
/**
* @param integer $quizid the quiz id.
* @param integer $userid the userid.
* @return an array of all the ueser's attempts at this quiz. Returns an empty array if there are none.
*/
function quiz_get_user_attempts($quizid, $userid) {
if ($attempts = get_records_select("quiz_attempts", "quiz = '$quizid' AND userid = '$userid' AND timefinish > 0",
"attempt ASC")) {
return $attempts;
} else {
return array();
}
}
/// FUNCTIONS TO DO WITH QUIZ LAYOUT AND PAGES ////////////////////////////////
/**
* Returns a comma separated list of question ids for the current page
*
* @return string Comma separated list of question ids
* @param string $layout The string representing the quiz layout. Each page is represented as a
* comma separated list of question ids and 0 indicating page breaks.
* So 5,2,0,3,0 means questions 5 and 2 on page 1 and question 3 on page 2
* @param integer $page The number of the current page.
*/
function quiz_questions_on_page($layout, $page) {
$pages = explode(',0', $layout);
return trim($pages[$page], ',');
}
/**
* Returns a comma separated list of question ids for the quiz
*
* @return string Comma separated list of question ids
* @param string $layout The string representing the quiz layout. Each page is represented as a
* comma separated list of question ids and 0 indicating page breaks.
* So 5,2,0,3,0 means questions 5 and 2 on page 1 and question 3 on page 2
*/
function quiz_questions_in_quiz($layout) {
return str_replace(',0', '', $layout);
}
/**
* Returns the number of pages in the quiz layout
*
* @return integer Comma separated list of question ids
* @param string $layout The string representing the quiz layout.
*/
function quiz_number_of_pages($layout) {
return substr_count($layout, ',0');
}
/**
* Returns the first question number for the current quiz page
*
* @return integer The number of the first question
* @param string $quizlayout The string representing the layout for the whole quiz
* @param string $pagelayout The string representing the layout for the current page
*/
function quiz_first_questionnumber($quizlayout, $pagelayout) {
// this works by finding all the questions from the quizlayout that
// come before the current page and then adding up their lengths.
global $CFG;
$start = strpos($quizlayout, ','.$pagelayout.',')-2;
if ($start > 0) {
$prevlist = substr($quizlayout, 0, $start);
return get_field_sql("SELECT sum(length)+1 FROM {$CFG->prefix}question
WHERE id IN ($prevlist)");
} else {
return 1;
}
}
/**
* Re-paginates the quiz layout
*
* @return string The new layout string
* @param string $layout The string representing the quiz layout.
* @param integer $perpage The number of questions per page
* @param boolean $shuffle Should the questions be reordered randomly?
*/
function quiz_repaginate($layout, $perpage, $shuffle=false) {
$layout = str_replace(',0', '', $layout); // remove existing page breaks
$questions = explode(',', $layout);
if ($shuffle) {
srand((float)microtime() * 1000000); // for php < 4.2
shuffle($questions);
}
$i = 1;
$layout = '';
foreach ($questions as $question) {
if ($perpage and $i > $perpage) {
$layout .= '0,';
$i = 1;
}
$layout .= $question.',';
$i++;
}
return $layout.'0';
}
/**
* Print navigation panel for quiz attempt and review pages
*
* @param integer $page The number of the current page (counting from 0).
* @param integer $pages The total number of pages.
*/
function quiz_print_navigation_panel($page, $pages) {
//$page++;
echo '<div class="pagingbar">';
echo '<span class="title">' . get_string('page') . ':</span>';
if ($page > 0) {
// Print previous link
$strprev = get_string('previous');
echo '<a href="javascript:navigate(' . ($page - 1) . ');" title="'
. $strprev . '">(' . $strprev . ')</a>';
}
for ($i = 0; $i < $pages; $i++) {
if ($i == $page) {
echo '<span class="thispage">'.($i+1).'</span>';
} else {
echo '<a href="javascript:navigate(' . ($i) . ');">'.($i+1).'</a>';
}
}
if ($page < $pages - 1) {
// Print next link
$strnext = get_string('next');
echo '<a href="javascript:navigate(' . ($page + 1) . ');" title="'
. $strnext . '">(' . $strnext . ')</a>';
}
echo '</div>';
}
/// FUNCTIONS TO DO WITH QUIZ GRADES //////////////////////////////////////////
/**
* Creates an array of maximum grades for a quiz
*
* The grades are extracted from the quiz_question_instances table.
* @return array Array of grades indexed by question id
* These are the maximum possible grades that
* students can achieve for each of the questions
* @param integer $quiz The quiz object
*/
function quiz_get_all_question_grades($quiz) {
global $CFG;
$questionlist = quiz_questions_in_quiz($quiz->questions);
if (empty($questionlist)) {
return array();
}
$instances = get_records_sql("SELECT question,grade,id
FROM {$CFG->prefix}quiz_question_instances
WHERE quiz = '$quiz->id'" .
(is_null($questionlist) ? '' :
"AND question IN ($questionlist)"));
$list = explode(",", $questionlist);
$grades = array();
foreach ($list as $qid) {
if (isset($instances[$qid])) {
$grades[$qid] = $instances[$qid]->grade;
} else {
$grades[$qid] = 1;
}
}
return $grades;
}
/**
* Get the best current grade for a particular user in a quiz.
*
* @param object $quiz the quiz object.
* @param integer $userid the id of the user.
* @return float the user's current grade for this quiz.
*/
function quiz_get_best_grade($quiz, $userid) {
$grade = get_field('quiz_grades', 'grade', 'quiz', $quiz->id, 'userid', $userid);
// Need to detect errors/no result, without catching 0 scores.
if (is_numeric($grade)) {
return round($grade,$quiz->decimalpoints);
} else {
return NULL;
}
}
/**
* Convert the raw grade stored in $attempt into a grade out of the maximum
* grade for this quiz.
*
* @param float $rawgrade the unadjusted grade, fof example $attempt->sumgrades
* @param object $quiz the quiz object. Only the fields grade, sumgrades and decimalpoints are used.
* @return float the rescaled grade.
*/
function quiz_rescale_grade($rawgrade, $quiz) {
if ($quiz->sumgrades) {
return round($rawgrade*$quiz->grade/$quiz->sumgrades, $quiz->decimalpoints);
} else {
return 0;
}
}
/**
* Get the feedback text that should be show to a student who
* got this grade on this quiz.
*
* @param float $grade a grade on this quiz.
* @param integer $quizid the id of the quiz object.
* @return string the comment that corresponds to this grade (empty string if there is not one.
*/
function quiz_feedback_for_grade($grade, $quizid) {
$feedback = get_field_select('quiz_feedback', 'feedbacktext',
"quizid = $quizid AND mingrade <= $grade AND $grade < maxgrade");
if (empty($feedback)) {
$feedback = '';
}
return $feedback;
}
/**
* @param integer $quizid the id of the quiz object.
* @return boolean Whether this quiz has any non-blank feedback text.
*/
function quiz_has_feedback($quizid) {
static $cache = array();
if (!array_key_exists($quizid, $cache)) {
$cache[$quizid] = record_exists_select('quiz_feedback',
"quizid = $quizid AND feedbacktext <> ''");
}
return $cache[$quizid];
}
/**
* The quiz grade is the score that student's results are marked out of. When it
* changes, the corresponding data in quiz_grades and quiz_feedback needs to be
* rescaled.
*
* @param float $newgrade the new maximum grade for the quiz.
* @param object $quiz the quiz we are updating. Passed by reference so its grade field can be updated too.
* @return boolean indicating success or failure.
*/
function quiz_set_grade($newgrade, &$quiz) {
// This is potentially expensive, so only do it if necessary.
if (abs($quiz->grade - $newgrade) < 1e-7) {
// Nothing to do.
return true;
}
// Use a transaction, so that on those databases that support it, this is safer.
begin_sql();
// Update the quiz table.
$success = set_field('quiz', 'grade', $newgrade, 'id', $quiz->instance);
// Rescaling the other data is only possible if the old grade was non-zero.
if ($quiz->grade > 1e-7) {
global $CFG;
$factor = $newgrade/$quiz->grade;
$quiz->grade = $newgrade;
// Update the quiz_grades table.
$timemodified = time();
$success = $success && set_field('quiz_grades', 'grade',
'$factor * grade, timemodified = $timemodified',
'quiz', $quiz->id);
// Update the quiz_grades table.
$success = $success && execute_sql('quiz_feedback', 'mingrade',
'$factor * mingrade, maxgrade = $factor * maxgrade',
'quizid', $quiz->id);
}
if ($success) {
return commit_sql();
} else {
rollback_sql();
return false;
}
}
/**
* Save the overall grade for a user at a quiz in the quiz_grades table
*
* @param object $quiz The quiz for which the best grade is to be calculated and then saved.
* @param integer $userid The userid to calculate the grade for. Defaults to the current user.
* @return boolean Indicates success or failure.
*/
function quiz_save_best_grade($quiz, $userid = null) {
global $USER;
if (empty($userid)) {
$userid = $USER->id;
}
// Get all the attempts made by the user
if (!$attempts = quiz_get_user_attempts($quiz->id, $userid)) {
notify('Could not find any user attempts');
return false;
}
// Calculate the best grade
$bestgrade = quiz_calculate_best_grade($quiz, $attempts);
$bestgrade = quiz_rescale_grade($bestgrade, $quiz);
// Save the best grade in the database
if ($grade = get_record('quiz_grades', 'quiz', $quiz->id, 'userid', $userid)) {
$grade->grade = $bestgrade;
$grade->timemodified = time();
if (!update_record('quiz_grades', $grade)) {
notify('Could not update best grade');
return false;
}
} else {
$grade->quiz = $quiz->id;
$grade->userid = $userid;
$grade->grade = $bestgrade;
$grade->timemodified = time();
if (!insert_record('quiz_grades', $grade)) {
notify('Could not insert new best grade');
return false;
}
}
return true;
}
/**
* Calculate the overall grade for a quiz given a number of attempts by a particular user.
*
* @return float The overall grade
* @param object $quiz The quiz for which the best grade is to be calculated
* @param array $attempts An array of all the attempts of the user at the quiz
*/
function quiz_calculate_best_grade($quiz, $attempts) {
switch ($quiz->grademethod) {
case QUIZ_ATTEMPTFIRST:
foreach ($attempts as $attempt) {
return $attempt->sumgrades;
}
break;
case QUIZ_ATTEMPTLAST:
foreach ($attempts as $attempt) {
$final = $attempt->sumgrades;
}
return $final;
case QUIZ_GRADEAVERAGE:
$sum = 0;
$count = 0;
foreach ($attempts as $attempt) {
$sum += $attempt->sumgrades;
$count++;
}
return (float)$sum/$count;
default:
case QUIZ_GRADEHIGHEST:
$max = 0;
foreach ($attempts as $attempt) {
if ($attempt->sumgrades > $max) {
$max = $attempt->sumgrades;
}
}
return $max;
}
}
/**
* Return the attempt with the best grade for a quiz
*
* Which attempt is the best depends on $quiz->grademethod. If the grade
* method is GRADEAVERAGE then this function simply returns the last attempt.
* @return object The attempt with the best grade
* @param object $quiz The quiz for which the best grade is to be calculated
* @param array $attempts An array of all the attempts of the user at the quiz
*/
function quiz_calculate_best_attempt($quiz, $attempts) {
switch ($quiz->grademethod) {
case QUIZ_ATTEMPTFIRST:
foreach ($attempts as $attempt) {
return $attempt;
}
break;
case QUIZ_GRADEAVERAGE: // need to do something with it :-)
case QUIZ_ATTEMPTLAST:
foreach ($attempts as $attempt) {
$final = $attempt;
}
return $final;
default:
case QUIZ_GRADEHIGHEST:
$max = -1;
foreach ($attempts as $attempt) {
if ($attempt->sumgrades > $max) {
$max = $attempt->sumgrades;
$maxattempt = $attempt;
}
}
return $maxattempt;
}
}
/// OTHER QUIZ FUNCTIONS ////////////////////////////////////////////////////
/**
* Print a box with quiz start and due dates
*
* @param object $quiz
*/
function quiz_view_dates($quiz) {
if (!$quiz->timeopen && !$quiz->timeclose) {
return;
}
print_simple_box_start('center', '', '', '', 'generalbox', 'dates');
echo '<table>';
if ($quiz->timeopen) {
echo '<tr><td class="c0">'.get_string("quizopen", "quiz").':</td>';
echo ' <td class="c1">'.userdate($quiz->timeopen).'</td></tr>';
}
if ($quiz->timeclose) {
echo '<tr><td class="c0">'.get_string("quizclose", "quiz").':</td>';
echo ' <td class="c1">'.userdate($quiz->timeclose).'</td></tr>';
}
echo '</table>';
print_simple_box_end();
}
/**
* Parse field names used for the replace options on question edit forms
*/
function quiz_parse_fieldname($name, $nameprefix='question') {
$reg = array();
if (preg_match("/$nameprefix(\\d+)(\w+)/", $name, $reg)) {
return array('mode' => $reg[2], 'id' => (int)$reg[1]);
} else {
return false;
}
}
/**
* Upgrade states for an attempt to Moodle 1.5 model
*
* Any state that does not yet have its timestamp set to nonzero has not yet been upgraded from Moodle 1.4
* The reason these are still around is that for large sites it would have taken too long to
* upgrade all states at once. This function sets the timestamp field and creates an entry in the
* question_sessions table.
* @param object $attempt The attempt whose states need upgrading
*/
function quiz_upgrade_states($attempt) {
global $CFG;
// The old quiz model only allowed a single response per quiz attempt so that there will be
// only one state record per question for this attempt.
// We set the timestamp of all states to the timemodified field of the attempt.
execute_sql("UPDATE {$CFG->prefix}question_states SET timestamp = '$attempt->timemodified' WHERE attempt = '$attempt->uniqueid'", false);
// For each state we create an entry in the question_sessions table, with both newest and
// newgraded pointing to this state.
// Actually we only do this for states whose question is actually listed in $attempt->layout.
// We do not do it for states associated to wrapped questions like for example the questions
// used by a RANDOM question
$newest->attemptid = $attempt->uniqueid;
$questionlist = quiz_questions_in_quiz($attempt->layout);
if ($questionlist and $states = get_records_select('question_states', "attempt = '$attempt->uniqueid' AND question IN ($questionlist)")) {
foreach ($states as $state) {
$session = new stdClass;
$session->newgraded = $state->id;
$session->newest = $state->id;
$session->questionid = $state->question;
insert_record('question_sessions', $session, false);
}
}
}
// ULPGc ecastro
function quiz_get_question_review($quiz, $question) {
// returns a question icon
$qnum = $question->id;
$strpreview = get_string('previewquestion', 'quiz');
$context = $quiz->id ? '&amp;contextquiz='.$quiz->id : '';
$quiz_id = $quiz->id ? '&amp;quizid=' . $quiz->id : '';
return "<a title=\"$strpreview\" href=\"javascript:void(0)\" onClick=\"openpopup('/question/preview.php?id=$qnum$quiz_id','questionpreview', " .
QUESTION_PREVIEW_POPUP_OPTIONS . ", false)\">
<img src=\"../../pix/t/preview.gif\" border=\"0\" alt=\"$strpreview\" /></a>";
}
/**
* Determine render options
*
* @param int $reviewoptions
* @param object $state
*/
function quiz_get_renderoptions($reviewoptions, $state) {
$options = new stdClass;
// Show the question in readonly (review) mode if the question is in
// the closed state
$options->readonly = question_state_is_closed($state);
// Show feedback once the question has been graded (if allowed by the quiz)
$options->feedback = question_state_is_graded($state) && ($reviewoptions & QUIZ_REVIEW_FEEDBACK & QUIZ_REVIEW_IMMEDIATELY);
// Show validation only after a validation event
$options->validation = QUESTION_EVENTVALIDATE === $state->event;
// Show correct responses in readonly mode if the quiz allows it
$options->correct_responses = $options->readonly && ($reviewoptions & QUIZ_REVIEW_ANSWERS & QUIZ_REVIEW_IMMEDIATELY);
// Show commentary if the question has been graded and the quiz allows it.
$options->commentary = question_state_is_graded($state) && ($reviewoptions & QUIZ_REVIEW_COMMENTARY & QUIZ_REVIEW_IMMEDIATELY);
// Always show responses and scores
$options->responses = true;
$options->scores = true;
return $options;
}
/**
* Determine review options
*
* @param object $quiz the quiz instance.
* @param object $attempt the attempt in question.
* @param $context the roles and permissions context,
* normally the context for the quiz module instance.
*
* @return object an object with boolean fields responses, scores, feedback,
* correct_responses, solutions and commentary
*/
function quiz_get_reviewoptions($quiz, $attempt, $context=null) {
$options = new stdClass;
$options->readonly = true;
// Provide the links to the question review and comment script
$options->questionreviewlink = '/mod/quiz/reviewquestion.php';
if ($context && has_capability('mod/quiz:viewreports'. $context) and !$attempt->preview) {
// The teacher should be shown everything except during preview when the teachers
// wants to see just what the students see
$options->responses = true;
$options->scores = true;
$options->feedback = true;
$options->correct_responses = true;
$options->solutions = false;
$options->commentary = true;
// Show a link to the comment box only for closed attempts
if ($attempt->timefinish) {
$options->questioncommentlink = '/mod/quiz/comment.php';
}
} else {
if (((time() - $attempt->timefinish) < 120) || $attempt->timefinish==0) {
$quiz_state_mask = QUIZ_REVIEW_IMMEDIATELY;
} else if (!$quiz->timeclose or time() < $quiz->timeclose) {
$quiz_state_mask = QUIZ_REVIEW_OPEN;
} else {
$quiz_state_mask = QUIZ_REVIEW_CLOSED;
}
$options->responses = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_RESPONSES) ? 1 : 0;
$options->scores = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_SCORES) ? 1 : 0;
$options->feedback = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_FEEDBACK) ? 1 : 0;
$options->correct_responses = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_ANSWERS) ? 1 : 0;
$options->solutions = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_SOLUTIONS) ? 1 : 0;
$options->commentary = ($quiz->review & $quiz_state_mask & QUIZ_REVIEW_COMMENTARY) ? 1 : 0;
}
return $options;
}
////////////////////////////////////////////////////////////////////////////////
/**
* Return boolean indicating if the quiz has attempts with hidden grades
*
* Selects all attempts matching specified quiz & user, and examines each to
* check they all have visible results.
* @return boolean If the quiz has attempts without visible results
* @param object $quiz The quiz being examined
* @param object $user The user concerned
*/
function all_attempt_results_visible($quiz, $user) {
global $CFG;
$sql = 'SELECT timefinish, preview FROM '.$CFG->prefix.'quiz_attempts qa'.
' WHERE qa.quiz='.$quiz->id.' AND qa.userid='.$user->id.
' ORDER BY id DESC';
if ($attempts = get_records_sql($sql)) {
foreach ($attempts as $attempt) {
$attemptoptions = quiz_get_reviewoptions($quiz, $attempt);
//if any attempt has scores option not set, not all attempt results are
//visible
if (!$attemptoptions->scores) {
return false;
}
}
}
return true;
}
?>