diff --git a/classes/local/files/file_metadata.php b/classes/local/api/file_metadata.php similarity index 79% rename from classes/local/files/file_metadata.php rename to classes/local/api/file_metadata.php index f6ce66e0..4794ab89 100644 --- a/classes/local/files/file_metadata.php +++ b/classes/local/api/file_metadata.php @@ -14,11 +14,15 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace qtype_questionpy\local\files; +namespace qtype_questionpy\local\api; use core\exception\coding_exception; use DateTimeImmutable; +use JsonSerializable; +use qtype_questionpy\local\array_converter\array_converter; use qtype_questionpy\local\array_converter\attributes\array_key; +use qtype_questionpy\local\array_converter\conversion_exception; +use qtype_questionpy\local\files\qpy_file_ref; use stored_file; /** @@ -29,7 +33,7 @@ * @copyright 2025 TU Berlin, innoCampus {@link https://www.questionpy.org} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class file_metadata { +class file_metadata implements JsonSerializable { /** * Trivial constructor. * @@ -78,4 +82,15 @@ public static function from_stored_file(stored_file $file, ?string $overridename size: $file->get_filesize(), ); } + /** + * Specify data which should be serialized to JSON + * @link https://php.net/manual/en/jsonserializable.jsonserialize.php + * @return mixed data which can be serialized by json_encode, + * which is a value of any type other than a resource. + * @throws coding_exception + * @throws conversion_exception + */ + public function jsonSerialize(): mixed { + return array_converter::to_array($this); + } } diff --git a/classes/local/api/package_api.php b/classes/local/api/package_api.php index 6ea06c05..1cce4535 100644 --- a/classes/local/api/package_api.php +++ b/classes/local/api/package_api.php @@ -134,6 +134,7 @@ public function start_attempt(string $questionstate, int $variant, ?array $attri * @param string $attemptstate the attempt state previously returned from {@see start_attempt()} * @param string|null $scoringstate the last scoring state if this attempt has already been scored * @param object|null $response data currently entered by the student + * @param array[]|null $uploads Lists of uploaded files by upload field name. * @param array|null $editors * @return attempt the attempt's metadata. The state is not returned since it never changes. * @throws GuzzleException @@ -141,12 +142,13 @@ public function start_attempt(string $questionstate, int $variant, ?array $attri * @throws request_error */ public function view_attempt(string $questionstate, ?array $attributes, string $attemptstate, ?string $scoringstate = null, - ?object $response = null, ?array $editors = null): attempt { + ?object $response = null, ?array $uploads = null, ?array $editors = null): attempt { $options['multipart'] = $this->transform_to_multipart( [ 'attempt_state' => $attemptstate, 'scoring_state' => $scoringstate, 'response' => $response, + 'uploads' => $uploads === null ? null : (object) $uploads, 'editors' => $editors === null ? null : (object) $editors, 'context' => $this->get_context_id(), 'lms_provided_attributes' => $attributes, @@ -165,19 +167,21 @@ public function view_attempt(string $questionstate, ?array $attributes, string $ * @param string $attemptstate the attempt state previously returned from {@see start_attempt()} * @param string|null $scoringstate the last scoring state if this attempt had been scored before * @param object $response data submitted by the student - * @param wysiwyg_editor_data[] $editors + * @param array[] $uploads Lists of uploaded files by upload field name. + * @param wysiwyg_editor_data[] $editors Editor data by editor name. * @return attempt_scored the attempt's metadata. The state is not returned since it never changes. * @throws GuzzleException * @throws moodle_exception * @throws request_error */ public function score_attempt(string $questionstate, ?array $attributes, string $attemptstate, ?string $scoringstate, - object $response, array $editors): attempt_scored { + object $response, array $uploads, array $editors): attempt_scored { $options['multipart'] = $this->transform_to_multipart( [ 'attempt_state' => $attemptstate, 'scoring_state' => $scoringstate, 'response' => $response, + 'uploads' => (object) $uploads, 'editors' => (object) $editors, 'generate_hint' => false, 'context' => $this->get_context_id(), diff --git a/classes/local/api/wysiwyg_editor_data.php b/classes/local/api/wysiwyg_editor_data.php index abf0366a..ae8e8228 100644 --- a/classes/local/api/wysiwyg_editor_data.php +++ b/classes/local/api/wysiwyg_editor_data.php @@ -22,7 +22,6 @@ use qtype_questionpy\local\array_converter\attributes\array_element_class; use qtype_questionpy\local\array_converter\attributes\array_key; use qtype_questionpy\local\array_converter\conversion_exception; -use qtype_questionpy\local\files\file_metadata; /** * Data class for WYSIWYG editor data. diff --git a/classes/local/files/response_file_service.php b/classes/local/files/response_file_service.php index 2dc78140..9fd506c3 100644 --- a/classes/local/files/response_file_service.php +++ b/classes/local/files/response_file_service.php @@ -248,7 +248,7 @@ public static function unmangle_filename(string $filename): array { * * @param array $response As returned by {@see question_attempt::get_last_qt_data()} and passed to * {@see qtype_questionpy_question::grade_response()}. - * @return stored_file[] Files belonging to the response. + * @return stored_file[][] Arrays à la `[$fieldname => [$filename => stored_file]]`. * @throws coding_exception */ public function get_all_files_from_qt_data(array $response): array { @@ -263,6 +263,12 @@ public function get_all_files_from_qt_data(array $response): array { throw new coding_exception("The '$key' qt var exists, but is not an instance of question_response_files."); } - return $accessor->get_files(); + $filesbyfield = []; + foreach ($accessor->get_files() as $file) { + [$fieldname, $filename] = self::unmangle_filename($file->get_filename()); + $filesbyfield[$fieldname][$filename] = $file; + } + + return $filesbyfield; } } diff --git a/classes/local/form/elements/file_upload_element.php b/classes/local/form/elements/file_upload_element.php index 4d5ee436..526bc42b 100644 --- a/classes/local/form/elements/file_upload_element.php +++ b/classes/local/form/elements/file_upload_element.php @@ -22,13 +22,11 @@ use moodle_exception; use MoodleQuickForm_filemanager; use qtype_questionpy\local\array_converter\array_converter; -use qtype_questionpy\local\array_converter\attributes\array_key; -use qtype_questionpy\local\files\file_metadata; +use qtype_questionpy\local\api\file_metadata; use qtype_questionpy\local\files\options_file_service; use qtype_questionpy\local\form\context\render_context; use qtype_questionpy\local\form\form_help; use qtype_questionpy\utils; -use stdClass; /** * File upload. diff --git a/classes/local/form/elements/wysiwyg_editor_element.php b/classes/local/form/elements/wysiwyg_editor_element.php index c072e48c..609370c0 100644 --- a/classes/local/form/elements/wysiwyg_editor_element.php +++ b/classes/local/form/elements/wysiwyg_editor_element.php @@ -27,7 +27,7 @@ use qtype_questionpy\local\api\wysiwyg_editor_data; use qtype_questionpy\local\array_converter\array_converter; use qtype_questionpy\local\array_converter\attributes\array_key; -use qtype_questionpy\local\files\file_metadata; +use qtype_questionpy\local\api\file_metadata; use qtype_questionpy\local\files\options_file_service; use qtype_questionpy\local\form\context\render_context; use qtype_questionpy\local\form\form_help; diff --git a/edit_questionpy_form.php b/edit_questionpy_form.php index 9629efd7..b9a3a828 100644 --- a/edit_questionpy_form.php +++ b/edit_questionpy_form.php @@ -632,7 +632,7 @@ public function validation($data, $files) { $errors = parent::validation($data, $files); $package = $this->validate_selected_package($data, $errors); - if ($data['qpy_package_selected']) { + if ($data['qpy_package_selected'] && !($data['qpy_package_invalid'] ?? 0)) { // The options form of a package is being submitted. $this->validate_options_form($data, $package, $errors); } diff --git a/question.php b/question.php index 9a595500..81f757fe 100644 --- a/question.php +++ b/question.php @@ -32,7 +32,7 @@ use qtype_questionpy\local\api\scoring_code; use qtype_questionpy\local\api\wysiwyg_editor_data; use qtype_questionpy\local\attempt_ui\question_ui_metadata_extractor; -use qtype_questionpy\local\files\file_metadata; +use qtype_questionpy\local\api\file_metadata; use qtype_questionpy\local\files\response_file_service; use qtype_questionpy\question_bridge_base; use qtype_questionpy\utils; @@ -201,25 +201,19 @@ public function apply_attempt_state(question_attempt_step $step) { question_attempt->start_question_based_on, where we shouldn't need to get the UI. */ try { $lastresponsestep = $qa->get_last_step_with_qt_var(constants::QT_VAR_RESPONSE); - $lastresponse = utils::get_qpy_response($lastresponsestep->get_qt_data()); - - $allfiles = $this->rfs->get_all_files_from_qt_data($lastresponsestep->get_qt_data()); - $editors = utils::get_qpy_editors_data($lastresponsestep->get_qt_data()); - array_walk( - $editors, - fn(&$editordata, $editorname) => $editordata = $this->build_wysiwyg_data($editorname, $editordata, $allfiles) - ); + [$lastresponse, $uploads, $editors] = $this->prepare_responses_for_server($lastresponsestep->get_qt_data()); $attributes = $this->get_requested_attributes(); $attempt = $this->api->package($this->packagehash, $this->packagefile) ->view_attempt( - $this->questionstate, - $attributes, - $this->attemptstate, - $this->scoringstate, - $lastresponse, - $editors, + questionstate: $this->questionstate, + attributes: $attributes, + attemptstate: $this->attemptstate, + scoringstate: $this->scoringstate, + response: $lastresponse, + uploads: $uploads, + editors: $editors, ); $this->update_attempt($attempt); $this->errorduringload = false; @@ -444,30 +438,6 @@ public function get_validation_error(array $response) { return ''; } - /** - * Joins the raw editor data with the files that belong to it ands returns a {@see wysiwyg_editor_data} object. - * - * @param string $editorname - * @param object $rawdata - * @param stored_file[] $allfiles - * @return wysiwyg_editor_data - * @throws coding_exception - */ - private function build_wysiwyg_data(string $editorname, object $rawdata, array $allfiles): wysiwyg_editor_data { - $filemetas = []; - foreach (response_file_service::filter_combined_files_for_field($allfiles, $editorname) as $filename => $file) { - $filemetas[] = file_metadata::from_stored_file($file, overridename: $filename); - } - - // TODO: Turn @@PLUGINFILE@@-links into QPy-URLs? - - return new wysiwyg_editor_data( - text: $rawdata->text, - textformat: $rawdata->format, - files: $filemetas, - ); - } - /** * Grade a response to the question, returning a fraction between * get_min_fraction() and get_max_fraction(), and the corresponding {@see question_state} @@ -485,20 +455,16 @@ public function grade_response(array $response): array { try { $attributes = $this->get_requested_attributes(); - $allfiles = $this->rfs->get_all_files_from_qt_data($response); - $editors = utils::get_qpy_editors_data($response); - array_walk( - $editors, - fn(&$editordata, $editorname) => $editordata = $this->build_wysiwyg_data($editorname, $editordata, $allfiles) - ); + [$qpyresponse, $uploads, $editors] = $this->prepare_responses_for_server($response); $attemptscored = $this->api->package($this->packagehash, $this->packagefile)->score_attempt( - $this->questionstate, - $attributes, - $this->attemptstate, - $this->scoringstate, - utils::get_qpy_response($response) ?? (object)[], - $editors + questionstate: $this->questionstate, + attributes: $attributes, + attemptstate: $this->attemptstate, + scoringstate: $this->scoringstate, + response: $qpyresponse ?? (object)[], + uploads: $uploads, + editors: $editors, ); $this->update_attempt($attemptscored); } catch (Throwable $t) { @@ -537,6 +503,63 @@ public function grade_response(array $response): array { return [$attemptscored->score, $newqstate]; } + /** + * Converts the given QT data to the response, uploads, and editors that are expected by the QuestionPy server. + * + * @param array $responseqtdata The qt data that is being scored or viewed. + * @return array A tuple of `[$response, $uploads, $editors]`. + * @throws coding_exception + */ + private function prepare_responses_for_server(array $responseqtdata): array { + $lastresponse = utils::get_qpy_response($responseqtdata); + + $filesbyfield = $this->rfs->get_all_files_from_qt_data($responseqtdata); + $raweditors = utils::get_qpy_editors_data($responseqtdata); + + $editors = []; + foreach ($raweditors as $editorname => $editordata) { + $text = $editordata->text; + $filemetas = []; + + if (isset($filesbyfield[$editorname])) { + $filenamestorefs = []; + + foreach ($filesbyfield[$editorname] as $filename => $file) { + $filemetas[] = $filemeta = file_metadata::from_stored_file($file, overridename: $filename); + $filenamestorefs[$filename] = $filemeta->fileref; + } + + // Filenames may be prefixes of each other, so we replace the longest ones first. + uksort($filenamestorefs, fn($a, $b) => strlen($b) - strlen($a)); + foreach ($filenamestorefs as $filename => $fileref) { + $text = str_replace('@@PLUGINFILE@@/' . $filename, 'qpy://response/' . $fileref, $text); + } + } + + if (str_contains($text, '@@PLUGINFILE@@')) { + debugging('Editor text still contains @@PLUGINFILE@@-placeholders after replacement.'); + $brokenfile = (new moodle_url('/brokenfile.php'))->out(); + $text = str_replace('@@PLUGINFILE@@', $brokenfile, $text); + } + + $editors[$editorname] = new wysiwyg_editor_data( + text: $text, + textformat: $editordata->format, + files: $filemetas, + ); + } + + $uploads = []; + // Any files that don't belong to editors must belong to file upload elements. + foreach (array_diff_key($filesbyfield, $editors) as $fieldname => $files) { + foreach ($files as $filename => $file) { + $uploads[$fieldname][] = file_metadata::from_stored_file($file, overridename: $filename); + } + } + + return [$lastresponse, $uploads, $editors]; + } + /** * Work out a final grade for this attempt, taking into account all the * tries the student made.