* @version 1.0 * @since 20 Aug 2014 17:20:09 */ require_once 'models/Model.inc.php'; class Question extends Model { // The database fields used by this model. public static $fields = array( 'account_id' => '', 'question_id' => '', 'survey_id' => '', 'rank' => 0, 'question_text' => '', 'auto_continue' => '', // 'added_datetime' => '', // 'added_by_user_id' => '', // 'modified_datetime' => '', // 'modified_by_user_id' => '', ); /* * * * @access public * @param * @return * @author Quinn Comendant * @version 1.0 * @since 16 Nov 2014 17:45:40 */ static public function insert($frm) { global $auth, $cache; $db =& DB::getInstance(); $app =& App::getInstance(); // Remove any stale cached list data. $cache->delete('question list'); // Create the record under the user's account, unless an account_id was provided. $account_id = isset($frm['account_id']) && is_numeric($frm['account_id']) ? $frm['account_id'] : $auth->get('account_id'); // Insert record data. $app->logMsg(sprintf('%s with data %s', __METHOD__, getDump($frm)), LOG_DEBUG, __FILE__, __LINE__); $db->query(" INSERT INTO question_tbl ( account_id, rank, survey_id, question_text, auto_continue, added_datetime, added_by_user_id, modified_datetime, modified_by_user_id ) VALUES ( '" . $db->escapeString($account_id) . "', '" . $db->escapeString($frm['rank']) . "', '" . $db->escapeString($frm['survey_id']) . "', '" . $db->escapeString($frm['question_text']) . "', '" . (isset($frm['auto_continue']) && !empty($frm['auto_continue'])) . "', NOW(), '" . $db->escapeString($auth->get('user_id')) . "', NOW(), '" . $db->escapeString($auth->get('user_id')) . "' ) "); $last_insert_id = mysql_insert_id($db->getDBH()); // Process question actions. self::saveQuestionActions($last_insert_id, $frm); // Create version. $version = Version::getInstance($auth); $version->create('question_tbl', 'question_id', $last_insert_id, $frm['question_text']); return $last_insert_id; } /* * * * @access public * @param * @return * @author Quinn Comendant * @version 1.0 * @since 16 Nov 2014 17:45:40 */ static public function update($frm) { global $auth, $lock, $cache; $db =& DB::getInstance(); $app =& App::getInstance(); $lock->select('question_tbl', 'question_id', $frm['question_id']); if ($lock->isLocked() && !$lock->isMine()) { $lock->dieErrorPage(); } // Remove any stale cached list data. $cache->delete('question list'); // Create the record under the user's account, unless an account_id was provided. $account_id = isset($frm['account_id']) && is_numeric($frm['account_id']) ? $frm['account_id'] : $auth->get('account_id'); // Update record data. $app->logMsg(sprintf('%s with data %s', __METHOD__, getDump($frm)), LOG_DEBUG, __FILE__, __LINE__); $db->query(" UPDATE question_tbl SET account_id = '" . $db->escapeString($account_id) . "', question_text = '" . $db->escapeString($frm['question_text']) . "', auto_continue = '" . (isset($frm['auto_continue']) && !empty($frm['auto_continue'])) . "', modified_datetime = NOW(), modified_by_user_id = '" . $db->escapeString($auth->get('user_id')) . "' WHERE question_id = '" . $db->escapeString($frm['question_id']) . "' "); // Process question actions. self::saveQuestionActions($frm['question_id'], $frm); // Create version. $version = Version::getInstance($auth); $version->create('question_tbl', 'question_id', $frm['question_id'], $frm['question_text']); // Unlock record. $lock->remove(); } /* * * * @access public * @param * @return * @author Quinn Comendant * @version 1.0 * @since 16 Nov 2014 17:45:40 */ static public function updateRank($survey_id, $newrank) { $db =& DB::getInstance(); $app =& App::getInstance(); // Check if submitted data is valid. if (!isset($newrank[0][0]['question_id'])) { $app->logMsg(sprintf('Saving rank failed; data is not valid: %s', getDump($newrank)), LOG_ERR, __FILE__, __LINE__); return false; } // Check each submited value of submitted data to ensure it is the required type. foreach ($newrank[0] as $rank => $q) { if (!is_numeric($rank) || !isset($q['question_id']) || !is_numeric($q['question_id'])) { $app->logMsg(sprintf('Saving rank failed; invalid array element: %s => %s', $rank, getDump($q)), LOG_ERR, __FILE__, __LINE__); return false; } } // Everything looks ok, update the database. // We correlate with survey_id so ensure the questions being updated belong to the correct survey. // Add + 1 to zero-index array. $result = ''; foreach ($newrank[0] as $rank => $q) { $rank++; $result[] = sprintf('%s => %s', $rank, $q['question_id']); $db->query(" UPDATE question_tbl SET rank = '" . $db->escapeString($rank) . "' WHERE question_id = '" . $db->escapeString($q['question_id']) . "' AND survey_id = '" . $db->escapeString($survey_id) . "' "); } $app->logMsg(sprintf('Saved rank for questions in survey_id %s: %s', $survey_id, join(', ', $result)), LOG_DEBUG, __FILE__, __LINE__); return true; } /* * * * @access public * @param * @return * @author Quinn Comendant * @version 1.0 * @since 16 Nov 2014 17:45:40 */ static public function delete($id) { global $lock, $cache, $locally_carried_queries; $db =& DB::getInstance(); $app =& App::getInstance(); $lock->select('question_tbl', 'question_id', $id); if ($lock->isLocked() && !$lock->isMine()) { $lock->dieErrorPage(); } // Remove any stale cached list data. $cache->delete('question list'); // Delete the record. $db->query("DELETE FROM question_tbl WHERE question_id = '" . $db->escapeString($id) . "'"); // Unlock record. $lock->remove(); } /* * * * @access public * @param * @return * @author Quinn Comendant * @version 1.0 * @since 16 Nov 2014 17:45:40 */ static public function getPaginatedList($where_clause='') { global $page, $so, $cache; $db =& DB::getInstance(); $app =& App::getInstance(); // Build search query if available. if (getFormData('q', false)) { $qry_words = preg_split('/[^\w]/', getFormData('q')); for ($i=0; $iescapeString($qry_words[$i]) . "%' ) "; } } if (getFormData('filter_survey_id', false)) { // Limit by filter. $where_clause .= (empty($where_clause) ? 'WHERE' : 'AND') . " survey_id = '" . $db->escapeString(getFormData('filter_survey_id')) . "'"; } // Count the total number of records so we can do something about the page numbers. $qid = $db->query(" SELECT COUNT(*) FROM question_tbl $where_clause "); list($num_results) = mysql_fetch_row($qid); // Set page numbers now we know (needed for next step). $page->setTotalItems($num_results); $page->calculate(); // Final SQL, with sort and page limiters. $sql = " SELECT question_tbl.*, a1.username AS added_by_username, a2.username AS modified_by_username FROM question_tbl LEFT JOIN user_tbl a1 ON (question_tbl.added_by_user_id = a1.user_id) LEFT JOIN user_tbl a2 ON (question_tbl.modified_by_user_id = a2.user_id) $where_clause " . $so->getSortOrderSQL() . " " . $page->getLimitSQL() . " "; // Use a cache hash to determine if the result-set has changed. // A unique key for this query, with the total_items in case db records // were added since the last cache. This identifies a unique set of // cached data, but we must refer to the list that is cached by a more // generic name. so that we can flush the cache (if records updated) // without knowing the hash. $cache_hash = md5($sql . '|' . $page->total_items); $tmp_prefs = new Prefs('session', array('storagetype' => 'session')); if ($tmp_prefs->get('cache_hash') != $cache_hash) { $cache->delete('question list'); $tmp_prefs->set('cache_hash', $cache_hash); } // First try to return from the cache. if ($cache->exists('question list')) { return $cache->get('question list'); } // The list was not cached, so issue the real query. $qid = $db->query($sql); $list = array(); while ($row = mysql_fetch_assoc($qid)) { $list[] = $row; } // Save this list into the cache. if (isset($list) && !empty($list)) { $cache->set('question list', $list); } return $list; } /* * * * @access public * @param * @return * @author Quinn Comendant * @version 1.0 * @since 08 Nov 2013 18:48:25 */ static public function getListBySurvey($survey_id, $rank=0) { $db =& DB::getInstance(); $qid = $db->query(" SELECT question_id, question_text, rank, account_id FROM question_tbl WHERE question_tbl.survey_id = '" . $db->escapeString($survey_id) . "' AND rank > '" . $db->escapeString($rank) . "' ORDER BY question_tbl.rank ASC "); $results = array(); while ($row = mysql_fetch_assoc($qid)) { $results[] = $row; } return $results; } /* * Save the question option actions. * * @access public * @param int $question_id The ID of the question. * @param array $frm Form values submitted from the Question actions table (options, actions, targets). * @return null * @author Quinn Comendant * @version 1.0 * @since 08 Nov 2013 23:44:14 */ static public function saveQuestionActions($question_id, $frm) { $app =& App::getInstance(); $db =& DB::getInstance(); if (!isset($frm['actions'])) { $app->logMsg(sprintf('No actions posted: %s', getDump($frm)), LOG_DEBUG, __FILE__, __LINE__); return false; } foreach ($frm['actions'] as $i => $a) { switch ($a) { case 'continue' : break; case 'goto' : $db->query(" REPLACE INTO question_action_tbl ( question_id, question_option, question_action, target_question_id ) VALUES ( '" . $db->escapeString($question_id) . "', '" . $db->escapeString($frm['options'][$i]) . "', '" . $db->escapeString($a) . "', '" . $db->escapeString($frm['targets'][$i]) . "' ) "); break; case 'end' : $db->query(" REPLACE INTO question_action_tbl ( question_id, question_option, question_action, target_question_id ) VALUES ( '" . $db->escapeString($question_id) . "', '" . $db->escapeString($frm['options'][$i]) . "', '" . $db->escapeString($a) . "', NULL ) "); break; default : $app->logMsg(sprintf('Unknown question action: %s', $a), LOG_ERR, __FILE__, __LINE__); return; } $app->logMsg(sprintf('Saved question_id %s action for option %s: %s %s', $question_id, $frm['options'][$i], $a, (isset($frm['targets'][$i]) ? $frm['targets'][$i] : '')), LOG_DEBUG, __FILE__, __LINE__); } } /* * * * @access public * @param * @return * @author Quinn Comendant * @version 1.0 * @since 03 Nov 2013 13:31:12 */ static public function getParsedJSON($question_text) { $app =& App::getInstance(); $q_parsed = self::parseOptions($question_text); $q_parsed_json = json_encode($q_parsed, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT); if (JSON_ERROR_NONE != json_last_error()) { $app->logMsg(sprintf('json_encode error: %s', json_last_error()), LOG_WARNING, __FILE__, __LINE__); } return $q_parsed_json; } /* * Returns the values from question_tbl for the first question in the specified survey_id. * * @access public * @param int $survey_id * @return mixed Return value from mysql_fetch_assoc() for the query. * @return * @author Quinn Comendant * @version 1.0 * @since 09 Nov 2013 15:20:26 */ static public function getFirstInSurvey($survey_id) { $db =& DB::getInstance(); $app =& App::getInstance(); $qid = $db->query(" SELECT * FROM question_tbl WHERE survey_id = '" . $db->escapeString($survey_id) . "' ORDER BY rank ASC LIMIT 1 "); if (mysql_num_rows($qid) > 0) { return mysql_fetch_assoc($qid); } else { $app->logMsg(sprintf('Survey_id %s has no questions.', $survey_id), LOG_WARNING, __FILE__, __LINE__); return false; } } /* * Finds the question *after* the given question_id for survey_id, defined by rank. * * @access public * @param int $question_id * @param int $survey_id * @return mixed Return value from mysql_fetch_assoc(). * @author Quinn Comendant * @version 1.0 * @since 02 Nov 2013 18:53:23 */ static public function getNextInSurvey($question_id, $survey_id) { $db =& DB::getInstance(); $q = parent::get(array('question_id' => $question_id)); // Get the question in this survey with the next-greater rank value. $qid = $db->query(" SELECT question_id FROM question_tbl WHERE survey_id = '" . $db->escapeString($survey_id) . "' AND rank > '" . $db->escapeString($q['rank']) . "' ORDER BY rank ASC LIMIT 1 "); return mysql_fetch_assoc($qid); } /* * Returns the values from question_action_tbl for question_id for the option provided in $response. * If there is no action defined for the given response, false is returned (which should be * interpreted as 'just continue to the next question') * * @access public * @param int $question_id * @param string $response The text reply sent from the participant. * @return mixed Return value from mysql_fetch_assoc(). * @author Quinn Comendant * @version 1.0 * @since 09 Nov 2013 17:42:49 */ static public function getAction($question_id, $response) { // TODO: Need to parse $response in case either 'a' or 'the answer' is given. // I.e., need to do the opposite of function Question::getParsedResponse(). $db =& DB::getInstance(); $qid = $db->query(" SELECT * FROM question_action_tbl WHERE question_id = '" . $db->escapeString($question_id) . "' AND question_option ='" . $db->escapeString(strtolower(trim($response))) . "' "); return mysql_fetch_assoc($qid); } /* * This function parses the question, with or without options, and returns the options as an array, or empty array for no options. * * @access public * @param string $text The text of the question. * @return mixed An array containing the type of question and its options; or false if the question could not be parsed. * @author Quinn Comendant * @version 1.0 * @since 02 Nov 2013 20:09:11 */ static public function parseOptions($text) { $app =& App::getInstance(); $options = array(); // Choose one only (radio). if (preg_match_all('/(?P[A-Z])\) *(?P[\w ]+\b)[,; ]?(?!\))/ui', $text, $matches)) { if (count(array_unique($matches['options'])) < count($matches['options']) || count(array_unique($matches['labels'])) < count($matches['labels'])) { $app->logMsg(sprintf('Duplicate option keys or values: %s', $text), LOG_NOTICE, __FILE__, __LINE__); return false; } $matches['options'] = array_map('trim', $matches['options']); $matches['labels'] = array_map('trim', $matches['labels']); foreach ($matches['options'] as $i => $o) { if (!ctype_upper($o) || '' == $matches['labels'][$i]) { // Error on non-uppercase option or empty label. $app->logMsg(sprintf('Non-uppercase option or empty label: %s => %s', $o, $matches['labels'][$i]), LOG_NOTICE, __FILE__, __LINE__); return false; } $options[strtolower($o)] = $matches['labels'][$i]; } return array( 'type' => 'radio', 'text' => $text, 'options' => $options ); } // Choose all that apply [checkboxes]. if (preg_match_all('/(?P[A-Z])\] *(?P[\w ]+)[,; ]?(?!\])/u', $text, $matches)) { if (count(array_unique($matches['options'])) < count($matches['options']) || count(array_unique($matches['labels'])) < count($matches['labels'])) { return false; // Duplicate option keys or values. } foreach ($matches['options'] as $i => $o) { $options[strtolower($o)] = trim($matches['labels'][$i]); } return array( 'type' => 'checkboxes', 'text' => $text, 'options' => $options ); } // Open question (textfield). if (preg_match('/\?$/u', $text)) { return array( 'type' => 'text', 'text' => $text, 'options' => $options ); } // Statement (not a question) if (preg_match('/[\.!]$/u', $text)) { return array( 'type' => 'statement', 'text' => $text, 'options' => $options ); } $app->logMsg(sprintf('Question options failed parsing: %s', $text), LOG_INFO, __FILE__, __LINE__); return false; } /* * Tests if the given text response conforms with the format of the question. * * @access public * @param int $question_id The ID of the question * @param string $response_text The Body received from the SMS. * @return bool True of it matches, false otherwise. * @author Quinn Comendant * @version 1.0 * @since 02 Nov 2013 20:06:11 */ static public function validateResponse($question_id, $response_text) { $app =& App::getInstance(); $q = parent::get(array('question_id' => $question_id)); $q_parsed = self::parseOptions($q['question_text']); if ('' == trim($response_text)) { $app->logMsg(sprintf('Received empty response.', null), LOG_INFO, __FILE__, __LINE__); return false; } if ('statement' == $q_parsed['type']) { // Statements should not receive a response. $app->logMsg(sprintf('Received a response to a statement.', null), LOG_NOTICE, __FILE__, __LINE__); return false; } if ('text' == $q_parsed['type']) { // Any response value is a valid response. return true; } $q_parsed_options_lowercase = array_map('strtolower', $q_parsed['options']); if ('radio' == $q_parsed['type'] && (isset($q_parsed['options'][strtolower(trim($response_text))]) || in_array(strtolower(trim($response_text)), $q_parsed_options_lowercase)) ) { // The response value matches one of the options return true; } if ('checkboxes' == $q_parsed['type']) { $return = 0; $response_array = array_diff(preg_split('/[\W -]/', $response_text), array('')); foreach ($response_array as $v) { if (isset($q_parsed['options'][strtolower(trim($v))]) || in_array(strtolower(trim($v)), $q_parsed_options_lowercase)) { $return++; } else { $return--; } } // The response had some unmatched values. :( return $return == sizeof($response_array); } return false; } /* * Returns the parsed value of a response (i.e. instead of "A" it returns "The option text") * * @access public * @param int $question_id The ID of the question * @param string $response_text The Body received from the SMS. * @return string A human-readable value for the response. * @author Quinn Comendant * @version 1.0 * @since 02 Nov 2013 20:06:11 */ static public function getParsedResponse($question_id, $response_text) { $app =& App::getInstance(); $q = parent::get(array('question_id' => $question_id)); $q_parsed = self::parseOptions($q['question_text']); if ('' == trim($response_text) || !$q_parsed['type']) { $app->logMsg(sprintf('Response was empty or failed to parse question text "%s"', $q['question_text']), LOG_DEBUG, __FILE__, __LINE__); return false; } if ('statement' == $q_parsed['type']) { // There shouldn't be any response. return $response_text; } if ('text' == $q_parsed['type']) { // Any response value is a valid response. return $response_text; } $q_parsed_options_lowercase = array_map('strtolower', $q_parsed['options']); if ('radio' == $q_parsed['type']) { if (isset($q_parsed['options'][strtolower(trim($response_text))])) { // The response value matches one of the options. return $q_parsed['options'][strtolower(trim($response_text))]; } else if (false !== ($key_of_match = array_search(strtolower(trim($response_text)), $q_parsed_options_lowercase))) { // The response value matches one of the labels. return $q_parsed['options'][$key_of_match]; } } if ('checkboxes' == $q_parsed['type']) { $return = array(); $response_array = array_diff(preg_split('/[\W -]/', $response_text), array('')); foreach ($response_array as $v) { if (isset($q_parsed['options'][strtolower(trim($v))])) { // The response value matches one of the options. $return[] = $q_parsed['options'][strtolower(trim($v))]; } else if (false !== ($key_of_match = array_search(strtolower(trim($v)), $q_parsed_options_lowercase))) { // The response value matches one of the labels. $return[] = $q_parsed['options'][$key_of_match]; } } if (!empty($return)) { // Return only if we found something. return join(', ', $return); } } $app->logMsg(sprintf('Failed to get parsed response for question %s: %s', $question_id, $response_text), LOG_WARNING, __FILE__, __LINE__); return false; } /* * Returns an array of question statistics. * * @access public * @param int $question_id * @return mixed The result from mysql_fetch_assoc() for the query. * @author Quinn Comendant * @version 1.0 * @since 10 Nov 2013 14:33:17 */ static public function getStats($question_id) { $db =& DB::getInstance(); $qid = $db->query(" SELECT ( SELECT COUNT(*) FROM response_tbl WHERE question_id = '" . $db->escapeString($question_id) . "' ) AS num_responses, ( SELECT ROUND((num_responses / COUNT(*)) * 100, 2) FROM question_tbl LEFT JOIN participant_survey_tbl ON (question_tbl.survey_id = participant_survey_tbl.survey_id) WHERE question_id = '" . $db->escapeString($question_id) . "' ) AS percent_of_participants "); return mysql_fetch_assoc($qid); } }