From 47c978a178f83d97f2066b164aa00ef8db157d48 Mon Sep 17 00:00:00 2001 From: Martin Gauk Date: Thu, 28 Aug 2025 17:50:58 +0200 Subject: [PATCH 1/3] feat: bridges to retrieve additional attempt information --- .../local/bridge/core_question_preview.php | 67 +++++ classes/local/bridge/mod_quiz.php | 91 ++++++ classes/question_bridge_base.php | 273 ++++++++++++++++++ question.php | 29 ++ 4 files changed, 460 insertions(+) create mode 100644 classes/local/bridge/core_question_preview.php create mode 100644 classes/local/bridge/mod_quiz.php create mode 100644 classes/question_bridge_base.php diff --git a/classes/local/bridge/core_question_preview.php b/classes/local/bridge/core_question_preview.php new file mode 100644 index 00000000..6edac4c2 --- /dev/null +++ b/classes/local/bridge/core_question_preview.php @@ -0,0 +1,67 @@ +. + +namespace qtype_questionpy\local\bridge; + +use qtype_questionpy\question_bridge_base; + +/** + * Bridge class between QuestionPy and core_question_preview. + * + * This class is used to retrieve data that is not available through the Question API. + * + * @package qtype_questionpy + * @author Martin Gauk + * @copyright 2025 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_question_preview extends question_bridge_base { + /** @var \stdClass|null quiz attempt data as stored in database. */ + private $quizattempt; + + /** + * Get additional LMS attributes. + * + * @param string[] $requestedattributes + * @return string[] + */ + protected function get_additional_lms_attributes(array $requestedattributes): array { + $attributes = []; + + if (in_array('attempt_started_at', $requestedattributes)) { + $firststep = $this->attempt->get_step(0); + $attributes['attempt_started_at'] = date('c', $firststep->get_timecreated()); + } + + if (in_array('lms_moodle_component_name', $requestedattributes)) { + $attributes['lms_moodle_component_name'] = 'core_question_preview'; + } + + return $attributes; + } + + /** + * Get user or group id this attempt belongs to. + * + * @return array<'user'|'group', int> + */ + public function get_user_or_group_id(): array { + if ($this->context instanceof \core\context\user) { + return ['user', $this->context->instanceid]; + } + throw new \coding_exception('Expected a user context, got ' . get_class($this->context)); + } +} diff --git a/classes/local/bridge/mod_quiz.php b/classes/local/bridge/mod_quiz.php new file mode 100644 index 00000000..1acd477a --- /dev/null +++ b/classes/local/bridge/mod_quiz.php @@ -0,0 +1,91 @@ +. + +namespace qtype_questionpy\local\bridge; + +use qtype_questionpy\question_bridge_base; + +/** + * Bridge class between QuestionPy and mod_quiz. + * + * This class is used to retrieve data that is not available through the Question API. + * + * @package qtype_questionpy + * @author Martin Gauk + * @copyright 2025 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mod_quiz extends question_bridge_base { + /** @var \stdClass|null quiz attempt data as stored in database. */ + private $quizattempt = null; + + /** + * Get additional LMS attributes. + * + * @param string[] $requestedattributes + * @return string[] + */ + protected function get_additional_lms_attributes(array $requestedattributes): array { + $attributes = []; + + if (in_array('attempt_started_at', $requestedattributes)) { + $attempt = $this->get_quiz_attempt_data(); + $attributes['attempt_started_at'] = date('c', $attempt->timestart); + } + + if (in_array('submission_at', $requestedattributes)) { + $attempt = $this->get_quiz_attempt_data(); + if ($attempt->timefinish) { + $attributes['submission_at'] = date('c', $attempt->timefinish); + } + } + + if (in_array('lms_moodle_component_name', $requestedattributes)) { + $attributes['lms_moodle_component_name'] = 'mod_quiz'; + } + + if (in_array('lms_moodle_module_instance', $requestedattributes)) { + $attempt = $this->get_quiz_attempt_data(); + $attributes['lms_moodle_module_instance'] = (int) $attempt->quiz; + } + + return $attributes; + } + + /** + * Get user or group id this attempt belongs to. + * + * @return array<'user'|'group', int> + */ + public function get_user_or_group_id(): array { + return ['user', $this->get_quiz_attempt_data()->userid]; + } + + /** + * Getter to lazily load quiz attempt data. + * + * @return \stdClass quiz_attempts db record + */ + private function get_quiz_attempt_data(): \stdClass { + global $DB; + + if ($this->quizattempt === null) { + $usageid = $this->attempt->get_usage_id(); + $this->quizattempt = $DB->get_record('quiz_attempts', ['uniqueid' => $usageid], '*', MUST_EXIST); + } + return $this->quizattempt; + } +} diff --git a/classes/question_bridge_base.php b/classes/question_bridge_base.php new file mode 100644 index 00000000..3d65448d --- /dev/null +++ b/classes/question_bridge_base.php @@ -0,0 +1,273 @@ +. + +namespace qtype_questionpy; + +use core\context; +use core\exception\coding_exception; +use core_user\fields; + +/** + * Base class for bridges between QuestionPy and other plugins that use QuestionPy questions. + * + * This class is used to retrieve data that is not available through the Question API. + * + * @package qtype_questionpy + * @author Martin Gauk + * @copyright 2025 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class question_bridge_base { + /** @var \question_attempt */ + protected $attempt; + + /** @var context */ + protected $context; + + /** + * Create an instance from a question attempt. + * + * @param \question_attempt $attempt + * @param context $context + */ + public function __construct(\question_attempt $attempt, context $context) { + $this->attempt = $attempt; + $this->context = $context; + } + + /** + * Get user or group id this attempt belongs to. + * + * @return array{0: 'user'|'group', 1: int} + */ + abstract public function get_user_or_group_id(): array; + + /** + * Get additional LMS attributes. + * + * For example: attempt_started_at, submission_at, lms_moodle_component_name and lms_moodle_module_instance. + * + * @param string[] $requestedattributes + * @return string[] + */ + abstract protected function get_additional_lms_attributes(array $requestedattributes): array; + + /** + * Get the requested attributes about the attempt. + * + * @param string[] $requestedattributes by the QPy package + * @return array { + * lms?: array, + * user?: array, + * group?: array { + * group_id?: string|int|null, + * group_name?: string|null, + * members: list>, + * }, + * } + */ + public function get_attributes(array $requestedattributes): array { + $attributes = []; + $lmsattributes = []; + + if (in_array('course_id', $requestedattributes)) { + $coursecontext = $this->context->get_course_context(false); + $lmsattributes['course_id'] = ($coursecontext) ? intval($coursecontext->instanceid) : null; + } + + if (in_array('attempt_id', $requestedattributes)) { + $lmsattributes['attempt_id'] = (int) $this->attempt->get_database_id(); + } + + $lmsattributes += $this->get_additional_lms_attributes($requestedattributes); + if ($lmsattributes) { + $attributes['lms'] = $lmsattributes; + } + + [$userfieldsapi, $userfieldsmapping] = $this->get_user_fields_api_mapping($requestedattributes); + if ($userfieldsapi || in_array('group_id', $requestedattributes) || in_array('group_name', $requestedattributes)) { + [$userorgroup, $id] = $this->get_user_or_group_id(); + + if ($userorgroup === 'user' && $userfieldsapi) { + $attributes['user'] = $this->get_user_attributes($id, $userfieldsapi, $userfieldsmapping); + } else if ($userorgroup === 'group') { + $attributes['group'] = $this->get_group_attributes($id, $requestedattributes, $userfieldsapi, $userfieldsmapping); + } + } + + return $attributes; + } + + /** + * Get user fields object and mapping for the requested attributes. + * + * @param array $requestedattributes + * @return array{0: \core_user\fields|null, 1: array|null} [null, null] if no user fields requested + */ + protected function get_user_fields_api_mapping(array $requestedattributes) { + $userfieldsapi = \core_user\fields::empty(); + $fieldsmapping = []; + + foreach ($requestedattributes as $attribute) { + if ($attribute === 'user_id') { + // ID must always be included in the query because get_records expects the first column to be unique. + $fieldsmapping[$attribute] = 'id'; + } else if ($attribute === 'login_identifier') { + $userfieldsapi->including('username'); + $fieldsmapping[$attribute] = 'username'; + } else if ($attribute === 'email') { + $userfieldsapi->including('email'); + $fieldsmapping[$attribute] = 'email'; + } else if ($attribute === 'display_name') { + $userfieldsapi->with_name(); + $fieldsmapping[$attribute] = ''; // We need to call the fullname function. + } else if ($attribute === 'person_first_name') { + $userfieldsapi->with_name(); + $fieldsmapping[$attribute] = 'firstname'; + } else if ($attribute === 'person_last_name') { + $userfieldsapi->with_name(); + $fieldsmapping[$attribute] = 'lastname'; + } else if (str_starts_with($attribute, 'profile_field_')) { + // Ensure the custom field exists, because the fields class JOINs the table. + // If the field does not exist, we would get an empty result. + $shortname = substr($attribute, 14); + $fieldinfo = profile_get_custom_field_data_by_shortname($shortname); + if ($fieldinfo) { + $userfieldsapi->including($attribute); + $fieldsmapping[$attribute] = $attribute; + } + } + } + + if ($fieldsmapping) { + return [$userfieldsapi, $fieldsmapping]; + } + return [null, null]; + } + + /** + * Get the attributes of a single user. + * + * @param int $userid + * @param fields $userfieldsapi as returned by {@see self::get_user_fields_api_mapping()} + * @param array $userfieldsmapping as returned by {@see self::get_user_fields_api_mapping()} + * @return array + */ + protected function get_user_attributes(int $userid, \core_user\fields $userfieldsapi, array $userfieldsmapping): array { + global $DB; + + $fieldssql = $userfieldsapi->get_sql('u', true); + $params = $fieldssql->params; + $params['uid'] = $userid; + $data = $DB->get_record_sql( + "SELECT u.id {$fieldssql->selects} FROM {user} u {$fieldssql->joins} WHERE u.id = :uid", + $params + ); + return $this->extract_user_attributes($data, $userfieldsmapping); + } + + /** + * Extract the user attributes from the user data object. + * + * @param \stdClass $userdata + * @param array $userfieldsmapping as returned by {@see self::get_user_fields_api_mapping()} + * @return array + */ + protected function extract_user_attributes(\stdClass $userdata, array $userfieldsmapping): array { + $attributes = []; + + foreach ($userfieldsmapping as $attributename => $field) { + if ($attributename === 'display_name') { + $attributes[$attributename] = \core_user::get_fullname($userdata, $this->context); + } else { + $attributes[$attributename] = $userdata->{$field} ?? null; + } + } + + return $attributes; + } + + /** + * Get the attributes of a group and its members. + * + * @param int $groupid + * @param array $requestedattributes + * @param fields|null $userfieldsapi as returned by {@see self::get_user_fields_api_mapping()} + * @param array|null $userfieldsmapping as returned by {@see self::get_user_fields_api_mapping()} + * @return array + */ + protected function get_group_attributes(int $groupid, array $requestedattributes, ?\core_user\fields $userfieldsapi, + ?array $userfieldsmapping): array { + global $DB; + $attributes = []; + + if (in_array('group_id', $requestedattributes)) { + $attributes['group_id'] = $groupid; + } + + if (in_array('group_name', $requestedattributes)) { + $attributes['group_name'] = $DB->get_field('groups', 'name', ['id' => $groupid]); + } + + // Get group members and their attributes. + if ($userfieldsapi) { + $fieldssql = $userfieldsapi->get_sql('u', true); + $params = $fieldssql->params; + $params['groupid'] = $groupid; + $data = $DB->get_records_sql("SELECT u.id {$fieldssql->selects} + FROM {groups_members} gm + JOIN {user} u ON (u.id = gm.userid) + {$fieldssql->joins} + WHERE gm.groupid = :groupid", $params); + + $attributes['members'] = []; + foreach ($data as $userdata) { + $attributes['members'][] = $this->extract_user_attributes($userdata, $userfieldsmapping); + } + } + + return $attributes; + } + + /** + * Create a bridge for the given attempt. + * + * We already provide our own bridges for the core_question_preview and mod_quiz components. + * Otherwise, we try to load a bridge class from the component namespace. + * + * @param \question_attempt $attempt + * @return self + */ + public static function create(\question_attempt $attempt): self { + global $DB; + + $usage = $DB->get_record('question_usages', ['id' => $attempt->get_usage_id()], '*', MUST_EXIST); + $context = context::instance_by_id($usage->contextid); + + if ($usage->component === 'mod_quiz') { + return new local\bridge\mod_quiz($attempt, $context); + } + if ($usage->component === 'core_question_preview') { + return new local\bridge\core_question_preview($attempt, $context); + } + + $classname = "\\{$usage->component}\\qtype_questionpy\\bridge"; + if (class_exists($classname)) { + return new $classname($attempt, $context); + } + throw new coding_exception("QuestionPy bridge class not found: '$classname'"); + } +} diff --git a/question.php b/question.php index 8402b65c..e27fcf44 100644 --- a/question.php +++ b/question.php @@ -29,6 +29,7 @@ use qtype_questionpy\local\api\package_dependency; use qtype_questionpy\local\api\scoring_code; use qtype_questionpy\local\attempt_ui\question_ui_metadata_extractor; +use qtype_questionpy\question_bridge_base; use qtype_questionpy\utils; /** @@ -65,6 +66,9 @@ class qtype_questionpy_question extends question_graded_automatically_with_count /** @var qbehaviour_questionpy|null $behaviour */ public ?qbehaviour_questionpy $behaviour = null; + /** @var question_bridge_base|null $bridge bridge to get additional information about an attempt */ + public ?question_bridge_base $bridge = null; + /** * Initialize a new question. Called from {@see qtype_questionpy::make_question_instance()}. * @@ -411,4 +415,29 @@ public function make_behaviour(question_attempt $qa, $preferredbehaviour): quest $delegate = parent::make_behaviour($qa, $preferredbehaviour); return new qbehaviour_questionpy($qa, $preferredbehaviour, $delegate); } + + /** + * Get the QuestionPy bridge used to retrieve additional information about an attempt. + * + * @return question_bridge_base + */ + public function get_bridge(): question_bridge_base { + if ($this->bridge === null) { + $this->bridge = question_bridge_base::create($this->get_behaviour()->get_qa()); + } + return $this->bridge; + } + + /** + * Explicitly set the bridge to use for this question. + * + * The plugin that uses this question may call this method so the bridge object does not need to fetch + * some data again from the database. + * + * @param question_bridge_base $bridge + * @return void + */ + public function set_bridge(question_bridge_base $bridge): void { + $this->bridge = $bridge; + } } From c02ecd55d2c1d0b78be364a2500e24177b87031b Mon Sep 17 00:00:00 2001 From: Martin Gauk Date: Tue, 9 Sep 2025 17:12:32 +0200 Subject: [PATCH 2/3] Update classes/question_bridge_base.php Co-authored-by: Jan Britz --- classes/question_bridge_base.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/classes/question_bridge_base.php b/classes/question_bridge_base.php index 3d65448d..56a0feab 100644 --- a/classes/question_bridge_base.php +++ b/classes/question_bridge_base.php @@ -85,11 +85,11 @@ public function get_attributes(array $requestedattributes): array { if (in_array('course_id', $requestedattributes)) { $coursecontext = $this->context->get_course_context(false); - $lmsattributes['course_id'] = ($coursecontext) ? intval($coursecontext->instanceid) : null; + $lmsattributes['course_id'] = $coursecontext ? $coursecontext->instanceid : null; } if (in_array('attempt_id', $requestedattributes)) { - $lmsattributes['attempt_id'] = (int) $this->attempt->get_database_id(); + $lmsattributes['attempt_id'] = $this->attempt->get_database_id(); } $lmsattributes += $this->get_additional_lms_attributes($requestedattributes); From db1f4aee675c5c453ffe3a33bd8f68834e6582c1 Mon Sep 17 00:00:00 2001 From: Martin Gauk Date: Tue, 9 Sep 2025 17:33:46 +0200 Subject: [PATCH 3/3] handle attempt not saved --- classes/question_bridge_base.php | 5 +++++ lang/en/qtype_questionpy.php | 1 + 2 files changed, 6 insertions(+) diff --git a/classes/question_bridge_base.php b/classes/question_bridge_base.php index 56a0feab..3f0753fd 100644 --- a/classes/question_bridge_base.php +++ b/classes/question_bridge_base.php @@ -18,6 +18,7 @@ use core\context; use core\exception\coding_exception; +use core\exception\moodle_exception; use core_user\fields; /** @@ -254,6 +255,10 @@ protected function get_group_attributes(int $groupid, array $requestedattributes public static function create(\question_attempt $attempt): self { global $DB; + if ($attempt->get_database_id() === null || !is_numeric($attempt->get_usage_id())) { + throw new moodle_exception('attempt_not_saved', 'qtype_questionpy'); + } + $usage = $DB->get_record('question_usages', ['id' => $attempt->get_usage_id()], '*', MUST_EXIST); $context = context::instance_by_id($usage->contextid); diff --git a/lang/en/qtype_questionpy.php b/lang/en/qtype_questionpy.php index 8405f7f9..d0496586 100644 --- a/lang/en/qtype_questionpy.php +++ b/lang/en/qtype_questionpy.php @@ -26,6 +26,7 @@ $string['attempt_detail_link'] = 'QuestionPy-specific details'; $string['attempt_does_not_exist'] = 'The attempt does not exist anymore.'; $string['attempt_not_questionpy'] = 'This is not an attempt at a QuestionPy question, but at a \'{$a}\' question.'; +$string['attempt_not_saved'] = 'The attempt has not yet been saved to the database.'; $string['attempt_preview'] = 'Preview'; $string['attempt_state_moodle'] = 'Moodle Attempt State'; $string['attempt_state_qpy'] = 'QuestionPy Attempt State';