Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.

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;

/**
Expand All @@ -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.
*
Expand Down Expand Up @@ -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 <b>json_encode</b>,
* 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);
}
}
10 changes: 7 additions & 3 deletions classes/local/api/package_api.php
Original file line number Diff line number Diff line change
Expand Up @@ -134,19 +134,21 @@ 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
* @throws moodle_exception
* @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,
Expand All @@ -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(),
Expand Down
1 change: 0 additions & 1 deletion classes/local/api/wysiwyg_editor_data.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions classes/local/files/response_file_service.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}
}
4 changes: 1 addition & 3 deletions classes/local/form/elements/file_upload_element.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion classes/local/form/elements/wysiwyg_editor_element.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion edit_questionpy_form.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
125 changes: 74 additions & 51 deletions question.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}
Expand All @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down