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
4 changes: 2 additions & 2 deletions classes/library_import.php
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ public static function import_execute($courseid, $category, $filepath, $isfolder
$basedir = $CFG->dirroot . '/question/type/stack/samplequestions/';
}
if ($external) {
// External libraries are controlled by the site-level capability.
require_capability('qtype/stack:useexternallibraries', \context_system::instance());
// External libraries are controlled by the course-level capability.
require_capability('qtype/stack:useexternallibraries', $thiscontext);
}
if (
!str_starts_with(realpath($requestedfile), "{$CFG->dataroot}/stack/sitelibrary") &&
Expand Down
4 changes: 2 additions & 2 deletions classes/library_render.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ public static function render_execute($category, $filepath, $cacheid, $apikey) {
self::validate_context($thiscontext);
require_capability('moodle/question:add', $thiscontext);
if ($external) {
// External libraries are controlled by the site-level capability.
require_capability('qtype/stack:useexternallibraries', \context_system::instance());
// External libraries are controlled by the course-level capability.
require_capability('qtype/stack:useexternallibraries', $thiscontext);
}

// Check if we've already cached the answer.
Expand Down
8 changes: 4 additions & 4 deletions db/access.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,20 @@
'manager' => CAP_ALLOW,
],
],
// Users with this in the system context can use external question libraries.
// Users with this in the course context can use external question libraries.
'qtype/stack:useexternallibraries' => [
'captype' => 'write',
'contextlevel' => CONTEXT_SYSTEM,
'contextlevel' => CONTEXT_COURSE,
'archetypes' => [
'editingteacher' => CAP_ALLOW,
'manager' => CAP_ALLOW,
],
'clonepermissionsfrom' => 'moodle/question:add',
],
// Users with this in the system context can export to external question libraries.
// Users with this in the course context can export to external question libraries.
'qtype/stack:exporttoexternallibraries' => [
'captype' => 'write',
'contextlevel' => CONTEXT_SYSTEM,
'contextlevel' => CONTEXT_COURSE,
'archetypes' => [
'editingteacher' => CAP_ALLOW,
'manager' => CAP_ALLOW,
Expand Down
5 changes: 5 additions & 0 deletions db/upgrade.php
Original file line number Diff line number Diff line change
Expand Up @@ -1068,6 +1068,11 @@ function xmldb_qtype_stack_upgrade($oldversion) {
// Stack savepoint reached.
upgrade_plugin_savepoint(true, 2026080700, 'qtype', 'stack');
}
if ($oldversion < 2026110400) {
// The external library capability is now available in course contexts.
upgrade_plugin_savepoint(true, 2026110400, 'qtype', 'stack');
}

// Add new upgrade blocks just above here.

// Check the version of the Maxima library code that comes with this version
Expand Down
2 changes: 1 addition & 1 deletion doc/en/Authoring/Metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ New questions will be created with editable `metadata` similar to the following:
"freeform" {} // Completely user-defined metadata
}
```
A question can have multiple authors. The metadata scheme is loosely based on [the Dublin Core specification](https://www.dublincore.org/specifications/dublin-core/dcmi-terms/).
A question can have multiple authors. The metadata scheme is loosely based on [the Dublin Core specification](https://www.dublincore.org/specifications/dublin-core/dcmi-terms/). Authors are given a first and last name for consistency with name storage within Moodle. If this is unsuitable, enter the author's full name in the last name field. You can enter the same author twice with a different year if you wish to preserve a limited edit history timeline.

Additional metadata is stored in Scope->Property->Qualifier->Value or Scope->Property->Value format. Scope identifies the metadata scheme being used. For instance, two institutions might have the property `Level` that has different meanings. Scope allows differentiation between the two:
```
Expand Down
38 changes: 19 additions & 19 deletions lang/en/qtype_stack.php
Original file line number Diff line number Diff line change
Expand Up @@ -565,10 +565,10 @@
<br>&nbsp;&nbsp;&nbsp;&nbsp;"name": "External: sample library"
<br>&nbsp;&nbsp;}
<br>}';
$string['settingnrwapikey'] = 'STACK.nrw databse API key';
$string['settingnrwapikey_desc'] = 'Allows access to the STACK.nrw question database via the STACK library page. Access can be restricted on a per user basis using Moodle permission qtype/stack:useexternallibraries.';
$string['settingnrwupload'] = 'STACK.nrw upload allowed';
$string['settingnrwupload_desc'] = 'Allows questions to be uploaded to the STACK.nrw question database via the STACK question dashboard. Access can be restricted on a per user basis using Moodle permission qtype/stack:exporttoexternallibraries.';
$string['settingatlasapikey'] = 'ATLAS database API key';
$string['settingatlasapikey_desc'] = 'Allows access to the ATLAS question database via the STACK library page. Access can be restricted on a per user basis using Moodle permission qtype/stack:useexternallibraries.';
$string['settingatlasupload'] = 'ATLAS upload allowed';
$string['settingatlasupload_desc'] = 'Allows questions to be uploaded to the ATLAS question database via the STACK question dashboard. Access can be restricted on a per user basis using Moodle permission qtype/stack:exporttoexternallibraries.';

// Strings used by replace dollars script.
$string['replacedollarscount'] = 'This category contains {$a} STACK questions.';
Expand Down Expand Up @@ -725,7 +725,7 @@
$string['seethisquestioninthequestionbank'] = '<i class="fa fa-list-alt"></i> Show in question bank';
$string['exportthisquestion'] = '<i class="fa fa-download"></i> Export as Moodle XML';
$string['exportthisquestion_help'] = 'This will create a Moodle XML export file containing just this one question. One example of when this is useful if you think this question demonstrates a bug in STACK that you would like to report to the developers.';
$string['exporttonrw'] = '<i class="fa fa-cloud-arrow-up"></i> Export to STACK.nrw';
$string['exporttoatlas'] = '<i class="fa fa-cloud-arrow-up"></i> Export to ATLAS';
$string['tidyquestion'] = '<i class="fa fa-sort-amount-asc"></i> Tidy inputs and PRTs';
$string['tidyquestion_txt'] = 'Tidy inputs and PRTs';
$string['sendgeneralfeedback'] = '<i class="fa fa-file-text"></i> Send general feedback to the CAS';
Expand All @@ -738,19 +738,19 @@
$string['bulktestquiz'] = '<i class="fa fa-certificate"></i> Bulk test quiz';
$string['bulktestquiznotes'] = 'Bulk test the latest version of all the questions in a quiz containing this question.';
$string['history'] = '<i class="fa fa-list-alt"></i> Question history';
$string['questionexportplaceholder'] = 'This page is a placeholder for the NRW export workflow.';
$string['nrwuploadbutton'] = 'Upload to STACK.nrw';
$string['nrwuploadcreated'] = 'Question uploaded to NRW successfully. ATLAS ID: {$a}.';
$string['nrwuploadduplicate'] = 'This XML already exists in NRW. Existing ATLAS ID: {$a}.';
$string['nrwuploadvalidationerror'] = 'NRW rejected the upload: XML failed validation.';
$string['nrwuploadfailed'] = 'NRW upload failed.';
$string['nrwuploadunexpected'] = 'Unexpected response from NRW upload endpoint.';
$string['nrwuploadapikeymissing'] = 'Cannot upload: NRW API key is not configured.';
$string['nrwuploadxmlerror'] = 'Could not export this question to XML for upload.';
$string['nrwuploadpayloadencodeerror'] = 'Could not encode upload payload.';
$string['nrwuploadduplicateapi'] = 'Duplicate question already exists.';
$string['nrwuploadcreatedapi'] = 'Question uploaded successfully.';
$string['nrwuploadxmlvalidationapifailed'] = 'XML failed validation.';
$string['questionexportplaceholder'] = 'This page is a placeholder for the ATLAS export workflow.';
$string['atlasuploadbutton'] = 'Upload to ATLAS';
$string['atlasuploadqueued'] = 'The question has been sent to ATLAS. Open the link below to check its metadata and publish it there.';
$string['atlasuploadclaimlink'] = 'Continue in ATLAS';
$string['atlasuploadclaimexpires'] = 'The link works once, until {$a}. Until it is opened, the question is not visible in ATLAS.';
$string['atlasuploadrejected'] = 'ATLAS rejected the upload:';
$string['atlasuploadfailed'] = 'ATLAS upload failed.';
$string['atlasuploadunexpected'] = 'Unexpected response from ATLAS upload endpoint.';
$string['atlasuploadapikeymissing'] = 'Cannot upload: ATLAS API key is not configured.';
$string['atlasuploadxmlerror'] = 'Could not export this question to XML for upload.';
$string['atlasuploadduplicateapi'] = 'Duplicate question already exists.';
$string['atlasuploadcreatedapi'] = 'Question uploaded successfully.';
$string['atlasuploadxmlvalidationapifailed'] = 'XML failed validation.';
$string['bulktestquizselect'] = 'Select a quiz';

$string['basicquestionreport'] = '<i class="fa fa-bar-chart"></i> Analyze responses';
Expand Down Expand Up @@ -2085,7 +2085,7 @@
$string['stack_library_selected'] = 'Displayed question:';
$string['stack_library_select'] = 'Select library:';
$string['stack_library_source'] = 'Source';
$string['stack_library_nrw'] = 'Search NRW database';
$string['stack_library_atlas'] = 'Search ATLAS database';
$string['stack_library_subject'] = 'Subject';
$string['stack_library_success'] = 'Successful import of:';
$string['stack_library_not_stack'] = 'This is not a STACK question and so cannot be fully rendered here but you can still import it.';
Expand Down
4 changes: 2 additions & 2 deletions questionexport.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,12 @@
require_capability('qtype/stack:exporttoexternallibraries', $context);

if (!get_config('qtype_stack', 'nrwupload')) {
throw new moodle_exception('nopermissions', 'error', '', 'export to NRW');
throw new moodle_exception('nopermissions', 'error', '', 'export to ATLAS');
}

$PAGE->set_context($context);
$PAGE->set_url('/question/type/stack/questionexport.php', $urlparams);
$title = stack_string('exporttonrw');
$title = stack_string('exporttoatlas');
$PAGE->set_title($title);
$PAGE->set_heading($title);
$PAGE->set_pagelayout('popup');
Expand Down
10 changes: 6 additions & 4 deletions questionlibrary.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@
$external = null;
$allowedlibraries = get_config('qtype_stack', 'libraries');
$allowedlibraries = json_decode($allowedlibraries);
$allowedlibraries = $allowedlibraries ? $allowedlibraries : new StdClass();
$allowedlibraries =
($allowedlibraries && has_capability('qtype/stack:useexternallibraries', $thiscontext)) ? $allowedlibraries : new StdClass();
$search = optional_param('search', '', PARAM_RAW);
$apikey = optional_param('apikey', '', PARAM_RAW);

Expand All @@ -120,7 +121,7 @@
$cacheid = stack_question_library::STACKLIB;
}
} else if (str_starts_with($location, stack_question_library::GITHUB)) {
require_capability('qtype/stack:useexternallibraries', \context_system::instance());
require_capability('qtype/stack:useexternallibraries', $thiscontext);
$libparts = explode('/', $location);
$librarytype = $libparts[0];
$libraryid = $libparts[1];
Expand All @@ -135,7 +136,7 @@
$externaldetail = $allowedlibraries->{$libraryid}->url;
}
} else if (str_starts_with($location, stack_question_library::NRWSEARCH)) {
require_capability('qtype/stack:useexternallibraries', \context_system::instance());
require_capability('qtype/stack:useexternallibraries', $thiscontext);
$cacheid = stack_question_library::NRWSEARCH;
$libraryname = null;
$external = stack_question_library::NRWSEARCH;
Expand Down Expand Up @@ -189,7 +190,8 @@
$outputdata->category = $mform->render();
$outputdata->coursename = $coursename;
$outputdata->courseid = $courseid;
$outputdata->isnrwapikey = (get_config('qtype_stack', 'nrwapikey')) ? true : false;
$outputdata->isnrwapikey =
(get_config('qtype_stack', 'nrwapikey') && has_capability('qtype/stack:useexternallibraries', $thiscontext)) ? true : false;
$outputdata->search = $search;
$outputdata->libraries = new StdClass();
$outputdata->libraries->items = [];
Expand Down
72 changes: 71 additions & 1 deletion questiontype.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
defined('MOODLE_INTERNAL') || die();

require_once($CFG->libdir . '/questionlib.php');
require_once($CFG->dirroot . '/question/format/xml/format.php');
require_once(__DIR__ . '/stack/input/factory.class.php');
require_once(__DIR__ . '/stack/answertest/controller.class.php');
require_once(__DIR__ . '/stack/cas/keyval.class.php');
Expand Down Expand Up @@ -67,6 +68,7 @@ class qtype_stack extends question_type {

// phpcs:ignore moodle.Commenting.MissingDocblock.Function
public function save_question($question, $fromform) {
global $DB;

if (!empty($fromform->fixdollars)) {
$this->fix_dollars_in_form_data($fromform);
Expand All @@ -81,6 +83,11 @@ public function save_question($question, $fromform) {
if (isset($question->options->questionid) && $question->options->questionid) {
$fromform->deployedseeds = $this->get_question_deployed_seeds($question->options->questionid);
$fromform->testcases = $this->load_question_tests($question->options->questionid);
$fromform->prescribedmetadata = $DB->get_field(
'qtype_stack_options',
'prescribedmetadata',
['questionid' => $question->options->questionid]
) ?: '';
}

$new = parent::save_question($question, $fromform);
Expand Down Expand Up @@ -203,6 +210,8 @@ public function save_question_options($fromform) {
$options->id = $DB->insert_record('qtype_stack_options', $options);
}

$initialprescribedmetadata = $fromform->prescribedmetadata ?? $options->prescribedmetadata ?? '';

$options->stackversion = $fromform->stackversion;
$options->questionvariables = $fromform->questionvariables;
$options->specificfeedback = $this->import_or_save_files(
Expand Down Expand Up @@ -267,7 +276,7 @@ public function save_question_options($fromform) {
$options->variantsselectionseed = $fromform->variantsselectionseed;
$options->isbroken = !empty($fromform->isbroken) ? 1 : 0;
$options->metadata = $fromform->metadata;
$options->prescribedmetadata = "";
$options->prescribedmetadata = $initialprescribedmetadata;

// We will not have the values for this.
$options->compiledcache = '{}';
Expand Down Expand Up @@ -558,9 +567,70 @@ public function save_question_options($fromform) {
'questionid = :questionid AND prtname ' . $nametest,
$params
);

$prescribedmetadata = json_decode($initialprescribedmetadata);
$hasatlasid = is_object($prescribedmetadata) && !empty($prescribedmetadata->atlasId);
if (
in_array($PAGE->pagetype, ['question-type-stack', 'question-type-stack-questionxmledit']) &&
(empty($fromform->import_process) || !$hasatlasid)
) {
// Preserve an Atlas ID supplied by an XML source such as ATLAS. If
// there is no Atlas ID, generate one as for an ordinary save.
$questiondata = question_bank::load_question_data($fromform->id);
$format = new qformat_xml();
$format->setQuestions([$questiondata]);
$coursecontext = $context->get_course_context(false);
$format->setCourse($coursecontext ? get_course($coursecontext->instanceid) : get_site());
$format->setCattofile(false);
$format->setContexttofile(false);
$atlasid = self::make_question_atlas_id($format->exportprocess(false));

$prescribedmetadata = $prescribedmetadata ?: new StdClass();
$prescribedmetadata->atlasIdHistory = !empty($prescribedmetadata->atlasIdHistory) ?
$prescribedmetadata->atlasIdHistory : [];
if (!empty($prescribedmetadata->atlasId)) {
$prescribedmetadata->atlasIdHistory[] = $prescribedmetadata->atlasId;
}
$prescribedmetadata->atlasId = $atlasid;

$options->prescribedmetadata = json_encode($prescribedmetadata, JSON_UNESCAPED_SLASHES);

$DB->set_field(
'qtype_stack_options',
'prescribedmetadata',
$options->prescribedmetadata,
['questionid' => $fromform->id]
);
}

return $result;
}

/**
* Generate an ATLAS question id from canonical question XML bytes.
* @param string $xmlbytes XML bytes for a STACK question or Moodle quiz XML containing one.
* @return string the generated ATLAS id.
*/
public static function make_question_atlas_id(string $xmlbytes): string {
$xmlbytes = preg_replace(
'~^[ \t]*<prescribedmetadata\b[^>]*>.*?</prescribedmetadata>[ \t]*\n?~ms',
'',
$xmlbytes
);
$xmlbytes = preg_replace(
'~^[ \t]*<!-- question: \d+\s+-->[ \t]*\n?~m',
'',
$xmlbytes
);

$timehex = str_pad(dechex((int) floor(microtime(true) * 1000)), 12, '0', STR_PAD_LEFT);
$hashhex = bin2hex(substr(hash('sha1', $xmlbytes, true), 0, 6));
$randhex = bin2hex(random_bytes(4));

$core = 'ATLAS-' . $timehex . '-' . $hashhex . '-' . $randhex;
return $core . '-' . substr(hash('sha256', $core), 0, 4);
}

// phpcs:ignore moodle.Commenting.MissingDocblock.Function
public function get_question_options($question) {
global $DB;
Expand Down
8 changes: 4 additions & 4 deletions settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -255,17 +255,17 @@

$settings->add(new admin_setting_configtext(
'qtype_stack/nrwapikey',
get_string('settingnrwapikey', 'qtype_stack'),
get_string('settingnrwapikey_desc', 'qtype_stack'),
get_string('settingatlasapikey', 'qtype_stack'),
get_string('settingatlasapikey_desc', 'qtype_stack'),
'',
PARAM_TEXT,
60
));

$settings->add(new admin_setting_configcheckbox(
'qtype_stack/nrwupload',
get_string('settingnrwupload', 'qtype_stack'),
get_string('settingnrwupload_desc', 'qtype_stack'),
get_string('settingatlasupload', 'qtype_stack'),
get_string('settingatlasupload_desc', 'qtype_stack'),
0
));

Expand Down
Loading
Loading