From 41765ffdd5ad233a1b1bcc5a894624c10c411099 Mon Sep 17 00:00:00 2001 From: Markus Heck Date: Tue, 7 May 2024 01:22:06 +0200 Subject: [PATCH] reworked and refactored view page + code improvements + fixes --- classes/event/question_answered.php | 19 +++ classes/external/answer_questions.php | 20 ++-- classes/local/helpers.php | 29 +++++ classes/local/output/pages/view_page.php | 14 +-- classes/output/view_renderer.php | 142 +++++++++++++++++++++++ templates/questions.mustache | 51 ++++++++ 6 files changed, 261 insertions(+), 14 deletions(-) create mode 100644 classes/event/question_answered.php create mode 100644 classes/output/view_renderer.php create mode 100644 templates/questions.mustache diff --git a/classes/event/question_answered.php b/classes/event/question_answered.php new file mode 100644 index 0000000..c3a7c8f --- /dev/null +++ b/classes/event/question_answered.php @@ -0,0 +1,19 @@ +data['crud'] = 'u'; + $this->data['edulevel'] = self::LEVEL_PARTICIPATING; + } + + public static function get_name() { + return get_string('event_question_answered', 'mod_adleradaptivity'); + } +} \ No newline at end of file diff --git a/classes/external/answer_questions.php b/classes/external/answer_questions.php index a0f93a7..18b0285 100644 --- a/classes/external/answer_questions.php +++ b/classes/external/answer_questions.php @@ -18,6 +18,7 @@ use external_value; use invalid_parameter_exception; use mod_adleradaptivity\local\completion_helpers; +use mod_adleradaptivity\local\db\adleradaptivity_task_repository; use mod_adleradaptivity\local\helpers; use mod_adleradaptivity\moodle_core; use moodle_database; @@ -124,7 +125,10 @@ public static function execute(array $module, array $questions): array { $params = self::validate_parameters(self::execute_parameters(), ['module' => $module, 'questions' => $questions]); $module = external_helpers::validate_module_params_and_get_module($params['module']); $context = context_module::instance($module->id); + + // permission checks static::validate_context($context); + require_capability('mod/adleradaptivity:create_and_edit_own_attempt', $context); $questions = self::validate_and_enhance_questions($questions, $module->instance); @@ -157,9 +161,10 @@ public static function execute(array $module, array $questions): array { * @throws invalid_parameter_exception If a question does not exist. */ protected static function validate_and_enhance_questions(array $questions, string $instance_id): array { + $task_repository = new adleradaptivity_task_repository(); foreach ($questions as $key => $question) { try { - $task = helpers::get_task_by_question_uuid($question['uuid'], $instance_id); + $task = $task_repository->get_task_by_question_uuid($question['uuid'], $instance_id); $questions[$key]['task'] = $task; } catch (moodle_exception $e) { throw new invalid_parameter_exception('Question with uuid ' . $question['uuid'] . ' does not exist.'); @@ -168,6 +173,7 @@ protected static function validate_and_enhance_questions(array $questions, strin return $questions; } + // TODO: move somewhere else, it is now not only used by this external class, but also in a moodle view /** * Determines the completion status of the module. * This is the completion state of the module, saved in the database and not with the currently submitted answers. @@ -176,7 +182,7 @@ protected static function validate_and_enhance_questions(array $questions, strin * @param stdClass $module Moodle module instance. * @return string Completion status. */ - protected static function determine_module_completion_status(completion_info $completion, stdClass $module): string { + public static function determine_module_completion_status(completion_info $completion, stdClass $module): string { $completionState = $completion->get_data($module)->completionstate; return ($completionState == COMPLETION_COMPLETE || $completionState == COMPLETION_COMPLETE_PASS) ? completion_helpers::STATUS_CORRECT @@ -303,7 +309,7 @@ protected static function process_questions(array $questions, int $time_at_reque // start processing the questions foreach ($questions as $key => $question) { - self::process_individual_question($question, $time_at_request_start, $quba); + self::process_single_question($question, $time_at_request_start, $quba); } // save current questions usage @@ -321,12 +327,12 @@ protected static function process_questions(array $questions, int $time_at_reque /** * Processes an individual question. * - * @param array $question Question data. + * @param array $question Question data containing: uuid, answer (json encoded array of booleans) * @param int $time_at_request_start Timestamp at the start of the request. * @param question_usage_by_activity $quba Question usage by activity object. * @throws invalid_parameter_exception If an unsupported question type is encountered. */ - private static function process_individual_question(array &$question, int $time_at_request_start, question_usage_by_activity $quba) { + public static function process_single_question(array $question, int $time_at_request_start, question_usage_by_activity $quba) { $question['question_object'] = $quba->get_question(helpers::get_slot_number_by_uuid($question['uuid'], $quba)); $question_type_class = get_class($question['question_object']->qtype); @@ -357,12 +363,12 @@ private static function process_individual_question(array &$question, int $time_ * @return completion_info Completion information after update. * @throws moodle_exception If completion is not enabled for the module. */ - private static function update_module_completion(stdClass $module): completion_info { + public static function update_module_completion(stdClass $module, int $user_id = 0): completion_info { $course = moodle_core::get_course($module->course); $completion = new completion_info($course); if ($completion->is_enabled($module)) { - $completion->update_state($module, COMPLETION_COMPLETE); + $completion->update_state($module, COMPLETION_COMPLETE, $user_id); } else { throw new moodle_exception('Completion is not enabled for this module.'); } diff --git a/classes/local/helpers.php b/classes/local/helpers.php index da49d25..ae980b2 100644 --- a/classes/local/helpers.php +++ b/classes/local/helpers.php @@ -41,6 +41,35 @@ public static function load_adleradaptivity_attempt_by_cmid($cmid) { return $DB->get_records_sql($sql, [$modulecontext->id]); } + /** + * Retrieves the course module ID (cmid) for a given question usage ID. + * + * @param int $quid The ID of the question usage. + * @return int The course module ID (cmid) associated with the question usage. + * @throws dml_exception If there's an error with the database query. + * @throws dml_missing_record_exception If the expected records are not found. + */ + public static function get_cmid_for_question_usage($quid) { + global $DB; + + // First, retrieve the contextid from the question_usages table using the question usage ID + $contextid = $DB->get_field('question_usages', 'contextid', ['id' => $quid], MUST_EXIST); + + if (!$contextid) { + throw new dml_missing_record_exception('context not found for the provided question usage ID'); + } + + // Now, use the contextid to find the corresponding cmid in the context table + // Note: CONTEXT_MODULE is a constant equal to 80, representing the context level for course modules in Moodle. + $cmid = $DB->get_field_select('context', 'instanceid', "contextlevel = ? AND id = ?", [CONTEXT_MODULE, $contextid]); + + if (!$cmid) { + throw new dml_missing_record_exception('course module (cmid) not found for the provided context ID'); + } + + return $cmid; + } + /** Gets the attempt object (question usage aka $quba) for the given cm and given user. * If there is no attempt object for the given cm and user, a new attempt object is created. * If there is more than one attempt object for the given cm and user, an exception is thrown. diff --git a/classes/local/output/pages/view_page.php b/classes/local/output/pages/view_page.php index 0625912..17080ce 100644 --- a/classes/local/output/pages/view_page.php +++ b/classes/local/output/pages/view_page.php @@ -70,7 +70,7 @@ public function __construct() { $this->check_attempt_permissions($module_context, $adleradaptivity_attempt); // continue setting up variables - $tasks = $this->load_tasks_with_questions_sorted_by_difficulty($quba, $module_instance); + $tasks = $this->load_tasks_with_questions_sorted_by_difficulty($quba, $module_instance, $module_context); // Trigger course_module_viewed event and completion. @@ -215,12 +215,12 @@ private function define_page_meta_information(stdClass $cm, stdClass $module_ins * @throws dml_exception * @throws moodle_exception */ - private function load_tasks_with_questions(question_usage_by_activity $quba, stdClass $module_instance): array { + private function load_tasks_with_questions(question_usage_by_activity $quba, stdClass $module_instance, context_module $module_context): array { $slots = $quba->get_slots(); $tasks = []; // This will be an array of tasks, each containing its questions foreach ($slots as $slot) { - $this->insert_question_from_slot_into_tasks_array($quba, $slot, $module_instance, $tasks); + $this->insert_question_from_slot_into_tasks_array($quba, $slot, $module_instance, $tasks, $module_context); } return $tasks; } @@ -234,8 +234,8 @@ private function load_tasks_with_questions(question_usage_by_activity $quba, std * @throws dml_exception * @throws moodle_exception */ - private function load_tasks_with_questions_sorted_by_difficulty(question_usage_by_activity $quba, stdClass $module_instance): array { - $tasks = $this->load_tasks_with_questions($quba, $module_instance); + private function load_tasks_with_questions_sorted_by_difficulty(question_usage_by_activity $quba, stdClass $module_instance, context_module $module_context): array { + $tasks = $this->load_tasks_with_questions($quba, $module_instance, $module_context); return view_page::sort_questions_in_tasks_by_difficulty($tasks); } @@ -247,9 +247,9 @@ private function load_tasks_with_questions_sorted_by_difficulty(question_usage_b * @throws dml_exception * @throws moodle_exception */ - private function insert_question_from_slot_into_tasks_array(question_usage_by_activity $quba, int $slot, stdClass $module_instance, array &$tasks): void { + private function insert_question_from_slot_into_tasks_array(question_usage_by_activity $quba, int $slot, stdClass $module_instance, array &$tasks, context_module $module_context): void { $question = $quba->get_question($slot); - $adaptivity_question = $this->question_repository->get_adleradaptivity_question_by_question_bank_entries_id($question->questionbankentryid); + $adaptivity_question = $this->question_repository->get_adleradaptivity_question_by_question_bank_entries_id($question->questionbankentryid, $module_context); $task = $this->task_repository->get_task_by_question_uuid($question->idnumber, $module_instance->id); if (!isset($tasks[$task->id])) { diff --git a/classes/output/view_renderer.php b/classes/output/view_renderer.php new file mode 100644 index 0000000..dbef36d --- /dev/null +++ b/classes/output/view_renderer.php @@ -0,0 +1,142 @@ +id; + + $slots = $quba->get_slots(); + // Define the URL for form submission + $actionurl = new moodle_url('/mod/adleradaptivity/processattempt.php', ['id' => $cmid, 'attempt' => $quba->get_id()]); + + // start generating output + $output = ''; + + + // Start the question form with the action URL + $output .= html_writer::start_tag('form', ['method' => 'post', 'action' => $actionurl->out(false), 'enctype' => 'multipart/form-data', 'id' => 'responseform']); + + // Hidden fields for sesskey and possibly other data need to pass through the form to the processattempt page + $output .= html_writer::start_tag('div'); + $output .= html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'sesskey', 'value' => sesskey()]); + $output .= html_writer::end_tag('div'); + + $output .= $this->render_content($tasks, $quba, $cm, $course); + + $output .= html_writer::end_tag('form'); + + + return $output; + } + + /** + * Renders the tasks with its questions of the adleradaptivity module. + * + * @param array $tasks Array of task objects with associated questions. + * @return string HTML to output. + * @throws coding_exception If getting the translation string failed + * @throws moodle_exception If the question cannot be rendered. + */ + private function render_content($tasks, question_usage_by_activity $quba, stdClass $cm, stdClass $course): string { + $completion = new completion_info($course); + + $data = [ + 'is_behat_mode' => defined('BEHAT_SITE_RUNNING') && BEHAT_SITE_RUNNING, + 'module_completed' => answer_questions::determine_module_completion_status($completion, $cm) == completion_helpers::STATUS_CORRECT, + 'tasks' => [] + ]; + + foreach ($tasks as $task_id => $task) { + $task_status = completion_helpers::check_task_status($quba, $task_id, $task['required_difficulty']); + + $taskData = [ + 'title' => $task['title'], + 'optional' => $task['required_difficulty'] === null, + 'difficulty' => $this->get_difficulty_label($task['required_difficulty']), + 'status_success' => in_array($task_status, [completion_helpers::STATUS_CORRECT, completion_helpers::STATUS_OPTIONAL_INCORRECT, completion_helpers::STATUS_OPTIONAL_NOT_ATTEMPTED]), + 'status_message' => get_string($this->get_task_status_message_translation_key($task_status), 'mod_adleradaptivity'), + 'status_behat_class' => $this->get_task_status_behat_class($task_status), + 'questions' => [] + ]; + + foreach ($task['questions'] as $question) { + $options = new question_preview_options($question['question']); + $options->load_user_defaults(); + $options->set_from_request(); + + $taskData['questions'][] = [ + 'content' => $quba->render_question($question['slot'], $options, $this->get_difficulty_label($question['difficulty'])), + 'status_best_try' => completion_helpers::check_question_answered_correctly_once($quba->get_question_attempt($question['slot'])), + ]; + } + + $data['tasks'][] = $taskData; + } + + return $this->render_from_template('mod_adleradaptivity/questions', $data); + } + + /** + * @param string $status One of the STATUS_* constants from completion_helpers. + * @return string The translation key for the status message. + */ + private function get_task_status_message_translation_key(string $status): string { + return match ($status) { + completion_helpers::STATUS_NOT_ATTEMPTED => 'view_task_status_not_attempted', + completion_helpers::STATUS_CORRECT => 'view_task_status_correct', + completion_helpers::STATUS_INCORRECT => 'view_task_status_incorrect', + completion_helpers::STATUS_OPTIONAL_NOT_ATTEMPTED => 'view_task_status_optional_not_attempted', + completion_helpers::STATUS_OPTIONAL_INCORRECT => 'view_task_status_optional_incorrect', + default => 'view_task_status_unknown', + }; + } + + private function get_task_status_behat_class(string $status): string { + return match ($status) { + completion_helpers::STATUS_NOT_ATTEMPTED => 'behat_task-not-attempted', + completion_helpers::STATUS_CORRECT => 'behat_task-correct', + completion_helpers::STATUS_INCORRECT => 'behat_task-incorrect', + completion_helpers::STATUS_OPTIONAL_NOT_ATTEMPTED => 'behat_task-optional-not-attempted', + completion_helpers::STATUS_OPTIONAL_INCORRECT => 'behat_task-optional-incorrect', + default => 'unknown', + }; + } + + /** + * Converts a difficulty code to a human-readable label. + * + * @param int $difficulty The difficulty code. + * @return string The difficulty label. + */ + private function get_difficulty_label($difficulty) { + $difficulties = [ + 0 => get_string('difficulty_0', 'mod_adleradaptivity'), + 100 => get_string('difficulty_100', 'mod_adleradaptivity'), + 200 => get_string('difficulty_200', 'mod_adleradaptivity') + ]; + + return $difficulties[$difficulty] ?? 'unknown'; + } +} diff --git a/templates/questions.mustache b/templates/questions.mustache new file mode 100644 index 0000000..18b389b --- /dev/null +++ b/templates/questions.mustache @@ -0,0 +1,51 @@ +
+ + {{#module_completed}} + + {{/module_completed}} + {{^module_completed}} + + {{/module_completed}} + + + {{#tasks}} +
+
+

{{#str}}view_task_title, mod_adleradaptivity{{/str}}: {{title}}

+ {{#optional}} +

{{#str}}view_task_optional, mod_adleradaptivity{{/str}}

+ {{/optional}} + {{^optional}} +

{{#str}}view_task_required_difficulty, mod_adleradaptivity{{/str}}: {{difficulty}}

+ {{/optional}} + + + {{#status_success}} +

✅ {{{status_message}}}

+ {{/status_success}} + {{^status_success}} + + {{/status_success}} + + +
+ {{#questions}} +
+
+ + {{#status_best_try}} +

✅ {{#str}}view_question_success, mod_adleradaptivity{{/str}}

+ {{/status_best_try}} +
{{{content}}}
+
+ {{/questions}} +
+
+ {{/tasks}} +