quiz-6.0.0-alpha4/quiz.module
quiz.module
<?php
/**
* @file
* Hooks for the quiz module.
*/
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultAllowed;
use Drupal\Core\Access\AccessResultForbidden;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Database\Query\AlterableInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\field\Entity\FieldConfig;
use Drupal\quiz\Entity\QuizResult;
use Drupal\quiz\Entity\QuizResultAnswerType;
use Drupal\quiz\Entity\QuizResultType;
use Drupal\quiz\Entity\QuizType;
use Drupal\quiz\Util\QuizUtil;
use Drupal\views\ViewExecutable;
/**
* @file
* Contains quiz.module.
*/
/**
* Implements hook_help().
*/
function quiz_help($route_name, RouteMatchInterface $route_match) {
if ($route_name == 'help.page.quiz') {
return t('<p>The quiz module allows users to administer a quiz, as a sequence of questions, and track the answers given. It allows for the creation of questions (and their answers), and organizes these questions into a quiz. Its target audience includes educational institutions, online training programs, employers, and people who just want to add a fun activity for their visitors to their Drupal site.</p>
<p>For more information about Quiz, and resources on how to use Quiz, see the <a href="https://drupal.org/project/quiz">Quiz project website</a></p>');
}
}
/**
* Implements hook_cron().
*/
function quiz_cron(): void {
$db = Drupal::database();
$result_ids = [];
// Remove old quiz results that haven't been finished.
$old_rm_time = Drupal::config('quiz.settings')
->get('remove_partial_quiz_record');
// $time = 0 for never.
if ($old_rm_time) {
$res = $db->select('quiz_result', 'qnr')
->fields('qnr', ['result_id'])
->condition('time_end', 0)
->where('(:request_time - time_start) > :remove_time', [
':request_time' => Drupal::time()->getRequestTime(),
':remove_time' => $old_rm_time,
])
->execute();
while ($result_id = $res->fetchField()) {
$result_ids[$result_id] = $result_id;
}
}
// Remove invalid quiz results.
$inv_rm_time = Drupal::config('quiz.settings')
->get('remove_invalid_quiz_record');
// $time = 0 for never.
if ($inv_rm_time) {
$query = $db->select('quiz_result', 'qnr');
$query->fields('qnr', ['result_id']);
$query->join('quiz', 'qnp', 'qnr.vid = qnp.vid');
// If the user has a limited amount of takes we don't delete invalid
// results.
$db_or = $query->orConditionGroup();
$db_or->isNull('qnp.takes');
$db_or->condition('qnp.takes', 0);
$query->condition($db_or);
$query->condition('qnr.is_invalid', 1);
$query->condition('qnr.time_end', Drupal::time()->getRequestTime() - $inv_rm_time, '<=');
$res = $query->execute();
while ($result_id = $res->fetchField()) {
$result_ids[$result_id] = $result_id;
}
}
$quiz_results = QuizResult::loadMultiple($result_ids);
Drupal::entityTypeManager()->getStorage('quiz_result')->delete($quiz_results);
}
/**
* Implements hook_menu().
*/
function quiz_menu() {
if (Drupal::moduleHandler()->moduleExists('devel_generate')) {
$items['admin/config/development/generate/quiz'] = [
'title' => 'Generate quiz',
'description' => 'Generate a given number of quizzes and questions.',
'access arguments' => ['administer quiz configuration'],
'page callback' => 'drupal_get_form',
'page arguments' => ['quiz_generate_form'],
'file' => 'quiz.devel.inc',
];
return $items;
}
}
/**
* Implements hook_theme().
*/
function quiz_theme($existing, $type, $theme, $path): array {
return [
'quiz' => [
'render element' => 'elements',
],
'quiz_question' => [
'render element' => 'elements',
],
'quiz_result' => [
'render element' => 'elements',
],
'quiz_progress' => [
'variables' => [
'current' => NULL,
'total' => NULL,
],
],
'question_selection_table' => [
'render element' => 'form',
],
'quiz_answer_result' => [
'variables' => [],
],
'quiz_report_form' => [
'render element' => 'form',
'path' => $path . '/theme',
'template' => 'quiz-report-form',
],
'quiz_question_score' => [
'variables' => ['score' => NULL, 'max_score' => NULL, 'class' => NULL],
'template' => 'quiz-question-score',
],
'quiz_jumper' => [
'variables' => ['total' => NULL, 'form' => NULL],
],
'quiz_pager' => [
'variables' => ['total' => 0, 'current' => 0, 'siblings' => 0],
],
'quiz_questions_page' => [
'render element' => 'form',
],
'quiz_result_summary' => [
'variables' => [
'quiz_result' => NULL,
'summary_passfail' => NULL,
'summary_range' => NULL,
'attributes' => NULL,
],
'template' => 'quiz-result-summary',
],
'quiz_result_score' => [
'variables' => [
'quiz_result' => NULL,
'numeric_score' => NULL,
'percentage_score' => NULL,
'question_count' => NULL,
'username' => NULL,
'your_total' => NULL,
'possible_attributes' => NULL,
'percent_attributes' => NULL,
],
'template' => 'quiz-result-score',
],
];
}
/**
* Implements hook_theme_suggestions_HOOK().
*/
function quiz_theme_suggestions_quiz(array $variables): array {
$suggestions = [];
$quiz = $variables['elements']['#quiz'];
$sanitized_view_mode = strtr($variables['elements']['#view_mode'], '.', '_');
$suggestions[] = 'quiz__' . $sanitized_view_mode;
$suggestions[] = 'quiz__' . $quiz->bundle();
$suggestions[] = 'quiz__' . $quiz->bundle() . '__' . $sanitized_view_mode;
$suggestions[] = 'quiz__' . $quiz->id();
$suggestions[] = 'quiz__' . $quiz->id() . '__' . $sanitized_view_mode;
return $suggestions;
}
/**
* Implements hook_theme_suggestions_HOOK().
*/
function quiz_theme_suggestions_quiz_result(array $variables): array {
$suggestions = [];
$quiz_result = $variables['elements']['#quiz_result'];
$sanitized_view_mode = strtr($variables['elements']['#view_mode'], '.', '_');
$suggestions[] = 'quiz_result__' . $sanitized_view_mode;
$suggestions[] = 'quiz_result__' . $quiz_result->bundle();
$suggestions[] = 'quiz_result__' . $quiz_result->bundle() . '__' . $sanitized_view_mode;
$suggestions[] = 'quiz_result__' . $quiz_result->id();
$suggestions[] = 'quiz_result__' . $quiz_result->id() . '__' . $sanitized_view_mode;
return $suggestions;
}
/**
* Implements hook_theme_suggestions_HOOK().
*/
function quiz_theme_suggestions_quiz_result_summary(array $variables) {
$suggestions = [];
$quiz_result = $variables['quiz_result'];
$suggestions[] = 'quiz_result_summary__' . $quiz_result->bundle();
$suggestions[] = 'quiz_result_summary__' . $quiz_result->id();
return $suggestions;
}
/**
* Implements hook_theme_suggestions_HOOK().
*/
function quiz_theme_suggestions_quiz_result_score(array $variables) {
$suggestions = [];
$quiz_result = $variables['quiz_result'];
$suggestions[] = 'quiz_result_score__' . $quiz_result->bundle();
$suggestions[] = 'quiz_result_score__' . $quiz_result->id();
return $suggestions;
}
/**
* Implements hook_field_extra_fields().
*
* Add extra fields for Quiz entities.
*/
function quiz_entity_extra_field_info(): array {
$extra = [];
// Add extra fields for a take quiz button and stats table.
foreach (QuizType::loadMultiple() as $bundle) {
$extra['quiz'][$bundle->id()] = [
'display' => [
'take' => [
'label' => t('Take @quiz button', ['@quiz' => QuizUtil::getQuizName()]),
'description' => t('The take button.'),
'weight' => 10,
],
'stats' => [
'label' => t('@quiz summary', ['@quiz' => QuizUtil::getQuizName()]),
'description' => t('@quiz summary', ['@quiz' => QuizUtil::getQuizName()]),
'weight' => 9,
],
],
];
}
// Allow for configurable feedback bits on the quiz result answer.
$options = quiz_get_feedback_options();
foreach (QuizResultAnswerType::loadMultiple() as $bundle) {
$extra['quiz_result_answer'][$bundle->id()]['display']['table'] = [
'label' => t('Feedback table'),
'description' => t('A table of feedback.'),
'weight' => 0,
'visible' => TRUE,
];
foreach ($options as $option => $label) {
$extra['quiz_result_answer'][$bundle->id()]['display'][$option] = [
'label' => $label,
'description' => t('Feedback for @label.', ['@label' => $label]),
'weight' => 0,
'visible' => FALSE,
];
}
}
// Allow for configurable feedback bits on the quiz result.
foreach (QuizResultType::loadMultiple() as $bundle) {
$extra['quiz_result'][$bundle->id()]['display'] = [
'score' => [
'label' => t('Score'),
'description' => t('The score of the result.'),
'weight' => 1,
],
'questions' => [
'label' => t('Questions'),
'description' => t('The questions in this result.'),
'weight' => 2,
],
'summary' => [
'label' => t('Summary'),
'description' => t('The summary and pass/fail text.'),
'weight' => 3,
],
];
}
return $extra;
}
/**
* Implements hook_user_cancel().
*
* Reassign Quiz results to the anonymous user, if requested.
*/
function quiz_user_cancel($edit, $account, $method): void {
if ($method == 'user_cancel_reassign') {
Drupal::database()
->query("UPDATE {quiz_result} SET uid = 0 WHERE uid = :uid", [':uid' => $account->id()]);
}
}
/**
* Implements hook_user_delete().
*/
function quiz_user_delete($account): void {
if (Drupal::config('quiz.settings')->get('durod', 0)) {
_quiz_delete_users_results($account->id());
}
}
/**
* Deletes all results associated with a given user.
*
* @param int $uid
* The user ID.
*/
function _quiz_delete_users_results(int $uid): void {
$res = Drupal::database()
->query("SELECT result_id FROM {quiz_result} WHERE uid = :uid", [':uid' => $uid]);
$result_ids = [];
while ($result_id = $res->fetchField()) {
$result_ids[] = $result_id;
}
$controller = \Drupal::entityTypeManager()->getStorage('quiz_result');
$entities = $controller->loadMultiple($result_ids);
$controller->delete($entities);
}
/**
* Implements hook_quiz_access().
*
* Can a user take this quiz?
*/
function quiz_quiz_access(EntityInterface $entity, $operation, AccountInterface $account) {
if ($operation == 'take') {
$user_is_admin = $entity->access('update');
// Make sure this is available.
if (!$entity->get('quiz_date')->isEmpty()) {
// Compare current GMT time to the open and close dates
// (which should still be in GMT time).
$request_time = Drupal::time()->getRequestTime();
$quiz_date = $entity->get('quiz_date')->get(0)->getValue();
$quiz_open = $request_time >= strtotime($quiz_date['value']);
$quiz_closed = $request_time >= strtotime($quiz_date['end_value']);
if (!$quiz_open || $quiz_closed) {
if ($user_is_admin) {
$hooks['admin_ignore_date'] = [
'success' => TRUE,
'message' => (string) t('You are marked as an administrator or owner for this @quiz. While you can take this @quiz, the open/close times prohibit other users from taking this @quiz.', ['@quiz' => QuizUtil::getQuizName()]),
];
}
else {
if ($quiz_closed) {
return AccessResultForbidden::forbidden((string) t('This @quiz is closed.', ['@quiz' => QuizUtil::getQuizName()]));
}
if (!$quiz_open) {
return AccessResultForbidden::forbidden((string) t('This @quiz is not yet open.', ['@quiz' => QuizUtil::getQuizName()]));
}
}
}
}
// Check to see if this user is allowed to take the quiz again:
if ($entity->get('takes')->getString() > 0) {
$taken = Drupal::database()
->query('SELECT COUNT(*) AS takes FROM {quiz_result} WHERE uid = :uid AND qid = :qid', [
':uid' => $account->id(),
':qid' => $entity->id(),
])
->fetchField();
$t = Drupal::translation();
$allowed_times = $t->formatPlural($entity->get('takes')->getString(), '1 time', '@count times');
$taken_times = $t->formatPlural($taken, '1 time', '@count times');
// The user has already taken this quiz.
if ($taken) {
if (FALSE && $user_is_admin) {
$hooks['owner_limit'] = [
'success' => TRUE,
'message' => (string) t('You have taken this @quiz already. You are marked as an owner or administrator for this quiz, so you can take this quiz as many times as you would like.', ['@quiz' => QuizUtil::getQuizName()]),
];
}
// If the user has already taken this quiz too many times,
// stop the user.
elseif ($taken >= $entity->get('takes')->getString()) {
/** @var \Drupal\quiz\Services\QuizSessionInterface $quiz_session */
$quiz_session = Drupal::service('quiz.session');
if ($entity->allow_resume->value && $entity->getResumeableResult($account)) {
// Quiz is resumable and there is an active attempt, so we should
// allow them to finish it as it won't be creating a new attempt.
// This is the blocker, so we do nothing here. The resume handles
// in the take function.
}
elseif (!$quiz_session->isTakingQuiz($entity)) {
// If result is in session, don't check the attempt limit.
// @todo would be to split up "take" into something like "start" and "continue" an attempt.
$hooks['attempt_limit'] = [
'success' => FALSE,
'message' => (string) t('You have already taken this @quiz @really. You may not take it again.', [
'@quiz' => QuizUtil::getQuizName(),
'@really' => $taken_times,
]),
];
}
}
// If the user has taken the quiz more than once, see if we should
// report this.
elseif ($entity->show_attempt_stats->value) {
$hooks['attempt_limit'] = [
'success' => TRUE,
'message' => (string) t("You can only take this @quiz @allowed. You have taken it @really.", [
'@quiz' => QuizUtil::getQuizName(),
'@allowed' => $allowed_times,
'@really' => $taken_times,
]),
'weight' => -10,
];
}
}
}
// Check to see if the user is registered, and user already passed
// this quiz.
if ($entity->show_passed->value && $account->id() && $entity->isPassed($account)) {
$hooks['already_passed'] = [
'success' => TRUE,
'message' => (string) t('You have already passed this @quiz.', ['@quiz' => QuizUtil::getQuizName()]),
'weight' => 10,
];
}
if (!empty($hooks)) {
foreach ($hooks as $hook) {
if (!$hook['success']) {
return AccessResultForbidden::forbidden($hook['message']);
}
}
}
if (!empty($hooks)) {
foreach ($hooks as $hook) {
if ($hook['success']) {
if (Drupal::routeMatch()->getRouteName() == 'entity.quiz.canonical') {
// Only display if we are viewing the quiz.
Drupal::messenger()->addWarning($hook['message']);
}
return [AccessResultAllowed::allowed()];
}
}
}
// Check permission and node access.
if (!Drupal::currentUser()->hasPermission('access quiz') || !$entity->access('view')) {
return [AccessResultForbidden::forbidden((string) t('You are not allowed to take this @quiz.', ['@quiz' => QuizUtil::getQuizName()]))];
}
}
}
/**
* Retrieve question type plugins.
*
* @return array
* Array of question types.
*/
function quiz_get_question_types(): array {
$pluginManager = Drupal::service('plugin.manager.quiz.question');
$plugins = $pluginManager->getDefinitions();
if (empty($plugins)) {
Drupal::messenger()->addWarning(t('You need to install and enable at least one question type to use Quiz.'));
}
return $plugins;
}
/**
* Format a number of seconds to a hh:mm:ss format.
*
* @param int $time_in_sec
* Integers time in seconds.
*
* @return string
* String time in min : sec format.
*/
function _quiz_format_duration(int $time_in_sec): string {
$hours = intval($time_in_sec / 3600);
$min = intval(($time_in_sec - $hours * 3600) / 60);
$sec = $time_in_sec % 60;
if (strlen($min) == 1) {
$min = '0' . $min;
}
if (strlen($sec) == 1) {
$sec = '0' . $sec;
}
return "$hours:$min:$sec";
}
/**
* Get the feedback options for Quizzes.
*/
function quiz_get_feedback_options(): array {
$feedback_options = Drupal::moduleHandler()->invokeAll('quiz_feedback_options');
$view_modes = Drupal::service('entity_display.repository')->getViewModes('quiz_question');
$feedback_options["quiz_question_view_full"] = t('Question: Full');
foreach ($view_modes as $view_mode => $info) {
$feedback_options["quiz_question_view_" . $view_mode] = t('Question: @label', ['@label' => $info['label']]);
}
$feedback_options += [
'attempt' => t('Attempt'),
'choice' => t('Choices'),
'correct' => t('Whether correct'),
'score' => t('Score'),
'answer_feedback' => t('Answer feedback'),
'question_feedback' => t('Question feedback'),
'solution' => t('Correct answer'),
'quiz_feedback' => t('@quiz feedback', ['@quiz' => QuizUtil::getQuizName()]),
];
Drupal::moduleHandler()->alter('quiz_feedback_options', $feedback_options);
return $feedback_options;
}
/**
* Implements hook_form_FORM_ID_alter().
*
* Adds a checkbox for controlling field edit access to fields added to
* quizzes.
*/
function quiz_form_field_config_edit_form_alter(&$form, FormStateInterface $form_state): void {
$field = $form_state->getFormObject()->getEntity();
if ($field->getTargetEntityTypeId() != 'quiz_result') {
return;
}
$form['third_party_settings']['quiz']['show_field'] = [
'#type' => 'checkbox',
'#title' => t('Show this field on @quiz start.', ['@quiz' => QuizUtil::getQuizName()]),
'#default_value' => $field->getThirdPartySetting('quiz', 'show_field', TRUE),
'#description' => t('If checked, this field will be presented when starting a quiz.'),
];
}
/**
* Implements hook_field_access().
*
* Don't show the user fields that weren't marked as quiz result fields.
*/
function quiz_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, ?FieldItemListInterface $items = NULL): AccessResultInterface {
if ($field_definition->getTargetEntityTypeId() == 'quiz_result') {
if (is_a($field_definition, FieldConfig::class)) {
/** @var \Drupal\field\Entity\FieldConfig $field_definition */
if (!$field_definition->getThirdPartySetting('quiz', 'show_field')) {
return AccessResult::forbidden('quiz_show_field');
}
}
}
return AccessResult::neutral();
}
/**
* Implements hook_page_attachments().
*
* Add Quiz CSS to all pages.
*/
function quiz_page_attachments(&$page): void {
$page['#attached']['library'][] = 'quiz/styles';
}
/**
* Implements hook_query_TAG_alter().
*
* Add randomization to the categorized question build generated by
* entityQuery().
*/
function quiz_query_quiz_random_alter(AlterableInterface $query): void {
$query->orderRandom();
}
/**
* Help us with special pagination.
*
* Why not the Drupal theme_pager()?
*
* It uses query strings. We have access on each menu argument (quiz question
* number) so we unfortunately cannot use it.
*/
function _quiz_pagination_helper($total, $perpage = NULL, $current = NULL, $siblings = NULL): array {
$result = [];
if (isset($total, $perpage) === TRUE) {
$result = range(1, ceil($total / $perpage));
if (isset($current, $siblings) === TRUE) {
if (($siblings = floor($siblings / 2) * 2 + 1) >= 1) {
$result = array_slice($result, max(0, min(count($result) - $siblings, intval($current) - ceil($siblings / 2))), $siblings);
}
}
}
return $result;
}
/**
* Implements hook_views_data_alter().
*
* Add the wildcard Quiz result answer fields to Views.
*/
function quiz_views_data_alter(&$data): void {
$data['quiz_result']['quiz_result_answers'] = [
'title' => 'All answers',
'help' => 'Display all answers for a Quiz result in separate columns.',
'field' => [
'id' => 'quiz_result_answers',
],
];
$data['quiz_result']['quiz_result_answer'] = [
'title' => 'Single answers',
'help' => 'Display an answer for a specific question in a Quiz result.',
'field' => [
'id' => 'quiz_result_answer',
],
];
}
/**
* Implements hook_views_pre_view().
*
* Replace the static field with dynamic fields.
*
* @todo this only works on a single quiz, with the first argument being a quiz
* ID (e.g. quiz/1/results). Should be expanded to make argument configurable.
*/
function quiz_views_pre_view(ViewExecutable $view, $display_id, array &$args): void {
$fields = $view->getHandlers('field');
foreach ($fields as $field_name => $field) {
if ($field['id'] == 'quiz_result_answers') {
$quiz = Drupal::service('entity_type.manager')
->getStorage('quiz')
->load($args[0]);
$i = 0;
foreach ($quiz->getQuestions() as $quizQuestionRelationship) {
$quizQuestion = $quizQuestionRelationship->getQuestion();
if ($quizQuestion->isGraded()) {
$i++;
$newfield = [];
$newfield['id'] = 'quiz_result_answer';
$newfield['field'] = 'quiz_result_answer';
$newfield['table'] = 'quiz_result';
$newfield['alter'] = [];
$newfield['label'] = t('@num. @question', [
'@num' => $i,
'@question' => $quizQuestion->get('title')->value,
]);
$newfield['qqid'] = $quizQuestion->id();
$newfield['entity_type'] = 'quiz_result';
$newfield['plugin_id'] = 'quiz_result_answer';
$view->setHandler($view->current_display, 'field', 'answer_' . $quizQuestion->id(), $newfield);
}
}
// Remove placeholder field.
$view->setHandlerOption($view->current_display, 'field', $field_name, 'exclude', TRUE);
}
}
}
/**
* Prepares variables for quiz templates.
*
* Default template: quiz.html.twig.
*
* @param array $variables
* An associative array containing:
* - elements: An associative array containing rendered fields.
* - attributes: HTML attributes for the containing element.
*/
function template_preprocess_quiz(array &$variables): void {
/** @var \Drupal\quiz\Entity\Quiz $quiz */
$quiz = $variables['elements']['#quiz'];
$variables['quiz'] = $quiz;
// Helpful $content variable for templates.
$variables['content'] = [];
foreach (Element::children($variables['elements']) as $key) {
$variables['content'][$key] = $variables['elements'][$key];
}
}
/**
* Prepares variables for quiz-question templates.
*
* Default template: quiz-question.html.twig.
*
* @param array $variables
* An associative array containing:
* - elements: An associative array containing rendered fields.
* - attributes: HTML attributes for the containing element.
*/
function template_preprocess_quiz_question(array &$variables): void {
/** @var \Drupal\quiz\Entity\QuizResult $quiz_question */
$quiz_question = $variables['elements']['#quiz_question'];
$variables['quiz_question'] = $quiz_question;
// Helpful $content variable for templates.
$variables['content'] = [];
foreach (Element::children($variables['elements']) as $key) {
$variables['content'][$key] = $variables['elements'][$key];
}
}
/**
* Prepares variables for quiz-result templates.
*
* Default template: quiz-result.html.twig.
*
* @param array $variables
* An associative array containing:
* - elements: An associative array containing rendered fields.
* - attributes: HTML attributes for the containing element.
*/
function template_preprocess_quiz_result(array &$variables): void {
/** @var \Drupal\quiz\Entity\QuizResult $quiz_result */
$quiz_result = $variables['elements']['#quiz_result'];
$variables['quiz_result'] = $quiz_result;
// Helpful $content variable for templates.
$variables['content'] = [];
foreach (Element::children($variables['elements']) as $key) {
$variables['content'][$key] = $variables['elements'][$key];
}
}
/**
* Implements hook_entity_bundle_info_alter().
*
* Map question and result classes to bundles.
*/
function quiz_entity_bundle_info_alter(array &$bundles): void {
/** @var \Drupal\quiz\Plugin\QuizQuestionPluginManager $pluginManager */
$pluginManager = Drupal::service('plugin.manager.quiz.question');
$plugins = $pluginManager->getDefinitions();
foreach ($plugins as $key => $plugin) {
if (isset($bundles['quiz_question'][$key])) {
$bundles['quiz_question'][$key]['class'] = $plugin['class'];
$bundles['quiz_result_answer'][$key]['class'] = $plugin['handlers']['response'];
}
}
}
