diff --git a/.github/workflows/moodle-ci.yml b/.github/workflows/moodle-ci.yml
index afbce1b..165db1f 100644
--- a/.github/workflows/moodle-ci.yml
+++ b/.github/workflows/moodle-ci.yml
@@ -8,7 +8,7 @@ jobs:
services:
postgres:
- image: postgres:13
+ image: postgres:14
env:
POSTGRES_USER: 'postgres'
POSTGRES_HOST_AUTH_METHOD: 'trust'
@@ -32,7 +32,16 @@ jobs:
matrix: # I don't know why, but mariadb is much slower, so mostly use pgsql.
include:
- php: '8.2'
- moodle-branch: 'master'
+ moodle-branch: 'main'
+ database: 'pgsql'
+ - php: '8.2'
+ moodle-branch: 'MOODLE_405_STABLE'
+ database: 'pgsql'
+ - php: '8.2'
+ moodle-branch: 'MOODLE_404_STABLE'
+ database: 'pgsql'
+ - php: '8.1'
+ moodle-branch: 'MOODLE_403_STABLE'
database: 'pgsql'
- php: '8.1'
moodle-branch: 'MOODLE_402_STABLE'
diff --git a/classes/cli_helper.php b/classes/cli_helper.php
index ff42a9d..38003d0 100644
--- a/classes/cli_helper.php
+++ b/classes/cli_helper.php
@@ -49,6 +49,7 @@ class cli_helper {
* @var array|null Full set of options combining command line and defaults
*/
public ?array $processedoptions = null;
+
/**
* CATEGORY_FILE - Name of file containing category information in each directory and subdirectory.
*/
@@ -58,6 +59,10 @@ class cli_helper {
* Appended to name of moodle instance.
*/
public const MANIFEST_FILE = '_question_manifest.json';
+ /**
+ * QUIZ_FILE - File name ending for quiz structure file.
+ */
+ public const QUIZ_FILE = '_quiz.json';
/**
* TEMP_MANIFEST_FILE - File name ending for temporary manifest file.
* Appended to name of moodle instance.
@@ -91,10 +96,12 @@ public function get_arguments(): array {
$longopts = $parsed['longopts'];
$commandlineargs = getopt($shortopts, $longopts);
$argcount = count($commandlineargs);
- echo "\nProcessed {$argcount} valid command line argument" .
- (($argcount !== 1) ? 's' : '') . ".\n";
+ if (!isset($commandlineargs['w'])) {
+ echo "\nProcessed {$argcount} valid command line argument" .
+ (($argcount !== 1) ? 's' : '') . ".\n";
+ }
$this->processedoptions = $this->prioritise_options($commandlineargs);
- if ($this->processedoptions['help']) {
+ if (!empty($this->processedoptions['help'])) {
$this->show_help();
exit;
}
@@ -132,6 +139,10 @@ public function parse_options(): array {
*/
public function validate_and_clean_args(): void {
$cliargs = $this->processedoptions;
+ if (!isset($cliargs['usegit'])) {
+ echo "\nAre you using Git? You will need to specify true or false for --usegit.\n";
+ static::call_exit();
+ }
if (!isset($cliargs['token'])) {
echo "\nYou will need a security token (--token).\n";
static::call_exit();
@@ -187,8 +198,8 @@ public function validate_and_clean_args(): void {
if (isset($cliargs['manifestpath'])) {
$cliargs['manifestpath'] = $this->trim_slashes($cliargs['manifestpath']);
if (isset($cliargs['coursename']) || isset($cliargs['modulename'])
- || isset($cliargs['coursecategory']) || (isset($cliargs['instanceid'])
- || isset($cliargs['contextlevel']) )) {
+ || isset($cliargs['coursecategory']) || isset($cliargs['instanceid'])
+ || isset($cliargs['contextlevel'])) {
echo "\nYou have specified a manifest file (possibly as a default in your config file). " .
"Contextlevel, instance id, course name, module name and/or course category are not needed. " .
"Context data can be extracted from the file.\n";
@@ -270,12 +281,14 @@ public function validate_and_clean_args(): void {
break;
}
}
- if (!isset($cliargs['manifestpath']) && !isset($cliargs['contextlevel'])) {
+ if (!isset($cliargs['manifestpath']) && !isset($cliargs['quizmanifestpath'])
+ && !isset($cliargs['contextlevel']) && !isset($cliargs['quizdatapath'])) {
echo "\nYou have not specified context. " .
"You must specify context level (--contextlevel) unless " .
"using a function where this information can be read from a manifest file, in which case " .
"you could set a manifest path (--manifestpath) instead. If using exportrepofrommoodle, you " .
- "must set manifest path only. If you still see this message, you may be using invalid arguments.\n";
+ "must set manifest path only. If dealing with export of quizzes, you must specify --quizmanifestpath. " .
+ "If you still see this message, you may be using invalid arguments.\n";
static::call_exit();
}
@@ -289,7 +302,7 @@ public function validate_and_clean_args(): void {
* @param string $path
* @return string
*/
- public function trim_slashes(string $path):string {
+ public function trim_slashes(string $path): string {
$path = str_replace( '\\', '/', $path);
if (substr($path, 0, 1) === '/') {
$path = substr($path, 1);
@@ -312,13 +325,19 @@ public function prioritise_options($commandlineargs): array {
foreach ($this->options as $option) {
$variablename = $option['variable'];
if ($option['valuerequired']) {
- if (isset($commandlineargs[$option['longopt']])) {
+ if (isset($option['hidden'])) {
+ $variables[$variablename] = $option['default'];
+ } else if (isset($commandlineargs[$option['longopt']])) {
$variables[$variablename] = $commandlineargs[$option['longopt']];
} else if (isset($commandlineargs[$option['shortopt']])) {
$variables[$variablename] = $commandlineargs[$option['shortopt']];
} else {
$variables[$variablename] = $option['default'];
}
+ if (in_array($variablename, ['usegit'])) {
+ $variables[$variablename] = ($variables[$variablename] === 'true') ? true : $variables[$variablename];
+ $variables[$variablename] = ($variables[$variablename] === 'false') ? false : $variables[$variablename];
+ }
} else {
if (isset($commandlineargs[$option['longopt']]) || isset($commandlineargs[$option['shortopt']])) {
$variables[$variablename] = true;
@@ -337,9 +356,11 @@ public function prioritise_options($commandlineargs): array {
*
* @return void
*/
- public function show_help() {
+ public function show_help(): void {
foreach ($this->options as $option) {
- echo "-{$option['shortopt']} --{$option['longopt']} \t{$option['description']}\n";
+ if (!isset($option['hidden'])) {
+ echo "-{$option['shortopt']} --{$option['longopt']} \t{$option['description']}\n";
+ }
}
exit;
}
@@ -379,7 +400,7 @@ public static function get_context_level(string $level): int {
* @return string
*/
public static function get_manifest_path(string $moodleinstance, string $contextlevel, ?string $coursecategory,
- ?string $coursename, ?string $modulename, string $directory):string {
+ ?string $coursename, ?string $modulename, string $directory): string {
$filenamemod = '_' . $contextlevel;
switch ($contextlevel) {
case 'coursecategory':
@@ -399,23 +420,48 @@ public static function get_manifest_path(string $moodleinstance, string $context
return $filename;
}
+ /**
+ * Create quiz structure path.
+ *
+ * @param string|null $modulename
+ * @param string $directory
+ * @return string
+ */
+ public static function get_quiz_structure_path(string $modulename, string $directory): string {
+ $filename = substr($modulename, 0, 100);
+ $filename = $directory . '/' .
+ preg_replace(self::BAD_CHARACTERS, '-', strtolower($filename)) .
+ self::QUIZ_FILE;
+ return $filename;
+ }
+
+ /**
+ * Create quiz directory name.
+ *
+ * @param string $basedirectory
+ * @param string $directory
+ * @return string
+ */
+ public static function get_quiz_directory(string $basedirectory, string $quizname): string {
+ $quizname = substr($quizname, 0, 100);
+ $directoryname = $basedirectory . '_quiz_' .
+ preg_replace(self::BAD_CHARACTERS, '-', strtolower($quizname));
+ return $directoryname;
+ }
+
/**
* Create manifest file from temporary file.
*
* @param object $manifestcontents \stdClass Current contents of manifest file
* @param string $tempfilepath
* @param string $manifestpath
- * @param string $moodleurl
- * @param int|null $subcategoryid
- * @param string|null $subdirectory
* @param bool $showupdated
* @return object
*/
- public static function create_manifest_file(object $manifestcontents, string $tempfilepath,
- string $manifestpath, string $moodleurl,
- ?int $subcategoryid=null,
- ?string $subdirectory=null,
- bool $showupdated=true):object {
+ public static function create_manifest_file(object $manifestcontents,
+ string $tempfilepath,
+ string $manifestpath,
+ bool $showupdated=true): object {
// Read in temp file a question at a time, process and add to manifest.
// No actual processing at the moment so could simplify to write straight
// to manifest in the first place if no processing materialises.
@@ -456,18 +502,6 @@ public static function create_manifest_file(object $manifestcontents, string $te
$existingentries["{$questioninfo->questionbankentryid}"]->moodlecommit = $questioninfo->moodlecommit;
}
}
- if ($manifestcontents->context === null) {
- $manifestcontents->context = new \stdClass();
- $manifestcontents->context->contextlevel = $questioninfo->contextlevel;
- $manifestcontents->context->coursename = $questioninfo->coursename;
- $manifestcontents->context->modulename = $questioninfo->modulename;
- $manifestcontents->context->coursecategory = $questioninfo->coursecategory;
- $manifestcontents->context->instanceid = $questioninfo->instanceid;
- $manifestcontents->context->defaultsubcategoryid = $subcategoryid;
- $manifestcontents->context->defaultsubdirectory = $subdirectory;
- $manifestcontents->context->defaultignorecat = $questioninfo->ignorecat;
- $manifestcontents->context->moodleurl = $moodleurl;
- }
}
}
echo "\nAdded {$addedcount} question" . (($addedcount !== 1) ? 's' : '') . ".\n";
@@ -489,7 +523,7 @@ public static function create_manifest_file(object $manifestcontents, string $te
* @param string $question original question XML
* @return string tidied question XML
*/
- public static function reformat_question(string $question):string {
+ public static function reformat_question(string $question): string {
$quiz = simplexml_load_string($question);
if ($quiz === false) {
throw new \Exception('Broken XML');
@@ -507,10 +541,11 @@ public static function reformat_question(string $question):string {
* @param object $activity e.g. import_repo
* @return void
*/
- public function commit_hash_update(object $activity):void {
+ public function commit_hash_update(object $activity): void {
if (!$this->get_arguments()['usegit']) {
return;
}
+ chdir(dirname($activity->manifestpath));
foreach ($activity->manifestcontents->questions as $question) {
$commithash = exec('git log -n 1 --pretty=format:%H -- "' . substr($question->filepath, 1) . '"');
if ($commithash) {
@@ -531,15 +566,15 @@ public function commit_hash_update(object $activity):void {
* @param object $activity e.g. create_repo
* @return void
*/
- public function commit_hash_setup(object $activity):void {
+ public function commit_hash_setup(object $activity): void {
if (!$this->get_arguments()['usegit']) {
return;
}
$this->create_gitignore($activity->manifestpath);
$manifestdirname = dirname($activity->manifestpath);
chdir($manifestdirname);
- exec("git add .");
- exec('git commit -m "Initial Commit"');
+ exec("git add --all");
+ exec('git commit -m "Initial Commit - ' . basename($activity->manifestpath) . '"');
foreach ($activity->manifestcontents->questions as $question) {
$commithash = exec('git log -n 1 --pretty=format:%H -- "' . substr($question->filepath, 1) . '"');
if ($commithash) {
@@ -559,7 +594,7 @@ public function commit_hash_setup(object $activity):void {
* @param object $activity e.g. create_repo
* @return void
*/
- public function tidy_repo_xml(object $activity):void {
+ public function tidy_repo_xml(object $activity): void {
if ($activity->subdirectory) {
$subdirectory = $activity->directory . '/' . $activity->subdirectory;
} else {
@@ -585,14 +620,16 @@ public function tidy_repo_xml(object $activity):void {
* @param string $manifestpath
* @return void
*/
- public function create_gitignore(string $manifestpath):void {
+ public function create_gitignore(string $manifestpath): void {
if (!$this->get_arguments()['usegit']) {
return;
}
$manifestdirname = dirname($manifestpath);
if (!is_file($manifestdirname . '/.gitignore')) {
$ignore = fopen($manifestdirname . '/.gitignore', 'a');
- $contents = "**/*_question_manifest.json\n**/*_manifest_update.tmp\n";
+
+ $contents = "**/*" . self::MANIFEST_FILE . "\n**/*" .
+ self::TEMP_MANIFEST_FILE . "\n";
fwrite($ignore, $contents);
fclose($ignore);
}
@@ -604,17 +641,15 @@ public function create_gitignore(string $manifestpath):void {
* @param string $fullmanifestpath
* @return void
*/
- public function check_repo_initialised(string $fullmanifestpath):void {
+ public function check_repo_initialised(string $fullmanifestpath): void {
if (!$this->get_arguments()['usegit']) {
return;
}
$manifestdirname = dirname($fullmanifestpath);
if (chdir($manifestdirname)) {
// Will give path of .git if in repo or error.
- // Working on the assumption we have to be at the top of the repo.
- if (exec('git rev-parse --git-dir') !== '.git') {
- echo "The Git repository has not been initialised or " .
- "the manifest directory is not at the top level.\n";
+ if (substr(exec('git rev-parse --git-dir'), -4) !== '.git') {
+ echo "The Git repository has not been initialised.\n";
exit;
}
} else {
@@ -623,6 +658,23 @@ public function check_repo_initialised(string $fullmanifestpath):void {
}
}
+ /**
+ * Create directory.
+ *
+ * @param string $directory
+ * @return string updated directory name
+ */
+ public function create_directory(string $directory): string {
+ $basename = $directory;
+ $i = 0;
+ while (is_dir($directory)) {
+ $i++;
+ $directory = $basename . '_' . $i;
+ }
+ mkdir($directory);
+ return $directory;
+ }
+
/**
* Check the git repo containing the manifest file to see if there
* are any uncommited changes and stop if there are.
@@ -630,8 +682,8 @@ public function check_repo_initialised(string $fullmanifestpath):void {
* @param string $fullmanifestpath
* @return void
*/
- public function check_for_changes($fullmanifestpath) {
- if (!$this->get_arguments()['usegit']) {
+ public function check_for_changes(string $fullmanifestpath): void {
+ if (!$this->get_arguments()['usegit'] || !empty($this->get_arguments()['subcall'])) {
return;
}
$this->check_repo_initialised($fullmanifestpath);
@@ -657,7 +709,7 @@ public function check_for_changes($fullmanifestpath) {
* @param string $fullmanifestpath
* @return void
*/
- public function backup_manifest($fullmanifestpath) {
+ public function backup_manifest(string $fullmanifestpath): void {
$manifestdirname = dirname($fullmanifestpath);
$manifestfilename = basename($fullmanifestpath);
$backupdir = $manifestdirname . '/manifest_backups';
@@ -674,7 +726,7 @@ public function backup_manifest($fullmanifestpath) {
*
* @return void
*/
- public static function call_exit():void {
+ public static function call_exit(): void {
exit;
}
@@ -687,7 +739,7 @@ public static function call_exit():void {
* @param bool $silent If true, don't display returned info
* @return object
*/
- public function check_context(object $activity, bool $defaultwarning=false, bool $silent=false):object {
+ public function check_context(object $activity, bool $defaultwarning=false, bool $silent=false): object {
$activity->listpostsettings['contextonly'] = 1;
$activity->listcurlrequest->set_option(CURLOPT_POSTFIELDS, $activity->listpostsettings);
$response = $activity->listcurlrequest->execute();
@@ -705,7 +757,7 @@ public function check_context(object $activity, bool $defaultwarning=false, bool
echo "Failed to get list of questions from Moodle.\n";
static::call_exit();
return new \stdClass(); // Required for PHPUnit.
- } else if (!$silent) {
+ } else if (!$silent && empty($this->get_arguments()['subcall'])) {
$activityname = get_class($activity);
switch ($activityname) {
case 'qbank_gitsync\export_repo':
@@ -760,7 +812,7 @@ public function check_context(object $activity, bool $defaultwarning=false, bool
*
* @return void
*/
- public static function handle_abort():void {
+ public static function handle_abort(): void {
echo "Abort? y/n\n";
$handle = fopen ("php://stdin", "r");
$line = fgets($handle);
@@ -778,7 +830,7 @@ public static function handle_abort():void {
* @param [type] $filename
* @return string|null $qcategoryname Question category name in format top/cat1/subcat1
*/
- public static function get_question_category_from_file($filename):?string {
+ public static function get_question_category_from_file($filename): ?string {
if (!is_file($filename)) {
echo "\nRequired category file does not exist: {$filename}\n";
return null;
diff --git a/classes/create_repo.php b/classes/create_repo.php
index deb9dd3..0d2fee0 100644
--- a/classes/create_repo.php
+++ b/classes/create_repo.php
@@ -84,6 +84,12 @@ class create_repo {
* @var string
*/
public string $manifestpath;
+ /**
+ * Path to actual manifest file.
+ *
+ * @var string|null
+ */
+ public ?string $nonquizmanifestpath;
/**
* Path to temporary manifest file.
*
@@ -109,6 +115,12 @@ class create_repo {
* @var string
*/
public string $moodleurl;
+ /**
+ * Are we using git?.
+ * Set in config. Adds commit hash to manifest.
+ * @var bool
+ */
+ public bool $usegit;
/**
* Constructor.
@@ -121,11 +133,19 @@ public function __construct(cli_helper $clihelper, array $moodleinstances) {
// (Moodle code rules don't allow 'extract()').
$arguments = $clihelper->get_arguments();
$moodleinstance = $arguments['moodleinstance'];
+ $this->usegit = $arguments['usegit'];
if ($arguments['directory']) {
- $this->directory = $arguments['rootdirectory'] . '/' . $arguments['directory'];
+ $this->directory = ($arguments['rootdirectory']) ?
+ $arguments['rootdirectory'] . '/' . $arguments['directory'] : $arguments['directory'];
} else {
$this->directory = $arguments['rootdirectory'];
}
+ if (!empty($arguments['nonquizmanifestpath'])) {
+ $this->nonquizmanifestpath = ($arguments['rootdirectory']) ?
+ $arguments['rootdirectory'] . '/' . $arguments['nonquizmanifestpath'] : $arguments['nonquizmanifestpath'];
+ } else {
+ $this->nonquizmanifestpath = null;
+ }
$this->subcategory = ($arguments['subcategory']) ? $arguments['subcategory'] : 'top';
if (is_array($arguments['token'])) {
$token = $arguments['token'][$moodleinstance];
@@ -134,8 +154,8 @@ public function __construct(cli_helper $clihelper, array $moodleinstances) {
}
$contextlevel = $arguments['contextlevel'];
$coursename = $arguments['coursename'];
- $modulename = $arguments['modulename'];
- $coursecategory = $arguments['coursecategory'];
+ $modulename = (isset($arguments['modulename'])) ? $arguments['modulename'] : null;
+ $coursecategory = (isset($arguments['coursecategory'])) ? $arguments['coursecategory'] : null;
$qcategoryid = $arguments['qcategoryid'];
$instanceid = $arguments['instanceid'];
$this->ignorecat = $arguments['ignorecat'];
@@ -197,6 +217,18 @@ public function __construct(cli_helper $clihelper, array $moodleinstances) {
$this->tempfilepath = str_replace(cli_helper::MANIFEST_FILE,
'_export' . cli_helper::TEMP_MANIFEST_FILE,
$this->manifestpath);
+ $this->manifestcontents = new \stdClass();
+ $this->manifestcontents->context = new \stdClass();
+ $this->manifestcontents->context->contextlevel = cli_helper::get_context_level($instanceinfo->contextinfo->contextlevel);
+ $this->manifestcontents->context->coursename = $instanceinfo->contextinfo->coursename;
+ $this->manifestcontents->context->modulename = $instanceinfo->contextinfo->modulename;
+ $this->manifestcontents->context->coursecategory = $instanceinfo->contextinfo->categoryname;
+ $this->manifestcontents->context->instanceid = $instanceinfo->contextinfo->instanceid;
+ $this->manifestcontents->context->defaultsubcategoryid = $this->qcategoryid;
+ $this->manifestcontents->context->defaultsubdirectory = null;
+ $this->manifestcontents->context->defaultignorecat = $this->ignorecat;
+ $this->manifestcontents->context->moodleurl = $this->moodleurl;
+ $this->manifestcontents->questions = [];
}
/**
@@ -205,14 +237,11 @@ public function __construct(cli_helper $clihelper, array $moodleinstances) {
*
* @return void
*/
- public function process():void {
- $this->manifestcontents = new \stdClass();
- $this->manifestcontents->context = null;
- $this->manifestcontents->questions = [];
+ public function process(): void {
$this->export_to_repo();
+ $this->manifestcontents->context->defaultsubdirectory = $this->subdirectory;
cli_helper::create_manifest_file($this->manifestcontents, $this->tempfilepath,
- $this->manifestpath, $this->moodleurl,
- $this->qcategoryid, $this->subdirectory, false);
+ $this->manifestpath, false);
unlink($this->tempfilepath);
}
@@ -222,7 +251,83 @@ public function process():void {
* @param string $wsurl webservice URL
* @return curl_request
*/
- public function get_curl_request($wsurl):curl_request {
+ public function get_curl_request($wsurl): curl_request {
return new \qbank_gitsync\curl_request($wsurl);
}
+
+ /**
+ * Create quiz directories and populate.
+ * @param object $clihelper
+ * @param string $scriptdirectory - directory of CLI scripts
+ * @return void
+ */
+ public function create_quiz_directories(object $clihelper, string $scriptdirectory): void {
+ $contextinfo = $clihelper->check_context($this, false, true);
+ $arguments = $clihelper->get_arguments();
+ if ($arguments['directory']) {
+ $basedirectory = $arguments['rootdirectory'] . '/' . $arguments['directory'];
+ } else {
+ $basedirectory = $arguments['rootdirectory'];
+ }
+ $moodleinstance = $arguments['moodleinstance'];
+ $instanceid = $arguments['instanceid'];
+ if (is_array($arguments['token'])) {
+ $token = $arguments['token'][$moodleinstance];
+ } else {
+ $token = $arguments['token'];
+ }
+ $ignorecat = $arguments['ignorecat'];
+ $ignorecat = ($ignorecat) ? ' -x "' . $ignorecat . '"' : '';
+ $quizlocations = [];
+ foreach ($contextinfo->quizzes as $quiz) {
+ $instanceid = $quiz->instanceid;
+ $quizdirectory = cli_helper::get_quiz_directory($basedirectory, $quiz->name);
+ $rootdirectory = $clihelper->create_directory($quizdirectory);
+ echo "\nExporting quiz: {$quiz->name} to {$rootdirectory}\n";
+ $output = $this->call_repo_creation($rootdirectory, $moodleinstance, $instanceid, $token, $ignorecat, $scriptdirectory);
+ echo $output;
+ $quizmanifestpath = cli_helper::get_manifest_path($moodleinstance, 'module', null,
+ $contextinfo->contextinfo->coursename, $quiz->name, $rootdirectory);
+ $output = $this->call_export_quiz($moodleinstance, $token, $quizmanifestpath,
+ $this->manifestpath, $scriptdirectory);
+ $quizlocation = new \StdClass();
+ $quizlocation->moduleid = $instanceid;
+ $quizlocation->directory = basename($rootdirectory);
+ $quizlocations[] = $quizlocation;
+ $this->manifestcontents->quizzes = $quizlocations;
+ $success = file_put_contents($this->manifestpath, json_encode($this->manifestcontents));
+ if ($success === false) {
+ echo "\nUnable to update manifest file: {$this->manifestpath}\n Aborting.\n";
+ exit();
+ }
+ echo $output;
+ }
+ if ($arguments['usegit'] && empty($arguments['subcall'])) {
+ // Commit the final quiz file.
+ // The others are committed by the following createrepo.
+ chdir($basedirectory);
+ exec("git add --all");
+ exec('git commit -m "Initial Commit - final update"');
+ }
+ }
+
+ /**
+ * Separate out exec call for mocking.
+ *
+ * @param string $rootdirectory
+ * @param string $moodleinstance
+ * @param string $instanceid
+ * @param string $token
+ * @param string $ignorecat
+ * @param string $scriptdirectory
+ * @return string|null
+ */
+ public function call_repo_creation(string $rootdirectory, string $moodleinstance, string $instanceid,
+ string $token, string $ignorecat, string $scriptdirectory
+ ): ?string {
+ chdir($scriptdirectory);
+ return shell_exec('php createrepo.php -u ' . $this->usegit . ' -w -r "' .
+ $rootdirectory . '" -i "' . $moodleinstance .
+ '" -l "module" -n ' . $instanceid . ' -t ' . $token . $ignorecat);
+ }
}
diff --git a/classes/export_quiz.php b/classes/export_quiz.php
new file mode 100644
index 0000000..a248a49
--- /dev/null
+++ b/classes/export_quiz.php
@@ -0,0 +1,283 @@
+.
+
+/**
+ * Wrapper class for processing performed by command line interface for exporting quiz data from Moodle.
+ *
+ * Utilised in cli\exportquizstructurefrommoodle.php
+ *
+ * Allows mocking and unit testing via PHPUnit.
+ * Used outside Moodle.
+ *
+ * @package qbank_gitsync
+ * @copyright 2024 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace qbank_gitsync;
+
+/**
+ * Export structure data of a Moodle quiz.
+ */
+class export_quiz {
+ /**
+ * Settings for POST request
+ *
+ * These are the parameters for the webservice call.
+ *
+ * @var array
+ */
+ public array $postsettings;
+ /**
+ * cURL request handle for file upload
+ *
+ * @var curl_request
+ */
+ public curl_request $curlrequest;
+ /**
+ * cURL request handle for question list retrieve
+ *
+ * @var curl_request
+ */
+ public curl_request $listcurlrequest;
+ /**
+ * Settings for question list request
+ *
+ * These are the parameters for the webservice list call.
+ *
+ * @var array
+ */
+ public array $listpostsettings;
+ /**
+ * Full path to manifest file
+ *
+ * @var string|null
+ */
+ public ?string $quizmanifestpath = null;
+ /**
+ * Parsed content of JSON manifest file
+ *
+ * @var \stdClass|null
+ */
+ public ?\stdClass $quizmanifestcontents = null;
+ /**
+ * URL of Moodle instance
+ *
+ * @var string
+ */
+ public string $moodleurl;
+ /**
+ * Full path to manifest file
+ *
+ * @var string|null
+ */
+ public ?string $nonquizmanifestpath = null;
+ /**
+ * Parsed content of JSON manifest file
+ *
+ * @var \stdClass|null
+ */
+ public ?\stdClass $nonquizmanifestcontents = null;
+ /**
+ * Full path to output file
+ *
+ * @var string
+ */
+ public string $filepath;
+
+ /**
+ * Constructor
+ *
+ * @param cli_helper $clihelper
+ * @param array $moodleinstances pairs of names and URLs
+ */
+ public function __construct(cli_helper $clihelper, array $moodleinstances) {
+ // Convert command line options into variables.
+ $arguments = $clihelper->get_arguments();
+ $moodleinstance = $arguments['moodleinstance'];
+ $this->moodleurl = $moodleinstances[$moodleinstance];
+ $rootdirectory = ($arguments['rootdirectory']) ? $arguments['rootdirectory'] . '/' : '';
+ $this->quizmanifestpath = ($arguments['quizmanifestpath']) ?
+ $rootdirectory . $arguments['quizmanifestpath'] : null;
+ $this->quizmanifestcontents = json_decode(file_get_contents($this->quizmanifestpath));
+ if (!$this->quizmanifestcontents) {
+ echo "\nUnable to access or parse manifest file: {$this->quizmanifestpath}\nAborting.\n";
+ $this->call_exit();
+ }
+ if ($this->quizmanifestcontents->context->moodleurl !== $this->moodleurl) {
+ echo "\nManifest file is for the wrong Moodle instance: {$this->quizmanifestpath}\nAborting.\n";
+ $this->call_exit();
+ }
+ $instanceid = $this->quizmanifestcontents->context->instanceid;
+ if (!empty($arguments['nonquizmanifestpath'])) {
+ $this->nonquizmanifestpath = ($arguments['nonquizmanifestpath']) ?
+ $rootdirectory . $arguments['nonquizmanifestpath'] : null;
+ $this->nonquizmanifestcontents = json_decode(file_get_contents($this->nonquizmanifestpath));
+ if (!$this->nonquizmanifestcontents) {
+ echo "\nUnable to access or parse manifest file: {$this->nonquizmanifestpath}\nAborting.\n";
+ $this->call_exit();
+ }
+ if ($this->nonquizmanifestcontents->context->moodleurl !== $this->moodleurl) {
+ echo "\nManifest file is for the wrong Moodle instance: {$this->nonquizmanifestpath}\nAborting.\n";
+ $this->call_exit();
+ }
+ }
+ if (is_array($arguments['token'])) {
+ $token = $arguments['token'][$moodleinstance];
+ } else {
+ $token = $arguments['token'];
+ }
+
+ $wsurl = $this->moodleurl . '/webservice/rest/server.php';
+
+ $this->curlrequest = $this->get_curl_request($wsurl);
+ $this->postsettings = [
+ 'wstoken' => $token,
+ 'wsfunction' => 'qbank_gitsync_export_quiz_data',
+ 'moodlewsrestformat' => 'json',
+ 'moduleid' => $instanceid,
+ 'coursename' => null,
+ 'quizname' => null,
+ ];
+ $this->curlrequest->set_option(CURLOPT_RETURNTRANSFER, true);
+ $this->curlrequest->set_option(CURLOPT_POST, 1);
+ $this->curlrequest->set_option(CURLOPT_POSTFIELDS, $this->postsettings);
+ if (empty($arguments['subcall'])) {
+ $this->listcurlrequest = $this->get_curl_request($wsurl);
+ $this->listpostsettings = [
+ 'wstoken' => $token,
+ 'wsfunction' => 'qbank_gitsync_get_question_list',
+ 'moodlewsrestformat' => 'json',
+ 'contextlevel' => 70,
+ 'coursename' => null,
+ 'modulename' => null,
+ 'coursecategory' => null,
+ 'qcategoryname' => 'top',
+ 'qcategoryid' => null,
+ 'instanceid' => $instanceid,
+ 'contextonly' => 1,
+ 'qbankentryids[]' => null,
+ 'ignorecat' => null,
+ ];
+ $this->listcurlrequest->set_option(CURLOPT_RETURNTRANSFER, true);
+ $this->listcurlrequest->set_option(CURLOPT_POST, 1);
+ $clihelper->check_context($this, false, false);
+ }
+ }
+
+ /**
+ * Get quiz data from webservice, convert question ids to file locations
+ * and then write to file.
+ *
+ * @return void
+ */
+ public function process(): void {
+ $this->export_quiz_data();
+ }
+
+ /**
+ * Wrapper for cURL request to allow mocking.
+ *
+ * @param string $wsurl webservice URL
+ * @return curl_request
+ */
+ public function get_curl_request($wsurl): curl_request {
+ return new \qbank_gitsync\curl_request($wsurl);
+ }
+
+ /**
+ * Get quiz data from webservice, convert question ids to file locations
+ * and then write to file.
+ *
+ * @return void
+ */
+ public function export_quiz_data() {
+ $response = $this->curlrequest->execute();
+ $responsejson = json_decode($response);
+ if (!$responsejson) {
+ echo "Broken JSON returned from Moodle:\n";
+ echo $response . "\n";
+ echo "Quiz data file not updated.\n";
+ $this->call_exit();
+ $responsejson = json_decode('{"quiz": {"name": ""}, "questions": []}'); // For unit test purposes.
+ } else if (property_exists($responsejson, 'exception')) {
+ echo "{$responsejson->message}\n";
+ if (property_exists($responsejson, 'debuginfo')) {
+ echo "{$responsejson->debuginfo}\n";
+ }
+ echo "Quiz data file not updated.\n";
+ $this->call_exit();
+ $responsejson = json_decode('{"quiz": {"name": ""}, "questions": []}'); // For unit test purposes.
+ }
+ $quizmanifestentries = [];
+ $nonquizmanifestentries = [];
+ $missingquestions = false;
+ // Determine quiz info location based on locations of manifest paths.
+ if ($this->quizmanifestpath) {
+ $this->filepath = cli_helper::get_quiz_structure_path($responsejson->quiz->name, dirname($this->quizmanifestpath));
+ $quizmanifestentries = array_column($this->quizmanifestcontents->questions, null, 'questionbankentryid');
+ } else {
+ $this->filepath = cli_helper::get_quiz_structure_path($responsejson->quiz->name, dirname($this->nonquizmanifestpath));
+ }
+ if ($this->nonquizmanifestpath) {
+ $nonquizmanifestentries = array_column($this->nonquizmanifestcontents->questions, null, 'questionbankentryid');
+ }
+ // Convert the returned QBE ids into file locations using the manifest files to translate.
+ foreach ($responsejson->questions as $question) {
+ $quizmanifestentry = $quizmanifestentries["{$question->questionbankentryid}"] ?? false;
+ $nonquizmanifestentry = $nonquizmanifestentries["{$question->questionbankentryid}"] ?? false;
+ if ($quizmanifestentry) {
+ $question->quizfilepath = $quizmanifestentry->filepath;
+ unset($question->questionbankentryid);
+ } else if ($nonquizmanifestentry) {
+ $question->nonquizfilepath = $nonquizmanifestentry->filepath;
+ unset($question->questionbankentryid);
+ } else {
+ $missingquestions = true;
+ $multiple = ($this->quizmanifestpath && $this->nonquizmanifestpath) ? 's' : '';
+ echo "\nQuestion: {$question->questionbankentryid}\n";
+ echo "This question is in the quiz but not in the supplied manifest file{$multiple}\n";
+ }
+ }
+ if ($missingquestions) {
+ echo "Questions must either be in the repo for the quiz context defined by a supplied quiz manifest " .
+ "(--quizmanifestpath) or in the context (e.g. course) " .
+ "defined by a different manifest (--nonquizmanifestpath).\n";
+ echo "You can supply either or both. If your quiz questions are spread between 3 or more contexts " .
+ "you will need to consolidate them.\n";
+ echo "Quiz structure file: {$this->filepath} not updated.\n";
+ } else {
+ // Save exported information (including relative file location but not QBE id so Moodle independent).
+ $success = file_put_contents($this->filepath, json_encode($responsejson));
+ if ($success === false) {
+ echo "\nUnable to update quiz structure file: {$this->filepath}\n Aborting.\n";
+ $this->call_exit();
+ }
+ echo "Quiz data exported to:\n";
+ echo "{$this->filepath}\n";
+ }
+ }
+
+ /**
+ * Mockable function that just exits code.
+ *
+ * Required to stop PHPUnit displaying output after exit.
+ *
+ * @return void
+ */
+ public function call_exit(): void {
+ exit;
+ }
+}
diff --git a/classes/export_repo.php b/classes/export_repo.php
index 5b5e028..1e5a299 100644
--- a/classes/export_repo.php
+++ b/classes/export_repo.php
@@ -68,6 +68,12 @@ class export_repo {
* @var string
*/
public string $manifestpath;
+ /**
+ * Full path to manifest file
+ *
+ * @var string|null
+ */
+ public ?string $nonquizmanifestpath;
/**
* Path to temporary manifest file
*
@@ -98,6 +104,12 @@ class export_repo {
* @var string|null
*/
public ?string $ignorecat;
+ /**
+ * Are we using git?.
+ * Set in config. Adds commit hash to manifest.
+ * @var bool
+ */
+ public bool $usegit;
/**
* Constructor
@@ -110,8 +122,21 @@ public function __construct(cli_helper $clihelper, array $moodleinstances) {
// (Moodle code rules don't allow 'extract()').
$arguments = $clihelper->get_arguments();
$moodleinstance = $arguments['moodleinstance'];
+ $this->moodleurl = $moodleinstances[$moodleinstance];
+ $this->usegit = $arguments['usegit'];
$defaultwarning = false;
- $this->manifestpath = $arguments['rootdirectory'] . '/' . $arguments['manifestpath'];
+ if ($arguments['manifestpath']) {
+ $this->manifestpath = ($arguments['rootdirectory']) ? $arguments['rootdirectory'] . '/' . $arguments['manifestpath'] :
+ $arguments['manifestpath'];
+ } else {
+ $this->manifestpath = null;
+ }
+ if (!empty($arguments['nonquizmanifestpath'])) {
+ $this->nonquizmanifestpath = ($arguments['rootdirectory']) ?
+ $arguments['rootdirectory'] . '/' . $arguments['nonquizmanifestpath'] : $arguments['nonquizmanifestpath'];
+ } else {
+ $this->nonquizmanifestpath = null;
+ }
if (is_array($arguments['token'])) {
$token = $arguments['token'][$moodleinstance];
} else {
@@ -122,6 +147,11 @@ public function __construct(cli_helper $clihelper, array $moodleinstances) {
echo "\nUnable to access or parse manifest file: {$this->manifestpath}\nAborting.\n";
$this->call_exit();
}
+ if ($this->manifestcontents->context->moodleurl !== $this->moodleurl) {
+ echo "\nManifest file is for the wrong Moodle instance: {$this->manifestcontents}\nAborting.\n";
+ $this->call_exit();
+ }
+
if ($arguments['subcategory']) {
$this->subcategory = $arguments['subcategory'];
$qcategoryid = null;
@@ -144,7 +174,6 @@ public function __construct(cli_helper $clihelper, array $moodleinstances) {
$this->tempfilepath = str_replace(cli_helper::MANIFEST_FILE,
'_export' . cli_helper::TEMP_MANIFEST_FILE,
$this->manifestpath);
- $this->moodleurl = $moodleinstances[$moodleinstance];
$wsurl = $this->moodleurl . '/webservice/rest/server.php';
$this->curlrequest = $this->get_curl_request($wsurl);
@@ -186,14 +215,13 @@ public function __construct(cli_helper $clihelper, array $moodleinstances) {
*
* @return void
*/
- public function process():void {
+ public function process(): void {
// Export latest versions of questions in manifest from Moodle.
$this->export_questions_in_manifest();
// Export any questions that are in Moodle but not in the manifest.
$this->export_to_repo();
cli_helper::create_manifest_file($this->manifestcontents, $this->tempfilepath,
- $this->manifestpath, $this->moodleurl,
- null, null, false);
+ $this->manifestpath, false);
unlink($this->tempfilepath);
// Remove questions from manifest that are no longer in Moodle.
// Will be restored from repo on next import if file is still there.
@@ -206,7 +234,7 @@ public function process():void {
* @param string $wsurl webservice URL
* @return curl_request
*/
- public function get_curl_request($wsurl):curl_request {
+ public function get_curl_request($wsurl): curl_request {
return new \qbank_gitsync\curl_request($wsurl);
}
@@ -288,4 +316,102 @@ public function export_questions_in_manifest() {
$this->call_exit();
}
}
+
+ /**
+ * Create/update quiz directories and populate.
+ * @param object $clihelper
+ * @param string $scriptdirectory - directory of CLI scripts
+ * @return void
+ */
+ public function update_quiz_directories($clihelper, $scriptdirectory) {
+ $arguments = $clihelper->get_arguments();
+ $contextinfo = $clihelper->check_context($this, false, true);
+ $basedirectory = dirname($this->manifestpath);
+ $moodleinstance = $arguments['moodleinstance'];
+ if (is_array($arguments['token'])) {
+ $token = $arguments['token'][$moodleinstance];
+ } else {
+ $token = $arguments['token'];
+ }
+ $ignorecat = $arguments['ignorecat'];
+ $ignorecat = ($ignorecat) ? ' -x "' . $ignorecat . '"' : '';
+ $quizlocations = isset($this->manifestcontents->quizzes) ? $this->manifestcontents->quizzes : [];
+ $locarray = array_column($quizlocations, null, 'moduleid');
+ foreach ($contextinfo->quizzes as $quiz) {
+ $instanceid = (int) $quiz->instanceid;
+ if (!isset($locarray[$instanceid])) {
+ $rootdirectory = $clihelper->create_directory(cli_helper::get_quiz_directory($basedirectory, $quiz->name));
+ if (!isset($locarray[$instanceid])) {
+ $quizlocation = new \StdClass();
+ $quizlocation->moduleid = $instanceid;
+ $quizlocation->directory = basename($rootdirectory);
+ $quizlocations[] = $quizlocation;
+ $this->manifestcontents->quizzes = $quizlocations;
+ $success = file_put_contents($this->manifestpath, json_encode($this->manifestcontents));
+ if ($success === false) {
+ echo "\nUnable to update manifest file: {$this->manifestpath}\n Aborting.\n";
+ exit();
+ }
+ }
+ echo "\nExporting quiz: {$quiz->name} to {$rootdirectory}\n";
+ $output = $this->call_repo_creation($rootdirectory, $moodleinstance,
+ $instanceid, $token, $ignorecat, $scriptdirectory);
+ } else if (!is_dir(dirname($basedirectory) . '/' . $locarray[$instanceid]->directory)) {
+ $rootdirectory = dirname($basedirectory) . '/' . $locarray[$instanceid]->directory;
+ mkdir($rootdirectory);
+ mkdir($rootdirectory . '/top');
+ echo "\nExporting quiz: {$quiz->name} to {$rootdirectory}\n";
+ $output = $this->call_repo_creation($rootdirectory, $moodleinstance,
+ $instanceid, $token, $ignorecat, $scriptdirectory);
+ } else {
+ $rootdirectory = dirname($basedirectory) . '/' . $locarray[$instanceid]->directory;
+ echo "\nExporting quiz: {$quiz->name} to {$rootdirectory}\n";
+ $quizmanifestname = cli_helper::get_manifest_path($moodleinstance, 'module', null,
+ $contextinfo->contextinfo->coursename, $quiz->name, '');
+ $output = $this->call_export_repo($rootdirectory, $moodleinstance, $token,
+ $quizmanifestname, $ignorecat, $scriptdirectory);
+ }
+ echo $output;
+ $quizmanifestpath = cli_helper::get_manifest_path($moodleinstance, 'module', null,
+ $contextinfo->contextinfo->coursename, $quiz->name, $rootdirectory);
+ $output = $this->call_export_quiz($moodleinstance, $token, $quizmanifestpath, $this->manifestpath, $scriptdirectory);
+ echo $output;
+ }
+ }
+
+ /**
+ * Separate out exec call for mocking.
+ *
+ * @param string $rootdirectory
+ * @param string $moodleinstance
+ * @param string $instanceid
+ * @param string $token
+ * @param string $ignorecat
+ * @return string|null
+ */
+ public function call_repo_creation(string $rootdirectory, string $moodleinstance, string $instanceid,
+ string $token, string $ignorecat, string $scriptdirectory
+ ): ?string {
+ chdir($scriptdirectory);
+ return shell_exec('php createrepo.php -u ' . $this->usegit . ' -w -r "' . $rootdirectory . '" -i "' . $moodleinstance .
+ '" -l "module" -n ' . $instanceid . ' -t ' . $token . $ignorecat);
+ }
+
+ /**
+ * Separate out exec call for mocking.
+ *
+ * @param string $rootdirectory
+ * @param string $moodleinstance
+ * @param string $token
+ * @param string $quizmanifestname
+ * @param string $ignorecat
+ * @param string $scriptdirectory
+ * @return string|null
+ */
+ public function call_export_repo(string $rootdirectory, string $moodleinstance, string $token,
+ string $quizmanifestname, string $ignorecat, string $scriptdirectory): ?string {
+ chdir($scriptdirectory);
+ return shell_exec('php exportrepofrommoodle.php -u ' . $this->usegit . ' -w -r "' . $rootdirectory . '" -i "' .
+ $moodleinstance . '" -f "' . $quizmanifestname . '" -t ' . $token . $ignorecat);
+ }
}
diff --git a/classes/export_trait.php b/classes/export_trait.php
index afee379..9295bdf 100644
--- a/classes/export_trait.php
+++ b/classes/export_trait.php
@@ -74,7 +74,11 @@ public function export_to_repo() {
* @param object $moodlequestionlist
* @return void
*/
- public function export_to_repo_main_process(object $moodlequestionlist):void {
+ public function export_to_repo_main_process(object $moodlequestionlist): void {
+ // Make top folder in case we don't have any questions.
+ if (!is_dir(dirname($this->manifestpath) . '/top')) {
+ mkdir(dirname($this->manifestpath) . '/top');
+ }
$this->subdirectory = 'top';
$questionsinmoodle = $moodlequestionlist->questions;
$this->postsettings['includecategory'] = 1;
@@ -202,14 +206,8 @@ public function export_to_repo_main_process(object $moodlequestionlist):void {
$fileoutput = [
'questionbankentryid' => $questioninfo->questionbankentryid,
'version' => $responsejson->version,
- 'contextlevel' => $this->listpostsettings['contextlevel'],
'filepath' => str_replace( '\\', '/', $bottomdirectory) . "/{$sanitisedqname}.xml",
- 'coursename' => $this->listpostsettings['coursename'],
- 'modulename' => $this->listpostsettings['modulename'],
- 'coursecategory' => $this->listpostsettings['coursecategory'],
- 'instanceid' => $this->listpostsettings['instanceid'],
'format' => 'xml',
- 'ignorecat' => $this->ignorecat,
];
fwrite($tempfile, json_encode($fileoutput) . "\n");
}
@@ -217,18 +215,44 @@ public function export_to_repo_main_process(object $moodlequestionlist):void {
}
/**
- * Prompt user whether they want to continue.
- *
+ * Export quiz structure
+ * @param mixed $clihelper
+ * @param mixed $scriptdirectory
* @return void
*/
- public function handle_abort():void {
- echo "Abort? y/n\n";
- $handle = fopen ("php://stdin", "r");
- $line = fgets($handle);
- if (trim($line) === 'y') {
- $this->call_exit();
+ public function export_quiz_structure($clihelper, $scriptdirectory) {
+ $arguments = $clihelper->get_arguments();
+ $moodleinstance = $arguments['moodleinstance'];
+ if (is_array($arguments['token'])) {
+ $token = $arguments['token'][$moodleinstance];
+ } else {
+ $token = $arguments['token'];
}
- fclose($handle);
+ $quizmanifestpath = cli_helper::get_manifest_path($moodleinstance, 'module', null,
+ $this->manifestcontents->context->coursename,
+ $this->manifestcontents->context->modulename, dirname($this->manifestpath));
+ $output = $this->call_export_quiz($moodleinstance, $token, $quizmanifestpath,
+ $this->nonquizmanifestpath, $scriptdirectory);
+ echo $output;
+ }
+
+ /**
+ * Separate out exec call for mocking.
+ *
+ * @param string $moodleinstance
+ * @param string $token
+ * @param string $quizmanifestpath
+ * @param string|null $nonquizmanifestpath
+ * @param string $scriptdirectory
+ * @return string|null
+ */
+ public function call_export_quiz(string $moodleinstance, string $token, string $quizmanifestpath,
+ ?string $nonquizmanifestpath, string $scriptdirectory): ?string {
+ chdir($scriptdirectory);
+ $nonquiz = ($nonquizmanifestpath) ? ' -p "' . $nonquizmanifestpath . '"' : '';
+ return shell_exec('php exportquizstructurefrommoodle.php -u ' . $this->usegit .
+ ' -w -r "" -i "' . $moodleinstance . '" -t "'
+ . $token. '" -f "' . $quizmanifestpath . '"' . $nonquiz);
}
/**
@@ -238,7 +262,7 @@ public function handle_abort():void {
*
* @return void
*/
- public function call_exit():void {
+ public function call_exit(): void {
exit;
}
}
diff --git a/classes/external/delete_question.php b/classes/external/delete_question.php
index ce8f8b3..7d088a7 100644
--- a/classes/external/delete_question.php
+++ b/classes/external/delete_question.php
@@ -68,7 +68,7 @@ public static function execute_returns(): external_single_structure {
* @param string $questionbankentryid questionbankentry id
* @return object \stdClass success: true or exception
*/
- public static function execute(string $questionbankentryid):object {
+ public static function execute(string $questionbankentryid): object {
$params = self::validate_parameters(self::execute_parameters(), [
'questionbankentryid' => $questionbankentryid,
]);
diff --git a/classes/external/export_question.php b/classes/external/export_question.php
index 928fdf2..4bd61a9 100644
--- a/classes/external/export_question.php
+++ b/classes/external/export_question.php
@@ -76,7 +76,7 @@ public static function execute_returns(): external_single_structure {
* @param bool $includecategory Include a 'category' question
* @return object \stdClass question details
*/
- public static function execute(string $questionbankentryid, bool $includecategory):object {
+ public static function execute(string $questionbankentryid, bool $includecategory): object {
global $DB, $SITE;
$params = self::validate_parameters(self::execute_parameters(), [
'questionbankentryid' => $questionbankentryid,
@@ -98,8 +98,8 @@ public static function execute(string $questionbankentryid, bool $includecategor
case \CONTEXT_MODULE:
$course = $DB->get_record_sql("
SELECT c.*
- FROM mdl_course_modules cm
- JOIN mdl_course c ON c.id = cm.course
+ FROM {course_modules} cm
+ JOIN {course} c ON c.id = cm.course
WHERE cm.id = :moduleid",
['moduleid' => $questiondata->instanceid],
MUST_EXIST);
diff --git a/classes/external/export_quiz_data.php b/classes/external/export_quiz_data.php
new file mode 100644
index 0000000..8e0641a
--- /dev/null
+++ b/classes/external/export_quiz_data.php
@@ -0,0 +1,146 @@
+.
+
+/**
+ * Export details of a quiz content and structure.
+ *
+ * @package qbank_gitsync
+ * @copyright 2024 The University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace qbank_gitsync\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->dirroot . '/lib/externallib.php');
+require_once($CFG->libdir . '/questionlib.php');
+require_once($CFG->dirroot. '/question/bank/gitsync/lib.php');
+
+use core_question\local\bank\question_version_status;
+use external_api;
+use external_function_parameters;
+use external_multiple_structure;
+use external_single_structure;
+use external_value;
+
+/**
+ * A webservice function to export details of a quiz content and structure.
+ */
+class export_quiz_data extends external_api {
+ /**
+ * Returns description of webservice function parameters
+ * @return external_function_parameters
+ */
+ public static function execute_parameters() {
+ return new external_function_parameters([
+ 'moduleid' => new external_value(PARAM_SEQUENCE, 'Course module id'),
+ 'coursename' => new external_value(PARAM_TEXT, 'Course name'),
+ 'quizname' => new external_value(PARAM_TEXT, 'Quiz name'),
+ ]);
+ }
+
+ /**
+ * Returns description of webservice function output.
+ * @return external_multiple_structure
+ */
+ public static function execute_returns(): external_single_structure {
+ return new external_single_structure([
+ 'quiz' => new external_single_structure([
+ 'name' => new external_value(PARAM_TEXT, 'context level description'),
+ 'intro' => new external_value(PARAM_RAW, 'course category name (course category context)'),
+ 'introformat' => new external_value(PARAM_SEQUENCE, 'id of course category, course or module'),
+ 'questionsperpage' => new external_value(PARAM_SEQUENCE, 'default questions per page'),
+ 'grade' => new external_value(PARAM_TEXT, 'maximum grade'),
+ 'navmethod' => new external_value(PARAM_TEXT, 'navigation method'),
+ ]),
+ 'sections' => new external_multiple_structure(
+ new external_single_structure([
+ 'firstslot' => new external_value(PARAM_SEQUENCE, 'first slot of section'),
+ 'heading' => new external_value(PARAM_TEXT, 'heading'),
+ 'shufflequestions' => new external_value(PARAM_INT, 'shuffle questions?'),
+ ])
+ ),
+ 'questions' => new external_multiple_structure(
+ new external_single_structure([
+ 'questionbankentryid' => new external_value(PARAM_SEQUENCE, 'questionbankentry id'),
+ 'slot' => new external_value(PARAM_SEQUENCE, 'slot number'),
+ 'page' => new external_value(PARAM_SEQUENCE, 'page number'),
+ 'requireprevious' => new external_value(PARAM_INT, 'Require completion of previous question?'),
+ 'maxmark' => new external_value(PARAM_TEXT, 'maximum mark'),
+ ])
+ ),
+ 'feedback' => new external_multiple_structure(
+ new external_single_structure([
+ 'feedbacktext' => new external_value(PARAM_TEXT, 'Feedback text'),
+ 'feedbacktextformat' => new external_value(PARAM_SEQUENCE, 'Format of feedback'),
+ 'mingrade' => new external_value(PARAM_TEXT, 'minimum mark'),
+ 'maxgrade' => new external_value(PARAM_TEXT, 'maximum mark'),
+ ])
+ ),
+ ]);
+ }
+
+ /**
+ * Export details of a quiz content and structure.
+ *
+ * @param string|null $moduleid course module id of the quiz to export
+ * @param string|null $coursename Name of the course
+ * @param string|null $quizname Name of the quiz
+ * @return object containing quiz data
+ */
+ public static function execute(?string $moduleid = null, ?string $coursename = null, ?string $quizname = null): object {
+ global $CFG, $DB;
+ $params = self::validate_parameters(self::execute_parameters(), [
+ 'moduleid' => $moduleid,
+ 'coursename' => $coursename,
+ 'quizname' => $quizname,
+ ]);
+ $contextinfo = get_context(\CONTEXT_MODULE, null, $params['coursename'], $params['quizname'], $params['moduleid']);
+
+ $thiscontext = $contextinfo->context;
+
+ // The webservice user needs to have access to the context. They could be given Manager
+ // role at site level to access everything or access could be restricted to certain courses.
+ self::validate_context($thiscontext);
+ require_capability('qbank/gitsync:listquestions', $thiscontext);
+
+ $response = new \stdClass();
+ $response->quiz = new \stdClass();
+ $response->sections = [];
+ $response->questions = [];
+ $quizid = (int) $contextinfo->quizid;
+
+ $quiz = $DB->get_record('quiz', ['id' => $quizid], 'intro, introformat, questionsperpage, grade, navmethod');
+ $quiz->name = $contextinfo->modulename;
+ $response->quiz = $quiz;
+
+ $response->sections = $DB->get_records('quiz_sections', ['quizid' => $quizid], null,
+ 'firstslot, heading, shufflequestions');
+
+ $response->questions = $DB->get_records_sql("
+ SELECT qr.questionbankentryid, qs.slot, qs.page, qs.requireprevious, qs.maxmark
+ FROM {quiz_slots} qs
+ JOIN {question_references} qr ON qr.itemid = qs.id
+ WHERE qr.usingcontextid = :contextid
+ AND qr.questionarea = 'slot'",
+ ['contextid' => $contextinfo->context->id]);
+
+ $response->feedback = $DB->get_records('quiz_feedback', ['quizid' => $quizid], null,
+ 'feedbacktext, feedbacktextformat, mingrade, maxgrade');
+ return $response;
+ }
+}
diff --git a/classes/external/get_question_list.php b/classes/external/get_question_list.php
index fa0a40f..df6f924 100644
--- a/classes/external/get_question_list.php
+++ b/classes/external/get_question_list.php
@@ -66,12 +66,13 @@ public static function execute_parameters() {
* Returns description of webservice function output.
* @return external_multiple_structure
*/
- public static function execute_returns():external_single_structure {
+ public static function execute_returns(): external_single_structure {
return new external_single_structure([
'contextinfo' => new external_single_structure([
'contextlevel' => new external_value(PARAM_TEXT, 'context level description'),
'categoryname' => new external_value(PARAM_TEXT, 'course category name (course category context)'),
'coursename' => new external_value(PARAM_TEXT, 'course name (course or module context)'),
+ 'courseid' => new external_value(PARAM_SEQUENCE, 'course id (course or module context)'),
'modulename' => new external_value(PARAM_TEXT, 'module name (module context)'),
'instanceid' => new external_value(PARAM_SEQUENCE, 'id of course category, course or module'),
'qcategoryname' => new external_value(PARAM_TEXT, 'name of question category'),
@@ -86,6 +87,12 @@ public static function execute_returns():external_single_structure {
'version' => new external_value(PARAM_SEQUENCE, 'version'),
])
),
+ 'quizzes' => new external_multiple_structure(
+ new external_single_structure([
+ 'instanceid' => new external_value(PARAM_SEQUENCE, 'course module id of quiz'),
+ 'name' => new external_value(PARAM_TEXT, 'name of quiz'),
+ ])
+ ),
]);
}
@@ -109,7 +116,7 @@ public static function execute(?string $qcategoryname,
int $contextlevel, ?string $coursename = null, ?string $modulename = null,
?string $coursecategory = null, ?string $qcategoryid = null,
?string $instanceid = null, bool $contextonly = false,
- ?array $qbankentryids = [''], ?string $ignorecat = null):object {
+ ?array $qbankentryids = [''], ?string $ignorecat = null): object {
global $CFG, $DB;
$params = self::validate_parameters(self::execute_parameters(), [
'qcategoryname' => $qcategoryname,
@@ -139,6 +146,7 @@ public static function execute(?string $qcategoryname,
$response->contextinfo = $contextinfo;
unset($response->contextinfo->context);
$response->questions = [];
+ $response->quizzes = [];
$response->contextinfo->qcategoryname = '';
$response->contextinfo->qcategoryid = null;
$response->contextinfo->ignorecat = $ignorecat;
@@ -177,6 +185,19 @@ public static function execute(?string $qcategoryname,
$response->contextinfo->qcategoryname = self::get_category_path($category);
$response->contextinfo->qcategoryid = $category->id;
+
+ if ((int) $params['contextlevel'] === \CONTEXT_COURSE) {
+ $response->quizzes = $DB->get_records_sql(
+ "SELECT cm.id as instanceid, q.name
+ FROM {course_modules} cm
+ INNER JOIN {quiz} q ON q.id = cm.instance
+ INNER JOIN {modules} m ON m.id = cm.module
+ WHERE cm.course = :courseid
+ AND m.name = 'quiz'
+ AND cm.deletioninprogress = 0",
+ ['courseid' => (int) $contextinfo->instanceid]);
+ }
+
if ($contextonly) {
return $response;
}
@@ -215,6 +236,7 @@ public static function execute(?string $qcategoryname,
array_push($response->questions, $qinfo);
}
}
+
return $response;
}
@@ -225,7 +247,7 @@ public static function execute(?string $qcategoryname,
* @param string|null $ignorecat Regex of categories to ignore (along with their descendants)
* @return array of question categories
*/
- public static function get_category_descendants(int $parentid, ?string $ignorecat):array {
+ public static function get_category_descendants(int $parentid, ?string $ignorecat): array {
global $DB;
$children = $DB->get_records('question_categories', ['parent' => $parentid], null, 'id, parent, name');
if ($ignorecat) {
diff --git a/classes/external/import_question.php b/classes/external/import_question.php
index 4a13fb5..6d2f718 100644
--- a/classes/external/import_question.php
+++ b/classes/external/import_question.php
@@ -105,7 +105,7 @@ public static function execute(?string $questionbankentryid, ?string $importedve
?string $qcategoryname, array $fileinfo,
int $contextlevel, ?string $coursename = null, ?string $modulename = null,
?string $coursecategory = null, ?string $qcategoryid = null,
- ?string $instanceid = null):object {
+ ?string $instanceid = null): object {
global $CFG, $DB, $USER;
$params = self::validate_parameters(self::execute_parameters(), [
'questionbankentryid' => $questionbankentryid,
diff --git a/classes/external/import_quiz_data.php b/classes/external/import_quiz_data.php
new file mode 100644
index 0000000..8d883fb
--- /dev/null
+++ b/classes/external/import_quiz_data.php
@@ -0,0 +1,253 @@
+.
+
+/**
+ * Import details of a quiz content and structure.
+ *
+ * @package qbank_gitsync
+ * @copyright 2024 The University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace qbank_gitsync\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->dirroot .'/course/lib.php');
+require_once($CFG->dirroot . '/question/editlib.php');
+require_once($CFG->dirroot . '/lib/externallib.php');
+require_once($CFG->libdir . '/questionlib.php');
+require_once($CFG->dirroot. '/question/bank/gitsync/lib.php');
+require_once($CFG->dirroot . '/mod/quiz/locallib.php');
+require_once($CFG->dirroot . '/course/modlib.php');
+
+use external_api;
+use external_function_parameters;
+use external_multiple_structure;
+use external_single_structure;
+use external_value;
+use mod_quiz\grade_calculator;
+use mod_quiz\quiz_settings;
+
+/**
+ * A webservice function to export details of a quiz content and structure.
+ */
+class import_quiz_data extends external_api {
+ /**
+ * Returns description of webservice function parameters
+ * @return external_single_structure
+ */
+ public static function execute_parameters() {
+ return new external_function_parameters([
+ 'quiz' => new external_single_structure([
+ 'name' => new external_value(PARAM_TEXT, 'context level description'),
+ 'intro' => new external_value(PARAM_RAW, 'course category name (course category context)'),
+ 'introformat' => new external_value(PARAM_SEQUENCE, 'id of course category, course or module'),
+ 'coursename' => new external_value(PARAM_TEXT, 'course to import quiz into'),
+ 'courseid' => new external_value(PARAM_SEQUENCE, 'course to import quiz into'),
+ 'questionsperpage' => new external_value(PARAM_SEQUENCE, 'default questions per page'),
+ 'grade' => new external_value(PARAM_TEXT, 'maximum grade'),
+ 'navmethod' => new external_value(PARAM_TEXT, 'navigation method'),
+ 'cmid' => new external_value(PARAM_TEXT, 'id of quiz if it already exists'),
+ ]),
+ 'sections' => new external_multiple_structure(
+ new external_single_structure([
+ 'firstslot' => new external_value(PARAM_SEQUENCE, 'first slot of section'),
+ 'heading' => new external_value(PARAM_TEXT, 'heading'),
+ 'shufflequestions' => new external_value(PARAM_INT, 'shuffle questions?'),
+ ])
+ ),
+ 'questions' => new external_multiple_structure(
+ new external_single_structure([
+ 'questionbankentryid' => new external_value(PARAM_SEQUENCE, 'questionbankentry id'),
+ 'slot' => new external_value(PARAM_SEQUENCE, 'slot number'),
+ 'page' => new external_value(PARAM_SEQUENCE, 'page number'),
+ 'requireprevious' => new external_value(PARAM_INT, 'Require completion of previous question?'),
+ 'maxmark' => new external_value(PARAM_TEXT, 'maximum mark'),
+ ]), '', VALUE_DEFAULT, []
+ ),
+ 'feedback' => new external_multiple_structure(
+ new external_single_structure([
+ 'feedbacktext' => new external_value(PARAM_TEXT, 'Feedback text', VALUE_OPTIONAL),
+ 'feedbacktextformat' => new external_value(PARAM_SEQUENCE, 'Format of feedback', VALUE_OPTIONAL),
+ 'mingrade' => new external_value(PARAM_TEXT, 'minimum mark', VALUE_OPTIONAL),
+ 'maxgrade' => new external_value(PARAM_TEXT, 'maximum mark', VALUE_OPTIONAL),
+ ]), '', VALUE_DEFAULT, []
+ ),
+ ]);
+ }
+
+ /**
+ * Returns description of webservice function output.
+ * @return external_single_structure
+ */
+ public static function execute_returns(): external_single_structure {
+ return new external_single_structure([
+ 'success' => new external_value(PARAM_BOOL, 'Import success?'),
+ 'cmid' => new external_value(PARAM_SEQUENCE, 'CMID of quiz'),
+ ]);
+ }
+
+ /**
+ * Import details of a quiz content and structure.
+ *
+ * @param array $quiz
+ * @param array $sections
+ * @param array $questions
+ * @param array|null $feedback
+ * @return object containing outcome
+ */
+ public static function execute(array $quiz, array $sections, array $questions, ?array $feedback = []): object {
+ global $CFG, $DB;
+ $params = self::validate_parameters(self::execute_parameters(), [
+ 'quiz' => $quiz,
+ 'sections' => $sections,
+ 'questions' => $questions,
+ 'feedback' => $feedback,
+ ]);
+ $contextinfo = get_context(\CONTEXT_COURSE, null, $params['quiz']['coursename'], null, $params['quiz']['courseid']);
+
+ $thiscontext = $contextinfo->context;
+
+ // The webservice user needs to have access to the context. They could be given Manager
+ // role at site level to access everything or access could be restricted to certain courses.
+ self::validate_context($thiscontext);
+ require_capability('qbank/gitsync:importquestions', $thiscontext);
+
+ $moduleinfo = new \stdClass();
+ $moduleinfo->name = $params['quiz']['name'];
+ $moduleinfo->modulename = 'quiz';
+ $moduleinfo->module = $DB->get_field('modules', 'id', ['name' => 'quiz']);
+ $moduleinfo->course = $contextinfo->instanceid;
+ $moduleinfo->section = 1;
+ $moduleinfo->quizpassword = '';
+ $moduleinfo->visible = true;
+ $moduleinfo->introeditor = [
+ 'text' => $params['quiz']['intro'],
+ 'format' => (int) $params['quiz']['introformat'],
+ 'itemid' => 0,
+ ];
+ $moduleinfo->preferredbehaviour = 'deferredfeedback';
+ $moduleinfo->grade = $params['quiz']['grade'];
+ $moduleinfo->questionsperpage = (int) $params['quiz']['questionsperpage'];
+ $moduleinfo->shuffleanswers = true;
+ $moduleinfo->navmethod = $params['quiz']['navmethod'];
+ $moduleinfo->timeopen = 0;
+ $moduleinfo->timeclose = 0;
+ $moduleinfo->decimalpoints = 2;
+ $moduleinfo->questiondecimalpoints = -1;
+ $moduleinfo->grademethod = 1;
+ $moduleinfo->graceperiod = 0;
+ $moduleinfo->timelimit = 0;
+ if ($params['quiz']['cmid']) {
+ $moduleinfo->coursemodule = (int) $params['quiz']['cmid'];
+ $moduleinfo->cmidnumber = $moduleinfo->coursemodule;
+ $module = get_coursemodule_from_id('', $moduleinfo->coursemodule, 0, false, \MUST_EXIST);
+ list($module, $moduleinfo) = \update_moduleinfo($module, $moduleinfo, \get_course($contextinfo->instanceid));
+ $module = get_module_from_cmid($moduleinfo->coursemodule)[0];
+ } else {
+ $moduleinfo->cmidnumber = '';
+ $moduleinfo = \add_moduleinfo($moduleinfo, \get_course($contextinfo->instanceid));
+
+ $module = get_module_from_cmid($moduleinfo->coursemodule)[0];
+ }
+
+ // Post-creation updates.
+ $reviewchoice = [];
+ $reviewchoice['reviewattempt'] = 69888;
+ $reviewchoice['reviewcorrectness'] = 4352;
+ $reviewchoice['reviewmarks'] = 4352;
+ $reviewchoice['reviewspecificfeedback'] = 4352;
+ $reviewchoice['reviewgeneralfeedback'] = 4352;
+ $reviewchoice['reviewrightanswer'] = 4352;
+ $reviewchoice['reviewoverallfeedback'] = 4352;
+ $reviewchoice['id'] = $moduleinfo->instance;
+ $DB->update_record('quiz', $reviewchoice);
+
+ // Sort questions by slot.
+ usort($params['questions'], function($a, $b) {
+ if ((int) $a['slot'] > (int) $b['slot']) {
+ return 1;
+ } else if ((int) $a['slot'] < (int) $b['slot']) {
+ return -1;
+ } else {
+ return 0;
+ }
+ });
+ if ($params['quiz']['cmid']) {
+ // We can only add questions if the quiz already exists.
+ foreach ($params['questions'] as $question) {
+ $qdata = get_minimal_question_data($question['questionbankentryid']);
+ // Double-check user has question access.
+ quiz_require_question_use($qdata->questionid);
+ quiz_add_quiz_question($qdata->questionid, $module, (int) $question['page'], (float) $question['maxmark']);
+ if ($question['requireprevious']) {
+ $quizcontext = get_context(\CONTEXT_MODULE, null, null, null, $moduleinfo->coursemodule);
+ $itemid = $DB->get_field('question_references', 'itemid',
+ ['usingcontextid' => $quizcontext->context->id, 'questionbankentryid' => $question['questionbankentryid']]);
+ $DB->set_field('quiz_slots', 'requireprevious', 1, ['id' => $itemid]);
+ }
+ }
+ if (class_exists('mod_quiz\grade_calculator')) {
+ quiz_settings::create($moduleinfo->instance)->get_grade_calculator()->recompute_quiz_sumgrades();
+ } else {
+ quiz_update_sumgrades($module);
+ }
+ // NB Must add questions before updating sections.
+ foreach ($params['sections'] as $section) {
+ $section['quizid'] = $moduleinfo->instance;
+ $section['firstslot'] = (int) $section['firstslot'];
+ // First slot will have been automatically created so we need to overwrite.
+ if ($section['firstslot'] == 1) {
+ $sectionid = $DB->get_field('quiz_sections', 'id',
+ ['quizid' => $moduleinfo->instance, 'firstslot' => 1]);
+ $section['id'] = $sectionid;
+ $DB->update_record('quiz_sections', $section);
+ } else {
+ $sectionid = $DB->insert_record('quiz_sections', $section);
+ }
+ $slotid = $DB->get_field('quiz_slots', 'id',
+ ['quizid' => $moduleinfo->instance, 'slot' => (int) $section['firstslot']]);
+
+ // Log section break created event.
+ $event = \mod_quiz\event\section_break_created::create([
+ 'context' => $thiscontext,
+ 'objectid' => $sectionid,
+ 'other' => [
+ 'quizid' => $section['quizid'],
+ 'firstslotnumber' => $section['firstslot'],
+ 'firstslotid' => $slotid,
+ 'title' => $section['heading'],
+ ],
+ ]);
+ $event->trigger();
+ }
+ } else {
+ $quizcontext = get_context(\CONTEXT_MODULE, null, null, null, $moduleinfo->coursemodule);
+ \question_make_default_categories([$quizcontext->context]);
+ }
+
+ foreach ($params['feedback'] as $feedback) {
+ $feedback['quizid'] = $moduleinfo->instance;
+ $DB->insert_record('quiz_feedback', $feedback);
+ }
+
+ $response = new \stdClass();
+ $response->success = true;
+ $response->cmid = $module->cmid;
+ return $response;
+ }
+}
diff --git a/classes/import_quiz.php b/classes/import_quiz.php
new file mode 100644
index 0000000..b1a0436
--- /dev/null
+++ b/classes/import_quiz.php
@@ -0,0 +1,498 @@
+.
+
+/**
+ * Wrapper class for processing performed by command line interface for importing quiz data to Moodle.
+ *
+ * Utilised in cli\importquizstructuretomoodle.php
+ *
+ * Allows mocking and unit testing via PHPUnit.
+ * Used outside Moodle.
+ *
+ * @package qbank_gitsync
+ * @copyright 2024 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace qbank_gitsync;
+
+/**
+ * Import structure data of a Moodle quiz.
+ */
+class import_quiz {
+ /**
+ * CLI helper for this import
+ *
+ * @var cli_helper
+ */
+ public cli_helper $clihelper;
+ /**
+ * Settings for POST request
+ *
+ * These are the parameters for the webservice call.
+ *
+ * @var array
+ */
+ public array $postsettings;
+ /**
+ * cURL request handle for file upload
+ *
+ * @var curl_request
+ */
+ public curl_request $curlrequest;
+ /**
+ * cURL request handle for question list retrieve
+ *
+ * @var curl_request
+ */
+ public curl_request $listcurlrequest;
+ /**
+ * Settings for question list request
+ *
+ * These are the parameters for the webservice list call.
+ *
+ * @var array
+ */
+ public array $listpostsettings;
+ /**
+ * Full path to manifest file
+ *
+ * @var string|null
+ */
+ public ?string $quizmanifestpath = null;
+ /**
+ * Parsed content of JSON manifest file
+ *
+ * @var \stdClass|null
+ */
+ public ?\stdClass $quizmanifestcontents = null;
+ /**
+ * Full path to manifest file
+ *
+ * @var string|null
+ */
+ public ?string $nonquizmanifestpath = null;
+ /**
+ * Parsed content of JSON manifest file
+ *
+ * @var \stdClass|null
+ */
+ public ?\stdClass $nonquizmanifestcontents;
+ /**
+ * Module id of quiz
+ *
+ * @var string|null
+ */
+ public string $cmid;
+ /**
+ * URL of Moodle instance
+ *
+ * @var string
+ */
+ public string $moodleurl;
+ /**
+ * Full path to data file
+ *
+ * @var string|null
+ */
+ public ?string $quizdatapath = null;
+ /**
+ * Parsed content of JSON data file
+ *
+ * @var \stdClass|null
+ */
+ public ?\stdClass $quizdatacontents;
+ /**
+ * Are we using git?.
+ * Set in config. Adds commit hash to manifest.
+ * @var bool
+ */
+ public bool $usegit;
+
+ /**
+ * Constructor
+ *
+ * @param cli_helper $clihelper
+ * @param array $moodleinstances pairs of names and URLs
+ */
+ public function __construct(cli_helper $clihelper, array $moodleinstances) {
+ // Convert command line options into variables.
+ $this->clihelper = $clihelper;
+ $arguments = $clihelper->get_arguments();
+ if (isset($arguments['directory'])) {
+ $directory = ($arguments['rootdirectory']) ? $arguments['rootdirectory'] . '/' . $arguments['directory'] :
+ $arguments['directory'];
+ } else {
+ $directory = $arguments['rootdirectory'];
+ }
+ $directoryprefix = ($directory) ? $directory . '/' : '';
+ $coursename = $arguments['coursename'];
+ $moodleinstance = $arguments['moodleinstance'];
+ $this->moodleurl = $moodleinstances[$moodleinstance];
+ $instanceid = $arguments['instanceid'];
+ $contextlevel = 50;
+ $this->usegit = $arguments['usegit'];
+ if (!empty($arguments['quizmanifestpath'])) {
+ $this->quizmanifestpath = ($arguments['quizmanifestpath']) ?
+ $directoryprefix . $arguments['quizmanifestpath'] : null;
+ $this->quizmanifestcontents = json_decode(file_get_contents($this->quizmanifestpath));
+ if (!$this->quizmanifestcontents) {
+ echo "\nUnable to access or parse manifest file: {$this->quizmanifestpath}\nAborting.\n";
+ $this->call_exit();
+ } else {
+ if ($this->quizmanifestcontents->context->moodleurl !== $this->moodleurl) {
+ echo "\nManifest file is for the wrong Moodle instance: {$this->quizmanifestpath}\nAborting.\n";
+ $this->call_exit();
+ }
+ $this->cmid = $this->quizmanifestcontents->context->instanceid;
+ $this->quizdatapath = ($arguments['quizdatapath']) ? $directoryprefix . $arguments['quizdatapath']
+ : cli_helper::get_quiz_structure_path($this->quizmanifestcontents->context->modulename,
+ dirname($this->quizmanifestpath));
+ $instanceid = $this->cmid;
+ $coursename = '';
+ $contextlevel = 70;
+ }
+ } else {
+ if ($arguments['quizdatapath']) {
+ $this->quizdatapath = $directoryprefix . $arguments['quizdatapath'];
+ } else {
+ if (empty($arguments['createquiz'])) {
+ echo "\nPlease supply a quiz manifest filepath or a quiz data filepath.\nAborting.\n";
+ $this->call_exit();
+ return; // Required for unit tests.
+ } else {
+ $quizfiles = scandir($directory);
+ $structurefile = null;
+ // Find the structure file.
+ foreach ($quizfiles as $quizfile) {
+ if (preg_match('/.*_quiz\.json/', $quizfile)) {
+ $structurefile = $quizfile;
+ break;
+ }
+ }
+ if (!$structurefile) {
+ echo "\nNo quiz structure file found.\nAborting.\n";
+ $this->call_exit();
+ return; // Required for unit tests.
+ }
+ $this->quizdatapath = $directoryprefix . $structurefile;
+ }
+ }
+ }
+ if (!empty($arguments['nonquizmanifestpath'])) {
+ $this->nonquizmanifestpath = ($arguments['rootdirectory']) ?
+ $arguments['rootdirectory'] . '/' . $arguments['nonquizmanifestpath'] : $arguments['nonquizmanifestpath'];
+ $this->nonquizmanifestcontents = json_decode(file_get_contents($this->nonquizmanifestpath));
+ if (!$this->nonquizmanifestcontents) {
+ echo "\nUnable to access or parse manifest file: {$this->nonquizmanifestpath}\nAborting.\n";
+ $this->call_exit();
+ }
+ if ($this->nonquizmanifestcontents->context->moodleurl !== $this->moodleurl) {
+ echo "\nManifest file is for the wrong Moodle instance: {$this->nonquizmanifestpath}\nAborting.\n";
+ $this->call_exit();
+ }
+ if (!$instanceid && $this->nonquizmanifestcontents->context->contextlevel === cli_helper::get_context_level('course')) {
+ $instanceid = $this->nonquizmanifestcontents->context->instanceid;
+ }
+ }
+ if (!$instanceid && !$arguments['coursename'] && !$this->quizmanifestcontents) {
+ echo "\nYou must identify the course you wish to add the quiz to. Use a course manifest path (--nonquizmanifestpath)" .
+ " or specify the course id (--instanceid) or course name (--coursename).\nAborting.\n";
+ $this->call_exit();
+ return; // Required for unit tests.
+ }
+ $this->quizdatacontents = json_decode(file_get_contents($this->quizdatapath));
+ if (!$this->quizdatacontents) {
+ echo "\nUnable to access or parse data file: {$this->quizdatapath}\nAborting.\n";
+ $this->call_exit();
+ }
+ if (is_array($arguments['token'])) {
+ $token = $arguments['token'][$moodleinstance];
+ } else {
+ $token = $arguments['token'];
+ }
+
+ $wsurl = $this->moodleurl . '/webservice/rest/server.php';
+
+ $this->listcurlrequest = $this->get_curl_request($wsurl);
+ $this->listpostsettings = [
+ 'wstoken' => $token,
+ 'wsfunction' => 'qbank_gitsync_get_question_list',
+ 'moodlewsrestformat' => 'json',
+ 'contextlevel' => $contextlevel,
+ 'coursename' => $coursename,
+ 'modulename' => null,
+ 'coursecategory' => null,
+ 'qcategoryname' => 'top',
+ 'qcategoryid' => null,
+ 'instanceid' => $instanceid,
+ 'contextonly' => 1,
+ 'qbankentryids[]' => null,
+ 'ignorecat' => null,
+ ];
+ $this->listcurlrequest->set_option(CURLOPT_RETURNTRANSFER, true);
+ $this->listcurlrequest->set_option(CURLOPT_POST, 1);
+
+ $this->curlrequest = $this->get_curl_request($wsurl);
+ $this->postsettings = [
+ 'wstoken' => $token,
+ 'wsfunction' => 'qbank_gitsync_import_quiz_data',
+ 'moodlewsrestformat' => 'json',
+ ];
+ $this->curlrequest->set_option(CURLOPT_RETURNTRANSFER, true);
+ $this->curlrequest->set_option(CURLOPT_POST, 1);
+ $instanceinfo = $this->clihelper->check_context($this, false, true);
+ if ($arguments['subcall']) {
+ echo "\nCreating quiz: {$this->quizdatacontents->quiz->name}\n";
+ } else {
+ echo "\nPreparing to create/update a quiz in Moodle.\n";
+ echo "Moodle URL: {$this->moodleurl}\n";
+ echo "Course: {$instanceinfo->contextinfo->coursename}\n";
+ echo "Quiz: {$this->quizdatacontents->quiz->name}\n";
+ $this->handle_abort();
+ }
+ $this->postsettings['quiz[coursename]'] = $instanceinfo->contextinfo->coursename;
+ $this->postsettings['quiz[courseid]'] = $instanceinfo->contextinfo->courseid;
+
+ if (!$this->quizmanifestpath) {
+ $this->quizmanifestpath = cli_helper::get_manifest_path($moodleinstance, 'module', null,
+ $instanceinfo->contextinfo->coursename, $this->quizdatacontents->quiz->name, $directory);
+ if (!is_file($this->quizmanifestpath)) {
+ $this->quizmanifestcontents = null;
+ $this->cmid = '';
+ } else {
+ $this->quizmanifestcontents = json_decode(file_get_contents($this->quizmanifestpath));
+ if (!$this->quizmanifestcontents) {
+ echo "\nUnable to access or parse manifest file: {$this->quizmanifestpath}\nAborting.\n";
+ $this->call_exit();
+ } else {
+ $this->cmid = $this->quizmanifestcontents->context->instanceid;
+ }
+ }
+ }
+ }
+
+ /**
+ * Get quiz data from file, convert question file locations to ids
+ * and then import to Moodle.
+ *
+ * @return void
+ */
+ public function process(): void {
+ $this->import_quiz_data();
+ }
+
+ /**
+ * Import quiz including structure.
+ * Creates quiz, imports questions, updates structure.
+ * @param mixed $clihelper
+ * @param mixed $scriptdirectory
+ * @return void
+ */
+ public function import_all($clihelper, $scriptdirectory): void {
+ if ($this->quizmanifestcontents) {
+ echo "\nA question manifest already exists for this quiz in this Moodle instance.\n";
+ echo "Use importrepotomoodle.php to update questions.\n";
+ echo "Use importquizstructuretomoodle if the quiz structure has not been imported.\n";
+ echo "Aborting.\n";
+ $this->call_exit();
+ }
+ $arguments = $clihelper->get_arguments();
+ $moodleinstance = $arguments['moodleinstance'];
+ if ($arguments['directory']) {
+ $directory = ($arguments['rootdirectory']) ?
+ $arguments['rootdirectory'] . '/' . $arguments['directory'] : $arguments['directory'];
+ } else {
+ $directory = $arguments['rootdirectory'];
+ }
+ if (!empty($arguments['nonquizmanifestpath'])) {
+ $this->nonquizmanifestpath = ($arguments['rootdirectory']) ?
+ $arguments['rootdirectory'] . '/' . $arguments['nonquizmanifestpath'] : $arguments['nonquizmanifestpath'];
+ } else {
+ $this->nonquizmanifestpath = null;
+ }
+ if (is_array($arguments['token'])) {
+ $token = $arguments['token'][$moodleinstance];
+ } else {
+ $token = $arguments['token'];
+ }
+ $ignorecat = $arguments['ignorecat'];
+ $ignorecat = ($ignorecat) ? ' -x "' . $ignorecat . '"' : '';
+ $this->import_quiz_data();
+ $output = $this->call_import_repo($directory, $moodleinstance, $token,
+ $this->cmid, $ignorecat, $scriptdirectory);
+ echo $output;
+ $output = $this->call_import_quiz_data($moodleinstance, $token, $scriptdirectory);
+ echo $output;
+ }
+
+ /**
+ * Separate out exec call for mocking.
+ *
+ * @param string $rootdirectory
+ * @param string $moodleinstance
+ * @param string $token
+ * @param string|null $cmid
+ * @param string $ignorecat
+ * @param string $scriptdirectory
+ * @return string|null
+ */
+ public function call_import_repo(string $rootdirectory, string $moodleinstance, string $token,
+ ?string $quizcmid, string $ignorecat, string $scriptdirectory): string {
+ chdir($scriptdirectory);
+ return shell_exec('php importrepotomoodle.php -u ' . $this->usegit . ' -w -r "' . $rootdirectory .
+ '" -i "' . $moodleinstance . '" -l "module" -n ' . $quizcmid . ' -t ' . $token . $ignorecat);
+ }
+
+ /**
+ * Separate out exec call for mocking.
+ *
+ * @param string $moodleinstance
+ * @param string $token
+ * @param string $scriptdirectory
+ * @return string|null
+ */
+ public function call_import_quiz_data(string $moodleinstance, string $token, string $scriptdirectory): ?string {
+ chdir($scriptdirectory);
+ $nonquiz = ($this->nonquizmanifestpath) ? ' -p "' . $this->nonquizmanifestpath . '"' : '';
+ return shell_exec('php importquizstructuretomoodle.php -u ' . $this->usegit .
+ ' -w -r "" -i "' . $moodleinstance . '" -t ' . $token. ' -a "' . $this->quizdatapath .
+ '" -f "' . $this->quizmanifestpath. '"' . $nonquiz);
+ }
+
+ /**
+ * Wrapper for cURL request to allow mocking.
+ *
+ * @param string $wsurl webservice URL
+ * @return curl_request
+ */
+ public function get_curl_request($wsurl): curl_request {
+ return new \qbank_gitsync\curl_request($wsurl);
+ }
+
+ /**
+ * Get quiz data from file, convert question file locations to ids
+ * and then import to Moodle. This needs to be called twice - once
+ * to create the quiz and then again to add the questions.
+ *
+ * @return void
+ */
+ public function import_quiz_data() {
+ $quizmanifestentries = [];
+ $nonquizmanifestentries = [];
+ if ($this->quizmanifestcontents) {
+ $quizmanifestentries = array_column($this->quizmanifestcontents->questions, null, 'filepath');
+ }
+ if ($this->nonquizmanifestpath) {
+ $nonquizmanifestentries = array_column($this->nonquizmanifestcontents->questions, null, 'filepath');
+ }
+
+ foreach ($this->quizdatacontents->quiz as $key => $quizparam) {
+ $this->postsettings["quiz[{$key}]"] = $quizparam;
+ }
+
+ $this->postsettings["quiz[cmid]"] = $this->cmid;
+
+ foreach ($this->quizdatacontents->sections as $sectionkey => $section) {
+ foreach ($section as $key => $sectionparam) {
+ $this->postsettings["sections[{$sectionkey}][{$key}]"] = $sectionparam;
+ }
+ }
+
+ if ($this->cmid && count($this->quizdatacontents->questions)) {
+ // We only add questions if quiz already exists.
+ foreach ($this->quizdatacontents->questions as $questionkey => $question) {
+ foreach ($question as $key => $questionparam) {
+ $this->postsettings["questions[{$questionkey}][{$key}]"] = $questionparam;
+ }
+ $manifestentry = false;
+ $qidentifier = '';
+ if (isset($question->quizfilepath)) {
+ $manifestentry = $quizmanifestentries["{$question->quizfilepath}"] ?? false;
+ $qidentifier = "Quiz repo: {$question->quizfilepath}";
+ unset($this->postsettings["questions[{$questionkey}][quizfilepath]"]);
+ } else if (isset($question->nonquizfilepath)) {
+ $manifestentry = $nonquizmanifestentries["{$question->nonquizfilepath}"] ?? false;
+ $qidentifier = "Non-quiz repo: {$question->nonquizfilepath}";
+ unset($this->postsettings["questions[{$questionkey}][nonquizfilepath]"]);
+ }
+
+ if ($manifestentry) {
+ $this->postsettings["questions[{$questionkey}][questionbankentryid]"] = $manifestentry->questionbankentryid;
+ } else {
+ $multiple = ($this->quizmanifestcontents && $this->nonquizmanifestpath) ? 's' : '';
+ echo "Question: {$qidentifier}\n";
+ echo "This question is in the quiz but not in the supplied manifest file" . $multiple . ".\n";
+ echo "Questions must either be in the repo for the quiz context defined by a supplied quiz manifest " .
+ "(--quizmanifestpath) or in the course context " .
+ "defined by a different manifest (--nonquizmanifestpath).\n";
+ echo "You can supply either or both.\n";
+ echo "Aborting.\n";
+ $this->call_exit();
+ }
+ }
+ }
+
+ foreach ($this->quizdatacontents->feedback as $feedbackkey => $feedback) {
+ foreach ($feedback as $key => $feedbackparam) {
+ $this->postsettings["feedback[{$feedbackkey}][{$key}]"] = $feedbackparam;
+ }
+ }
+
+ $this->curlrequest->set_option(CURLOPT_POSTFIELDS, $this->postsettings);
+ $response = $this->curlrequest->execute();
+ $responsejson = json_decode($response);
+ if (!$responsejson) {
+ echo "Broken JSON returned from Moodle:\n";
+ echo $response . "\n";
+ $this->call_exit();
+ } else if (property_exists($responsejson, 'exception')) {
+ echo "{$responsejson->message}\n";
+ if (property_exists($responsejson, 'debuginfo')) {
+ echo "{$responsejson->debuginfo}\n";
+ }
+ $this->call_exit();
+ }
+
+ $this->cmid = isset($responsejson->cmid) ? $responsejson->cmid : '';
+ echo "Quiz imported.\n";
+ }
+
+ /**
+ * Mockable function that just exits code.
+ *
+ * Required to stop PHPUnit displaying output after exit.
+ *
+ * @return void
+ */
+ public function call_exit(): void {
+ exit;
+ }
+
+ /**
+ * Prompt user whether they want to continue.
+ *
+ * @return void
+ */
+ public function handle_abort(): void {
+ echo "Abort? y/n\n";
+ $handle = fopen ("php://stdin", "r");
+ $line = fgets($handle);
+ if (trim($line) === 'y') {
+ $this->call_exit();
+ }
+ fclose($handle);
+ }
+}
diff --git a/classes/import_repo.php b/classes/import_repo.php
index d94dfa3..205d07f 100644
--- a/classes/import_repo.php
+++ b/classes/import_repo.php
@@ -27,7 +27,6 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace qbank_gitsync;
-use stdClass;
/**
* Import a Git repo.
*/
@@ -172,10 +171,12 @@ public function __construct(cli_helper $clihelper, array $moodleinstances) {
$moodleinstance = $arguments['moodleinstance'];
$manifestpath = $arguments['manifestpath'];
if ($arguments['directory']) {
- $this->directory = $arguments['rootdirectory'] . '/' . $arguments['directory'];
+ $this->directory = ($arguments['rootdirectory']) ? $arguments['rootdirectory'] . '/' . $arguments['directory'] :
+ $arguments['directory'];
} else {
- if ($manifestpath) {
- $this->directory = $arguments['rootdirectory'] . '/' . dirname($manifestpath);
+ if ($manifestpath && dirname($manifestpath) !== '.') {
+ $this->directory = ($arguments['rootdirectory']) ? $arguments['rootdirectory'] . '/' . dirname($manifestpath) :
+ dirname($manifestpath);
} else {
$this->directory = $arguments['rootdirectory'];
}
@@ -250,7 +251,8 @@ public function __construct(cli_helper $clihelper, array $moodleinstances) {
$this->listcurlrequest->set_option(CURLOPT_POST, 1);
if ($manifestpath) {
- $this->manifestpath = $arguments['rootdirectory'] . '/' . $manifestpath;
+ $this->manifestpath = ($arguments['rootdirectory']) ? $arguments['rootdirectory'] . '/' . $manifestpath :
+ $manifestpath;
} else {
$this->subdirectory = ($arguments['subdirectory']) ? $arguments['subdirectory'] : 'top';
$instanceinfo = $this->clihelper->check_context($this, false, false);
@@ -290,10 +292,24 @@ public function __construct(cli_helper $clihelper, array $moodleinstances) {
$this->call_exit();
} else if (!$manifestcontents && !$manifestpath) {
$this->manifestcontents = new \stdClass();
- $this->manifestcontents->context = null;
+ $this->manifestcontents->context = new \stdClass();
+ $this->manifestcontents->context->contextlevel =
+ cli_helper::get_context_level($instanceinfo->contextinfo->contextlevel);
+ $this->manifestcontents->context->coursename = $instanceinfo->contextinfo->coursename;
+ $this->manifestcontents->context->modulename = $instanceinfo->contextinfo->modulename;
+ $this->manifestcontents->context->coursecategory = $instanceinfo->contextinfo->categoryname;
+ $this->manifestcontents->context->instanceid = $instanceinfo->contextinfo->instanceid;
+ $this->manifestcontents->context->defaultsubcategoryid = $instanceinfo->contextinfo->qcategoryid;
+ $this->manifestcontents->context->defaultsubdirectory = $this->subdirectory;
+ $this->manifestcontents->context->defaultignorecat = $this->ignorecat;
+ $this->manifestcontents->context->moodleurl = $this->moodleurl;
$this->manifestcontents->questions = [];
} else {
$this->manifestcontents = $manifestcontents;
+ if ($this->manifestcontents->context->moodleurl !== $this->moodleurl) {
+ echo "\nManifest file is for the wrong Moodle instance: {$this->manifestcontents}\nAborting.\n";
+ $this->call_exit();
+ }
}
if ($manifestpath) {
@@ -324,7 +340,9 @@ public function __construct(cli_helper $clihelper, array $moodleinstances) {
if ($this->subdirectory === 'top') {
$qcategoryname = 'top';
} else {
- $listqcategoryfile = $this->directory . '/' . $this->subdirectory . '/' . cli_helper::CATEGORY_FILE . '.xml';
+ $listqcategoryfile = ($this->directory ) ?
+ $this->directory . '/' . $this->subdirectory . '/' . cli_helper::CATEGORY_FILE . '.xml' :
+ $this->subdirectory . '/' . cli_helper::CATEGORY_FILE . '.xml';
$qcategoryname = cli_helper::get_question_category_from_file($listqcategoryfile);
}
if (!$qcategoryname) {
@@ -336,7 +354,9 @@ public function __construct(cli_helper $clihelper, array $moodleinstances) {
$this->listpostsettings['qcategoryname'] = $qcategoryname;
$this->listcurlrequest->set_option(CURLOPT_POSTFIELDS, $this->listpostsettings);
- if (count($this->manifestcontents->questions) === 0) {
+ if (count($this->manifestcontents->questions) === 0 && empty($arguments['subcall'])) {
+ // A quiz in a whole course set up can have an empty manifest as
+ // the questions may be in the course.
echo "\nManifest file is empty. This should only be the case if you are importing ";
echo "questions for the first time into a Moodle context where they don't already exist.\n";
$this->handle_abort();
@@ -349,16 +369,13 @@ public function __construct(cli_helper $clihelper, array $moodleinstances) {
*
* @return void
*/
- public function process():void {
+ public function process(): void {
$this->import_categories();
$this->import_questions();
- $instanceinfo = $this->clihelper->check_context($this, true, true);
$this->manifestcontents = cli_helper::create_manifest_file($this->manifestcontents,
$this->tempfilepath,
$this->manifestpath,
- $this->moodleurl,
- $instanceinfo->contextinfo->qcategoryid,
- $this->subdirectory);
+ true);
unlink($this->tempfilepath);
$this->delete_no_file_questions(false);
$this->delete_no_record_questions(false);
@@ -370,7 +387,7 @@ public function process():void {
* @param string $wsurl webservice URL
* @return curl_request
*/
- public function get_curl_request($wsurl):curl_request {
+ public function get_curl_request($wsurl): curl_request {
return new \qbank_gitsync\curl_request($wsurl);
}
@@ -379,7 +396,7 @@ public function get_curl_request($wsurl):curl_request {
*
* @return void
*/
- public function import_categories():void {
+ public function import_categories(): void {
$this->repoiterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($this->directory, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
@@ -440,7 +457,7 @@ public function import_categories():void {
* @param resource $repoitem
* @return bool success or failure
*/
- public function upload_file($repoitem):bool {
+ public function upload_file($repoitem): bool {
$this->uploadpostsettings['file_1'] = new \CURLFile($repoitem->getPathname());
$this->uploadcurlrequest->set_option(CURLOPT_POSTFIELDS, $this->uploadpostsettings);
$fileinfo = json_decode($this->uploadcurlrequest->execute());
@@ -474,7 +491,7 @@ public function upload_file($repoitem):bool {
*/
public function import_questions() {
if ($this->subdirectory) {
- $subdirectory = $this->directory . '/' . $this->subdirectory;
+ $subdirectory = ($this->directory) ? $this->directory . '/' . $this->subdirectory : $this->subdirectory;
} else {
$subdirectory = $this->directory;
}
@@ -553,15 +570,8 @@ public function import_questions() {
$fileoutput = [
'questionbankentryid' => $responsejson->questionbankentryid,
'version' => $responsejson->version,
- // Questions can be imported in multiple contexts.
- 'contextlevel' => $this->postsettings['contextlevel'],
'filepath' => str_replace( '\\', '/', $repoitem->getPathname()),
- 'coursename' => $this->postsettings['coursename'],
- 'modulename' => $this->postsettings['modulename'],
- 'coursecategory' => $this->postsettings['coursecategory'],
- 'instanceid' => $this->postsettings['instanceid'],
'format' => 'xml',
- 'ignorecat' => $this->ignorecat,
];
if ($existingentry && isset($existingentry->currentcommit)) {
$fileoutput['moodlecommit'] = $existingentry->currentcommit;
@@ -591,16 +601,13 @@ public function import_questions() {
*
* @return void
*/
- public function recovery():void {
+ public function recovery(): void {
if (file_exists($this->tempfilepath)) {
echo 'Attempting recovery from failure on previous run. Updating manifest:';
- $instanceinfo = $this->clihelper->check_context($this, true, true);
$this->manifestcontents = cli_helper::create_manifest_file($this->manifestcontents,
$this->tempfilepath,
$this->manifestpath,
- $this->moodleurl,
- $instanceinfo->contextinfo->qcategoryid,
- $this->subdirectory);
+ true);
unlink($this->tempfilepath);
echo 'Recovery successful. Continuing...';
}
@@ -613,7 +620,7 @@ public function recovery():void {
* @param bool $deleteenabled Allows question delete if true, otherwise just lists applicable questions
* @return void
*/
- public function delete_no_file_questions(bool $deleteenabled=false):void {
+ public function delete_no_file_questions(bool $deleteenabled=false): void {
// Get all manifest entries for imported subdirectory.
// Filepath should equal subdirectory or path must be longer and continue with
// one (and only) one slash.
@@ -665,7 +672,7 @@ public function delete_no_file_questions(bool $deleteenabled=false):void {
* @param bool $deleteenabled Allows question delete if true, otherwise just lists applicable questions
* @return void
*/
- public function delete_no_record_questions(bool $deleteenabled=false):void {
+ public function delete_no_record_questions(bool $deleteenabled=false): void {
if (count($this->manifestcontents->questions) === 0 && $deleteenabled) {
echo 'Manifest file is empty or inaccessible. You probably want to abort.\n';
$this->handle_abort();
@@ -722,7 +729,7 @@ public function delete_no_record_questions(bool $deleteenabled=false):void {
* @param object $question \stdClass question to be deleted
* @return bool Was the question deleted
*/
- public function handle_delete(object $question):bool {
+ public function handle_delete(object $question): bool {
$deleted = false;
$handle = fopen ("php://stdin", "r");
$line = fgets($handle);
@@ -757,7 +764,7 @@ public function handle_delete(object $question):bool {
*
* @return void
*/
- public function handle_abort():void {
+ public function handle_abort(): void {
echo "Abort? y/n\n";
$handle = fopen ("php://stdin", "r");
$line = fgets($handle);
@@ -788,10 +795,13 @@ public function check_question_versions(): void {
$questionsinmoodle = json_decode('{"questions": []}'); // Required for unit tests.
} else if (property_exists($questionsinmoodle, 'exception')) {
if (isset($questionsinmoodle->errorcode) && $questionsinmoodle->errorcode === 'categoryerror') {
- echo "Target category {$this->listpostsettings['qcategoryname']} does not exist in Moodle.\n";
- echo "This should only be the case if you're importing it for the first time and\n";
- echo "want to create new questions in Moodle.\n";
- $this->handle_abort();
+ $arguments = $this->clihelper->get_arguments();
+ if (empty($arguments['subcall'])) {
+ echo "Target category {$this->listpostsettings['qcategoryname']} does not exist in Moodle.\n";
+ echo "This should only be the case if you're importing it for the first time and\n";
+ echo "want to create new questions in Moodle.\n";
+ $this->handle_abort();
+ }
return;
} else {
echo "{$questionsinmoodle->message}\n";
@@ -864,6 +874,181 @@ public function check_question_versions(): void {
$this->listcurlrequest->set_option(CURLOPT_POSTFIELDS, $this->listpostsettings);
}
+ /**
+ * Create/update quizzes for whole course.
+ * @param object $clihelper
+ * @param string $scriptdirectory - directory of CLI scripts
+ * @return void
+ */
+ public function update_quizzes($clihelper, $scriptdirectory) {
+ $arguments = $clihelper->get_arguments();
+ $contextinfo = $clihelper->check_context($this, false, true);
+ $basedirectory = dirname($this->manifestpath);
+ $moodleinstance = $arguments['moodleinstance'];
+ if (is_array($arguments['token'])) {
+ $token = $arguments['token'][$moodleinstance];
+ } else {
+ $token = $arguments['token'];
+ }
+ $ignorecat = $arguments['ignorecat'];
+ $ignorecat = ($ignorecat) ? ' -x "' . $ignorecat . '"' : '';
+ $quizlocations = (isset($this->manifestcontents->quizzes)) ? $this->manifestcontents->quizzes : [];
+ $topdircontents = scandir(dirname($basedirectory));
+ foreach ($topdircontents as $quizdirectory) {
+ if (!is_dir(dirname($basedirectory) . '/' . $quizdirectory) || strpos($quizdirectory, '_quiz_') === false) {
+ // Don't import from anything that isn't a directory or has a name in the wrong format.
+ continue;
+ }
+ $instanceid = array_column($quizlocations, null, 'directory')[$quizdirectory]->moduleid ?? false;
+ $rootdirectory = dirname($basedirectory) . '/' . $quizdirectory;
+ $quizfiles = scandir($rootdirectory);
+ $structurefile = null;
+ // Find the structure file.
+ foreach ($quizfiles as $quizfile) {
+ if (preg_match('/.*_quiz\.json/', $quizfile)) {
+ $structurefile = $quizfile;
+ break;
+ }
+ }
+ if (!$structurefile) {
+ echo "\nNo structure file in {$rootdirectory}\nQuiz not imported.\n";
+ continue;
+ }
+
+ $structurefilepath = $rootdirectory . '/' . $structurefile;
+ $contentsjson = file_get_contents($structurefilepath);
+ $structurecontents = json_decode($contentsjson);
+ $quizmanifestname = cli_helper::get_manifest_path($moodleinstance, 'module', null,
+ $contextinfo->contextinfo->coursename, $structurecontents->quiz->name, '');
+ $quizmanifestpath = $rootdirectory . $quizmanifestname;
+ $quizcmid = null;
+
+ if (!is_file($quizmanifestpath)) {
+ // The quiz does not exist in the targeted Moodle instance. We need to create it,
+ // import questions and then add questions to the quiz.
+ echo "\nCreating quiz: {$structurecontents->quiz->name}\n";
+ $output = $this->call_import_quiz_data($moodleinstance, $token,
+ $contextinfo->contextinfo->instanceid,
+ null, $structurefilepath, $scriptdirectory);
+ }
+ // Import quiz context questions.
+ echo "\nImporting quiz context: {$structurecontents->quiz->name}\n";
+ if (is_file($quizmanifestpath)) {
+ $output = $this->call_import_repo($rootdirectory, $moodleinstance, $token,
+ $quizmanifestname, null, $ignorecat, $scriptdirectory);
+ echo $output;
+ } else {
+ // No quiz manifest. We need to import questions into context of created quiz.
+ $newcontextinfo = $clihelper->check_context($this, false, true);
+ $oldquizzes = array_column($contextinfo->quizzes, null, 'instanceid');
+ foreach ($newcontextinfo->quizzes as $newquiz) {
+ if (!isset($oldquizzes[$newquiz->instanceid])) {
+ $quizcmid = $newquiz->instanceid;
+ break;
+ }
+ }
+ $contextinfo = $newcontextinfo;
+ if (!$quizcmid) {
+ echo "\nQuiz was not created for some reason.\n Aborting.\n";
+ $this->call_exit();
+ $instanceid = 'Test'; // Required for unit tests.
+ $this->manifestcontents->quizzes = [];
+ }
+ $output = $this->call_import_repo($rootdirectory, $moodleinstance, $token,
+ null, $quizcmid, $ignorecat, $scriptdirectory);
+ echo $output;
+ }
+ if ($instanceid === false) {
+ // Import quiz structure as it's not currenty in the nonquizmanifest.
+ echo "\nImporting quiz structure: {$structurecontents->quiz->name}\n";
+ $output = $this->call_import_quiz_data($moodleinstance, $token,
+ $contextinfo->contextinfo->instanceid,
+ $quizmanifestpath, $structurefilepath, $scriptdirectory);
+ echo $output;
+ $quizlocation = new \StdClass();
+ if (!$quizcmid) {
+ $quizmanifestcontents = json_decode(file_get_contents($quizmanifestpath));
+ if (!$quizmanifestcontents) {
+ echo "\nUnable to access or parse manifest file: {$this->quizmanifestpath}\nAborting.\n";
+ $this->call_exit();
+ }
+ $quizcmid = $quizmanifestcontents->context->instanceid;
+ }
+ $quizlocation->moduleid = $quizcmid;
+ $quizlocation->directory = basename($rootdirectory);
+ $quizlocations[] = $quizlocation;
+ $this->manifestcontents->quizzes = $quizlocations;
+ $success = file_put_contents($this->manifestpath, json_encode($this->manifestcontents));
+ if ($success === false) {
+ echo "\nUnable to update manifest file: {$this->manifestpath}\n Aborting.\n";
+ exit();
+ }
+ }
+ }
+
+ // Check for quizzes which are in Moodle but not in the manifest.
+ $quizlocations = $this->manifestcontents->quizzes;
+ $locarray = array_column($quizlocations, null, 'moduleid');
+ foreach ($contextinfo->quizzes as $quiz) {
+ $instanceid = (int) $quiz->instanceid;
+ if (!isset($locarray[$instanceid])) {
+ echo "\nQuiz {$quiz->name} is in Moodle but not in the manifest. " .
+ "It should be exported from Moodle or manually deleted within Moodle.\n";
+ }
+ }
+ }
+
+ /**
+ * Separate out exec call for mocking.
+ *
+ * @param string $rootdirectory
+ * @param string $moodleinstance
+ * @param string $token
+ * @param string|null $quizmanifestname
+ * @param string|null $cmid
+ * @param string $ignorecat
+ * @param string $scriptdirectory
+ * @return string|null
+ */
+ public function call_import_repo(string $rootdirectory, string $moodleinstance, string $token,
+ ?string $quizmanifestname, ?string $quizcmid,
+ string $ignorecat, string $scriptdirectory): string {
+ chdir($scriptdirectory);
+ if ($quizmanifestname) {
+ return shell_exec('php importrepotomoodle.php -u ' . $this->usegit . ' -w -r "' . $rootdirectory .
+ '" -i "' . $moodleinstance . '" -f "' . $quizmanifestname . '" -t ' . $token . $ignorecat);
+ } else {
+ return shell_exec('php importrepotomoodle.php -u ' . $this->usegit . ' -w -r "' . $rootdirectory .
+ '" -i "' . $moodleinstance . '" -l "module" -n ' . $quizcmid . ' -t ' . $token . $ignorecat);
+ }
+ }
+
+ /**
+ * Separate out exec call for mocking.
+ *
+ * @param string $moodleinstance
+ * @param string $token
+ * @param string $instanceid
+ * @param string|null $quizmanifestpath
+ * @param string $structurefilepath
+ * @param string $scriptdirectory
+ * @return string|null
+ */
+ public function call_import_quiz_data(string $moodleinstance, string $token, string $instanceid,
+ ?string $quizmanifestpath, string $structurefilepath, string $scriptdirectory): ?string {
+ chdir($scriptdirectory);
+ if ($quizmanifestpath) {
+ return shell_exec('php importquizstructuretomoodle.php -u ' . $this->usegit .
+ ' -w -r "" -i "' . $moodleinstance . '" -n ' .
+ $instanceid . ' -t ' . $token. ' -p "' . $this->manifestpath . '" -f "' .
+ $quizmanifestpath . '" -a "' . $structurefilepath . '"');
+ } else {
+ return shell_exec('php importquizstructuretomoodle.php -u ' . $this->usegit .
+ ' -w -r "" -i "' . $moodleinstance . '" -n ' .
+ $instanceid . ' -t ' . $token. ' -p "' . $this->manifestpath. '" -a "' . $structurefilepath . '"');
+ }
+ }
+
/**
* Mockable function that just exits code.
*
@@ -871,7 +1056,7 @@ public function check_question_versions(): void {
*
* @return void
*/
- public function call_exit():void {
+ public function call_exit(): void {
exit;
}
}
diff --git a/classes/tidy_trait.php b/classes/tidy_trait.php
index 9332409..5120ce2 100644
--- a/classes/tidy_trait.php
+++ b/classes/tidy_trait.php
@@ -38,7 +38,7 @@ trait tidy_trait {
*
* @return void
*/
- public function tidy_manifest():void {
+ public function tidy_manifest(): void {
// We want to check the whole context or we'll be flagging
// entries outside the subcategory/subdirectory.
$oldsetting = $this->listpostsettings['qcategoryname'];
diff --git a/cli/createrepo.php b/cli/createrepo.php
index 7a9b9ad..ac45599 100755
--- a/cli/createrepo.php
+++ b/cli/createrepo.php
@@ -15,7 +15,7 @@
// along with Stack. If not, see .
/**
- * Import a git repo containing questions from Moodle.
+ * Create a git repo containing questions from Moodle.
*
* @package qbank_gitsync
* @copyright 2023 University of Edinburgh
@@ -115,6 +115,14 @@
'variable' => 'instanceid',
'valuerequired' => true,
],
+ [
+ 'longopt' => 'nonquizmanifestpath',
+ 'shortopt' => 'p',
+ 'description' => 'Quiz export: Filepath of non-quiz manifest file relative to root directory.',
+ 'default' => null,
+ 'variable' => 'nonquizmanifestpath',
+ 'valuerequired' => true,
+ ],
[
'longopt' => 'token',
'shortopt' => 't',
@@ -137,7 +145,7 @@
'description' => 'Is the repo controlled using Git?',
'default' => $usegit,
'variable' => 'usegit',
- 'valuerequired' => false,
+ 'valuerequired' => true,
],
[
'longopt' => 'ignorecat',
@@ -147,6 +155,15 @@
'variable' => 'ignorecat',
'valuerequired' => true,
],
+ [
+ 'longopt' => 'subcall',
+ 'shortopt' => 'w',
+ 'description' => 'Is this a subcall of the script?',
+ 'default' => false,
+ 'variable' => 'subcall',
+ 'valuerequired' => false,
+ 'hidden' => true,
+ ],
];
if (!function_exists('simplexml_load_file')) {
@@ -159,3 +176,15 @@
$clihelper->check_repo_initialised($createrepo->manifestpath);
$createrepo->process();
$clihelper->commit_hash_setup($createrepo);
+// If we're exporting a quiz then we try getting the structure as well.
+// Skip if we're creating a whole course repo or we'll do it twice!
+if ($createrepo->manifestcontents->context->contextlevel === 70 && !$clihelper->get_arguments()['subcall']) {
+ $scriptdirectory = dirname(__FILE__);
+ $createrepo->export_quiz_structure($clihelper, $scriptdirectory);
+ if ($clihelper->get_arguments()['usegit']) {
+ chdir(dirname($createrepo->manifestpath));
+ exec("git add --all");
+ exec('git commit -m "Initial Commit - Quiz structure"');
+ }
+}
+
diff --git a/cli/createwholecourserepo.php b/cli/createwholecourserepo.php
new file mode 100755
index 0000000..73c9735
--- /dev/null
+++ b/cli/createwholecourserepo.php
@@ -0,0 +1,155 @@
+.
+
+/**
+ * Create git repos containing questions from Moodle.
+ * Exports a course context into one repo and associated
+ * quizzes into sibling repos.
+ *
+ * @package qbank_gitsync
+ * @copyright 2024 University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace qbank_gitsync;
+
+define('CLI_SCRIPT', true);
+require_once('./config.php');
+require_once('../classes/curl_request.php');
+require_once('../classes/cli_helper.php');
+require_once('../classes/export_trait.php');
+require_once('../classes/create_repo.php');
+
+$options = [
+ [
+ 'longopt' => 'moodleinstance',
+ 'shortopt' => 'i',
+ 'description' => 'Key of Moodle instance in $moodleinstances to use (see config.php). ' .
+ 'Should match end of instance URL. ' .
+ 'Defaults to $instance in config.php.',
+ 'default' => $instance,
+ 'variable' => 'moodleinstance',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'rootdirectory',
+ 'shortopt' => 'r',
+ 'description' => "Directory on user's computer containing repos. " .
+ 'Defaults to $rootdirectory in config.php.',
+ 'default' => $rootdirectory,
+ 'variable' => 'rootdirectory',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'directory',
+ 'shortopt' => 'd',
+ 'description' => 'Directory of repo on users computer, containing "top" folder, ' .
+ 'relative to root directory.',
+ 'default' => '',
+ 'variable' => 'directory',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'contextlevel',
+ 'shortopt' => 'l',
+ 'description' => 'Context from which to extract questions. Should always be course.',
+ 'default' => 'course',
+ 'variable' => 'contextlevel',
+ 'valuerequired' => true,
+ 'hidden' => true,
+ ],
+ [
+ 'longopt' => 'subcategory',
+ 'shortopt' => 's',
+ 'description' => 'Relative subcategory of question to actually export.',
+ 'default' => null,
+ 'variable' => 'subcategory',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'coursename',
+ 'shortopt' => 'c',
+ 'description' => 'Unique course name for course or module context.',
+ 'default' => null,
+ 'variable' => 'coursename',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'questioncategoryid',
+ 'shortopt' => 'q',
+ 'description' => 'Numerical id of subcategory to actually export.',
+ 'default' => null,
+ 'variable' => 'qcategoryid',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'instanceid',
+ 'shortopt' => 'n',
+ 'description' => 'Numerical id of the course, module of course category.',
+ 'default' => null,
+ 'variable' => 'instanceid',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'token',
+ 'shortopt' => 't',
+ 'description' => 'Security token for webservice.',
+ 'default' => $token,
+ 'variable' => 'token',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'help',
+ 'shortopt' => 'h',
+ 'description' => '',
+ 'default' => false,
+ 'variable' => 'help',
+ 'valuerequired' => false,
+ ],
+ [
+ 'longopt' => 'usegit',
+ 'shortopt' => 'u',
+ 'description' => 'Is the repo controlled using Git?',
+ 'default' => $usegit,
+ 'variable' => 'usegit',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'ignorecat',
+ 'shortopt' => 'x',
+ 'description' => 'Regex of categories to ignore - add an extra leading / for Windows.',
+ 'default' => $ignorecat,
+ 'variable' => 'ignorecat',
+ 'valuerequired' => true,
+ ],
+];
+
+if (!function_exists('simplexml_load_file')) {
+ echo 'Please install the PHP library SimpleXML.' . "\n";
+ exit;
+}
+
+// Create course directory and populate.
+$scriptdirectory = dirname(__FILE__);
+$clihelper = new cli_helper($options);
+echo "Exporting a course. Associated quiz contexts will also be exported.\n";
+$createrepo = new create_repo($clihelper, $moodleinstances);
+$clihelper->create_directory(dirname($createrepo->manifestpath));
+$clihelper->check_repo_initialised($createrepo->manifestpath);
+$createrepo->process();
+$clihelper->commit_hash_setup($createrepo);
+$createrepo->create_quiz_directories($clihelper, $scriptdirectory);
+
diff --git a/cli/deletefrommoodle.php b/cli/deletefrommoodle.php
index 77d50d3..72b84e3 100755
--- a/cli/deletefrommoodle.php
+++ b/cli/deletefrommoodle.php
@@ -135,7 +135,7 @@
'description' => 'Is the repo controlled using Git?',
'default' => $usegit,
'variable' => 'usegit',
- 'valuerequired' => false,
+ 'valuerequired' => true,
],
[
'longopt' => 'ignorecat',
diff --git a/cli/exportquizstructurefrommoodle.php b/cli/exportquizstructurefrommoodle.php
new file mode 100644
index 0000000..1fad703
--- /dev/null
+++ b/cli/exportquizstructurefrommoodle.php
@@ -0,0 +1,113 @@
+.
+
+/**
+ * Export structure (not questions) of a quiz.
+ *
+ * @package qbank_gitsync
+ * @copyright 2024 University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace qbank_gitsync;
+define('CLI_SCRIPT', true);
+require_once('./config.php');
+require_once('../classes/curl_request.php');
+require_once('../classes/cli_helper.php');
+require_once('../classes/export_quiz.php');
+
+$options = [
+ [
+ 'longopt' => 'moodleinstance',
+ 'shortopt' => 'i',
+ 'description' => 'Key of Moodle instance in $moodleinstances to use. ' .
+ 'Should match end of instance URL.',
+ 'default' => $instance,
+ 'variable' => 'moodleinstance',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'rootdirectory',
+ 'shortopt' => 'r',
+ 'description' => "Directory on user's computer containing repos.",
+ 'default' => $rootdirectory,
+ 'variable' => 'rootdirectory',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'nonquizmanifestpath',
+ 'shortopt' => 'p',
+ 'description' => 'Filepath of non-quiz manifest file relative to root directory.',
+ 'default' => null,
+ 'variable' => 'nonquizmanifestpath',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'quizmanifestpath',
+ 'shortopt' => 'f',
+ 'description' => 'Filepath of quiz manifest file relative to root directory.',
+ 'default' => null,
+ 'variable' => 'quizmanifestpath',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'token',
+ 'shortopt' => 't',
+ 'description' => 'Security token for webservice.',
+ 'default' => $token,
+ 'variable' => 'token',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'help',
+ 'shortopt' => 'h',
+ 'description' => '',
+ 'default' => false,
+ 'variable' => 'help',
+ 'valuerequired' => false,
+ ],
+ [
+ 'longopt' => 'usegit',
+ 'shortopt' => 'u',
+ 'description' => 'Is the repo controlled using Git?',
+ 'default' => $usegit,
+ 'variable' => 'usegit',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'subcall',
+ 'shortopt' => 'w',
+ 'description' => 'Is this a subcall of the script?',
+ 'default' => false,
+ 'variable' => 'subcall',
+ 'valuerequired' => false,
+ 'hidden' => true,
+ ],
+];
+
+if (!function_exists('simplexml_load_file')) {
+ echo 'Please install the PHP library SimpleXML.' . "\n";
+ exit;
+}
+$clihelper = new cli_helper($options);
+$exportquiz = new export_quiz($clihelper, $moodleinstances);
+if ($exportquiz->quizmanifestpath) {
+ if (empty($clihelper->get_arguments()['subcall'])) {
+ echo "Checking quiz repo...\n";
+ }
+ $clihelper->check_for_changes($exportquiz->quizmanifestpath);
+}
+$exportquiz->process();
diff --git a/cli/exportrepofrommoodle.php b/cli/exportrepofrommoodle.php
index a7491f0..b494b25 100644
--- a/cli/exportrepofrommoodle.php
+++ b/cli/exportrepofrommoodle.php
@@ -49,6 +49,14 @@
'variable' => 'rootdirectory',
'valuerequired' => true,
],
+ [
+ 'longopt' => 'nonquizmanifestpath',
+ 'shortopt' => 'p',
+ 'description' => 'Quiz export: Filepath of non-quiz manifest file relative to root directory.',
+ 'default' => null,
+ 'variable' => 'nonquizmanifestpath',
+ 'valuerequired' => true,
+ ],
[
'longopt' => 'manifestpath',
'shortopt' => 'f',
@@ -95,7 +103,7 @@
'description' => 'Is the repo controlled using Git?',
'default' => $usegit,
'variable' => 'usegit',
- 'valuerequired' => false,
+ 'valuerequired' => true,
],
[
'longopt' => 'ignorecat',
@@ -105,6 +113,15 @@
'variable' => 'ignorecat',
'valuerequired' => true,
],
+ [
+ 'longopt' => 'subcall',
+ 'shortopt' => 'w',
+ 'description' => 'Is this a subcall of the script?',
+ 'default' => false,
+ 'variable' => 'subcall',
+ 'valuerequired' => false,
+ 'hidden' => true,
+ ],
];
if (!function_exists('simplexml_load_file')) {
@@ -116,3 +133,7 @@
$clihelper->check_for_changes($exportrepo->manifestpath);
$clihelper->backup_manifest($exportrepo->manifestpath);
$exportrepo->process();
+if ($exportrepo->manifestcontents->context->contextlevel === 70 && !$clihelper->get_arguments()['subcall']) {
+ $scriptdirectory = dirname(__FILE__);
+ $exportrepo->export_quiz_structure($clihelper, $scriptdirectory);
+}
diff --git a/cli/exportwholecoursefrommoodle.php b/cli/exportwholecoursefrommoodle.php
new file mode 100644
index 0000000..20e2956
--- /dev/null
+++ b/cli/exportwholecoursefrommoodle.php
@@ -0,0 +1,121 @@
+.
+
+/**
+ * Export from Moodle into a git repo containing questions.
+ *
+ * @package qbank_gitsync
+ * @copyright 2023 University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace qbank_gitsync;
+define('CLI_SCRIPT', true);
+require_once('./config.php');
+require_once('../classes/curl_request.php');
+require_once('../classes/cli_helper.php');
+require_once('../classes/export_trait.php');
+require_once('../classes/tidy_trait.php');
+require_once('../classes/export_repo.php');
+
+$options = [
+ [
+ 'longopt' => 'moodleinstance',
+ 'shortopt' => 'i',
+ 'description' => 'Key of Moodle instance in $moodleinstances to use. ' .
+ 'Should match end of instance URL.',
+ 'default' => $instance,
+ 'variable' => 'moodleinstance',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'rootdirectory',
+ 'shortopt' => 'r',
+ 'description' => "Directory on user's computer containing repos.",
+ 'default' => $rootdirectory,
+ 'variable' => 'rootdirectory',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'manifestpath',
+ 'shortopt' => 'f',
+ 'description' => 'Filepath of manifest file relative to root directory.',
+ 'default' => $manifestpath,
+ 'variable' => 'manifestpath',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'subcategory',
+ 'shortopt' => 's',
+ 'description' => 'Relative subcategory of question to actually export.',
+ 'default' => null,
+ 'variable' => 'subcategory',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'questioncategoryid',
+ 'shortopt' => 'q',
+ 'description' => 'Numerical id of subcategory to actually export.',
+ 'default' => null,
+ 'variable' => 'qcategoryid',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'token',
+ 'shortopt' => 't',
+ 'description' => 'Security token for webservice.',
+ 'default' => $token,
+ 'variable' => 'token',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'help',
+ 'shortopt' => 'h',
+ 'description' => '',
+ 'default' => false,
+ 'variable' => 'help',
+ 'valuerequired' => false,
+ ],
+ [
+ 'longopt' => 'usegit',
+ 'shortopt' => 'u',
+ 'description' => 'Is the repo controlled using Git?',
+ 'default' => $usegit,
+ 'variable' => 'usegit',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'ignorecat',
+ 'shortopt' => 'x',
+ 'description' => 'Regex of categories to ignore - add an extra leading / for Windows.',
+ 'default' => $ignorecat,
+ 'variable' => 'ignorecat',
+ 'valuerequired' => true,
+ ],
+];
+
+if (!function_exists('simplexml_load_file')) {
+ echo 'Please install the PHP library SimpleXML.' . "\n";
+ exit;
+}
+$scriptdirectory = dirname(__FILE__);
+$clihelper = new cli_helper($options);
+echo "Exporting a course. Associated quiz contexts will also be exported.\n";
+$exportrepo = new export_repo($clihelper, $moodleinstances);
+$clihelper->check_for_changes($exportrepo->manifestpath);
+$clihelper->backup_manifest($exportrepo->manifestpath);
+$exportrepo->process();
+$exportrepo->update_quiz_directories($clihelper, $scriptdirectory);
diff --git a/cli/importquizstructuretomoodle.php b/cli/importquizstructuretomoodle.php
new file mode 100644
index 0000000..af38947
--- /dev/null
+++ b/cli/importquizstructuretomoodle.php
@@ -0,0 +1,138 @@
+.
+
+/**
+ * Import structure (not questions) of a quiz to Moodle.
+ *
+ * @package qbank_gitsync
+ * @copyright 2024 University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace qbank_gitsync;
+define('CLI_SCRIPT', true);
+require_once('./config.php');
+require_once('../classes/curl_request.php');
+require_once('../classes/cli_helper.php');
+require_once('../classes/import_quiz.php');
+
+$options = [
+ [
+ 'longopt' => 'moodleinstance',
+ 'shortopt' => 'i',
+ 'description' => 'Key of Moodle instance in $moodleinstances to use. ' .
+ 'Should match end of instance URL.',
+ 'default' => $instance,
+ 'variable' => 'moodleinstance',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'rootdirectory',
+ 'shortopt' => 'r',
+ 'description' => "Directory on user's computer containing repos.",
+ 'default' => $rootdirectory,
+ 'variable' => 'rootdirectory',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'nonquizmanifestpath',
+ 'shortopt' => 'p',
+ 'description' => 'Filepath of non-quiz manifest file relative to root directory.',
+ 'default' => null,
+ 'variable' => 'nonquizmanifestpath',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'quizmanifestpath',
+ 'shortopt' => 'f',
+ 'description' => 'Filepath of quiz manifest file relative to root directory.',
+ 'default' => null,
+ 'variable' => 'quizmanifestpath',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'quizdatapath',
+ 'shortopt' => 'a',
+ 'description' => 'Filepath of quiz data file relative to root directory.',
+ 'default' => null,
+ 'variable' => 'quizdatapath',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'coursename',
+ 'shortopt' => 'c',
+ 'description' => 'Unique course name of course.',
+ 'default' => null,
+ 'variable' => 'coursename',
+ 'valuerequired' => true,
+ 'hidden' => true,
+ ],
+ [
+ 'longopt' => 'instanceid',
+ 'shortopt' => 'n',
+ 'description' => 'Numerical id of the course.',
+ 'default' => null,
+ 'variable' => 'instanceid',
+ 'valuerequired' => true,
+ 'hidden' => true,
+ ],
+ [
+ 'longopt' => 'token',
+ 'shortopt' => 't',
+ 'description' => 'Security token for webservice.',
+ 'default' => $token,
+ 'variable' => 'token',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'help',
+ 'shortopt' => 'h',
+ 'description' => '',
+ 'default' => false,
+ 'variable' => 'help',
+ 'valuerequired' => false,
+ ],
+ [
+ 'longopt' => 'usegit',
+ 'shortopt' => 'u',
+ 'description' => 'Is the repo controlled using Git?',
+ 'default' => $usegit,
+ 'variable' => 'usegit',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'subcall',
+ 'shortopt' => 'w',
+ 'description' => 'Is this a subcall of the script?',
+ 'default' => false,
+ 'variable' => 'subcall',
+ 'valuerequired' => false,
+ 'hidden' => true,
+ ],
+];
+
+if (!function_exists('simplexml_load_file')) {
+ echo 'Please install the PHP library SimpleXML.' . "\n";
+ exit;
+}
+
+$clihelper = new cli_helper($options);
+$importquiz = new import_quiz($clihelper, $moodleinstances);
+if ($importquiz->quizmanifestpath && empty($clihelper->get_arguments()['subcall'])) {
+ echo "Checking quiz repo...\n";
+ $clihelper->check_for_changes($importquiz->quizmanifestpath);
+}
+$importquiz->process();
diff --git a/cli/importquiztomoodle.php b/cli/importquiztomoodle.php
new file mode 100755
index 0000000..1270efc
--- /dev/null
+++ b/cli/importquiztomoodle.php
@@ -0,0 +1,167 @@
+.
+
+/**
+ * Import a git repo containing questions into Moodle.
+ *
+ * @package qbank_gitsync
+ * @copyright 2024 University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace qbank_gitsync;
+define('CLI_SCRIPT', true);
+require_once('./config.php');
+require_once('../classes/curl_request.php');
+require_once('../classes/cli_helper.php');
+require_once('../classes/import_quiz.php');
+
+$options = [
+ [
+ 'longopt' => 'moodleinstance',
+ 'shortopt' => 'i',
+ 'description' => 'Key of Moodle instance in $moodleinstances to use. ' .
+ 'Should match end of instance URL.',
+ 'default' => $instance,
+ 'variable' => 'moodleinstance',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'rootdirectory',
+ 'shortopt' => 'r',
+ 'description' => "Directory on user's computer containing repos.",
+ 'default' => $rootdirectory,
+ 'variable' => 'rootdirectory',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'directory',
+ 'shortopt' => 'd',
+ 'description' => 'Directory of repo on users computer containing "top" folder, ' .
+ 'relative to root directory.',
+ 'default' => '',
+ 'variable' => 'directory',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'subdirectory',
+ 'shortopt' => 's',
+ 'description' => 'Relative subdirectory of repo to actually import.',
+ 'default' => null,
+ 'variable' => 'subdirectory',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'contextlevel',
+ 'shortopt' => 'l',
+ 'description' => 'Context in which to place quiz. Always course.',
+ 'default' => 'course',
+ 'variable' => 'contextlevel',
+ 'valuerequired' => true,
+ 'hidden' => true,
+ ],
+ [
+ 'longopt' => 'nonquizmanifestpath',
+ 'shortopt' => 'p',
+ 'description' => 'Filepath of non-quiz manifest file relative to root directory.',
+ 'default' => null,
+ 'variable' => 'nonquizmanifestpath',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'quizdatapath',
+ 'shortopt' => 'a',
+ 'description' => 'Filepath of quiz data file relative to root directory.',
+ 'default' => null,
+ 'variable' => 'quizdatapath',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'coursename',
+ 'shortopt' => 'c',
+ 'description' => 'Unique course name for course or module context.',
+ 'default' => null,
+ 'variable' => 'coursename',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'instanceid',
+ 'shortopt' => 'n',
+ 'description' => 'Numerical id of the course.',
+ 'default' => null,
+ 'variable' => 'instanceid',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'token',
+ 'shortopt' => 't',
+ 'description' => 'Security token for webservice.',
+ 'default' => $token,
+ 'variable' => 'token',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'help',
+ 'shortopt' => 'h',
+ 'description' => '',
+ 'default' => false,
+ 'variable' => 'help',
+ 'valuerequired' => false,
+ ],
+ [
+ 'longopt' => 'createquiz',
+ 'shortopt' => 'k',
+ 'description' => 'Are we creating a quiz?',
+ 'default' => true,
+ 'variable' => 'createquiz',
+ 'valuerequired' => false,
+ 'hidden' => true,
+ ],
+ [
+ 'longopt' => 'usegit',
+ 'shortopt' => 'u',
+ 'description' => 'Is the repo controlled using Git?',
+ 'default' => $usegit,
+ 'variable' => 'usegit',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'ignorecat',
+ 'shortopt' => 'x',
+ 'description' => 'Regex of categories to ignore - add an extra leading / for Windows.',
+ 'default' => $ignorecat,
+ 'variable' => 'ignorecat',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'subcall',
+ 'shortopt' => 'w',
+ 'description' => 'Is this a subcall of the script?',
+ 'default' => false,
+ 'variable' => 'subcall',
+ 'valuerequired' => false,
+ 'hidden' => true,
+ ],
+];
+
+if (!function_exists('simplexml_load_file')) {
+ echo 'Please install the PHP library SimpleXML.' . "\n";
+ exit;
+}
+$scriptdirectory = dirname(__FILE__);
+$clihelper = new cli_helper($options);
+$importquiz = new import_quiz($clihelper, $moodleinstances);
+$importquiz->import_all($clihelper, $scriptdirectory);
diff --git a/cli/importrepotomoodle.php b/cli/importrepotomoodle.php
index 0689909..a9a07e3 100755
--- a/cli/importrepotomoodle.php
+++ b/cli/importrepotomoodle.php
@@ -135,7 +135,7 @@
'description' => 'Is the repo controlled using Git?',
'default' => $usegit,
'variable' => 'usegit',
- 'valuerequired' => false,
+ 'valuerequired' => true,
],
[
'longopt' => 'ignorecat',
@@ -145,6 +145,15 @@
'variable' => 'ignorecat',
'valuerequired' => true,
],
+ [
+ 'longopt' => 'subcall',
+ 'shortopt' => 'w',
+ 'description' => 'Is this a subcall of the script?',
+ 'default' => false,
+ 'variable' => 'subcall',
+ 'valuerequired' => false,
+ 'hidden' => true,
+ ],
];
if (!function_exists('simplexml_load_file')) {
diff --git a/cli/importwholecoursetomoodle.php b/cli/importwholecoursetomoodle.php
new file mode 100755
index 0000000..eeb24a3
--- /dev/null
+++ b/cli/importwholecoursetomoodle.php
@@ -0,0 +1,170 @@
+.
+
+/**
+ * Import a git repo containing questions into Moodle.
+ *
+ * @package qbank_gitsync
+ * @copyright 2023 University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace qbank_gitsync;
+define('CLI_SCRIPT', true);
+require_once('./config.php');
+require_once('../classes/curl_request.php');
+require_once('../classes/cli_helper.php');
+require_once('../classes/tidy_trait.php');
+require_once('../classes/import_repo.php');
+
+$options = [
+ [
+ 'longopt' => 'moodleinstance',
+ 'shortopt' => 'i',
+ 'description' => 'Key of Moodle instance in $moodleinstances to use. ' .
+ 'Should match end of instance URL.',
+ 'default' => $instance,
+ 'variable' => 'moodleinstance',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'rootdirectory',
+ 'shortopt' => 'r',
+ 'description' => "Directory on user's computer containing repos.",
+ 'default' => $rootdirectory,
+ 'variable' => 'rootdirectory',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'manifestpath',
+ 'shortopt' => 'f',
+ 'description' => 'Filepath of manifest file relative to root directory.',
+ 'default' => $manifestpath,
+ 'variable' => 'manifestpath',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'directory',
+ 'shortopt' => 'd',
+ 'description' => 'Directory of repo on users computer containing "top" folder, ' .
+ 'relative to root directory.',
+ 'default' => '',
+ 'variable' => 'directory',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'subdirectory',
+ 'shortopt' => 's',
+ 'description' => 'Relative subdirectory of repo to actually import.',
+ 'default' => null,
+ 'variable' => 'subdirectory',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'contextlevel',
+ 'shortopt' => 'l',
+ 'description' => 'Context from which to extract questions. Should always be course.',
+ 'default' => null,
+ 'variable' => 'contextlevel',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'coursename',
+ 'shortopt' => 'c',
+ 'description' => 'Unique course name.',
+ 'default' => null,
+ 'variable' => 'coursename',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'modulename',
+ 'shortopt' => 'm',
+ 'description' => 'Not used',
+ 'default' => null,
+ 'variable' => 'modulename',
+ 'valuerequired' => true,
+ 'hidden' => true,
+ ],
+ [
+ 'longopt' => 'coursecategory',
+ 'shortopt' => 'g',
+ 'description' => 'Not used.',
+ 'default' => null,
+ 'variable' => 'coursecategory',
+ 'valuerequired' => true,
+ 'hidden' => true,
+ ],
+ [
+ 'longopt' => 'instanceid',
+ 'shortopt' => 'n',
+ 'description' => 'Numerical id of the course.',
+ 'default' => null,
+ 'variable' => 'instanceid',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'token',
+ 'shortopt' => 't',
+ 'description' => 'Security token for webservice.',
+ 'default' => $token,
+ 'variable' => 'token',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'help',
+ 'shortopt' => 'h',
+ 'description' => '',
+ 'default' => false,
+ 'variable' => 'help',
+ 'valuerequired' => false,
+ ],
+ [
+ 'longopt' => 'usegit',
+ 'shortopt' => 'u',
+ 'description' => 'Is the repo controlled using Git?',
+ 'default' => $usegit,
+ 'variable' => 'usegit',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'ignorecat',
+ 'shortopt' => 'x',
+ 'description' => 'Regex of categories to ignore - add an extra leading / for Windows.',
+ 'default' => $ignorecat,
+ 'variable' => 'ignorecat',
+ 'valuerequired' => true,
+ ],
+];
+
+if (!function_exists('simplexml_load_file')) {
+ echo 'Please install the PHP library SimpleXML.' . "\n";
+ exit;
+}
+
+$scriptdirectory = dirname(__FILE__);
+$clihelper = new cli_helper($options);
+$arguments = $clihelper->get_arguments();
+echo "Importing a course into Moodle. Associated quiz contexts will also be imported.\n";
+echo "Structures of existing quizzes in Moodle will not be updated.\n";
+$importrepo = new import_repo($clihelper, $moodleinstances);
+$clihelper->check_for_changes($importrepo->manifestpath);
+$clihelper->backup_manifest($importrepo->manifestpath);
+$importrepo->recovery();
+$importrepo->check_question_versions();
+$clihelper->commit_hash_update($importrepo);
+$importrepo->process();
+$importrepo->update_quizzes($clihelper, $scriptdirectory);
+
diff --git a/db/services.php b/db/services.php
index 4bace1f..f3dcd3c 100644
--- a/db/services.php
+++ b/db/services.php
@@ -50,6 +50,18 @@
'type' => 'read',
'ajax' => true,
],
+ 'qbank_gitsync_export_quiz_data' => [
+ 'classname' => 'qbank_gitsync\external\export_quiz_data',
+ 'description' => 'Export quiz content and layout data',
+ 'type' => 'read',
+ 'ajax' => true,
+ ],
+ 'qbank_gitsync_import_quiz_data' => [
+ 'classname' => 'qbank_gitsync\external\import_quiz_data',
+ 'description' => 'Import quiz content and layout data',
+ 'type' => 'write',
+ 'ajax' => true,
+ ],
];
$services = [
@@ -58,6 +70,8 @@
'qbank_gitsync_import_question',
'qbank_gitsync_delete_question',
'qbank_gitsync_get_question_list',
+ 'qbank_gitsync_export_quiz_data',
+ 'qbank_gitsync_import_quiz_data',
],
'restrictedusers' => 1,
'enabled' => 1,
diff --git a/doc/createrepo.md b/doc/createrepo.md
index 2d9edfa..f059772 100644
--- a/doc/createrepo.md
+++ b/doc/createrepo.md
@@ -21,6 +21,7 @@
|g|coursecategory|Unique course category name for coursecategory context.
|q|questioncategoryid|Numerical id of subcategory to actually export.
|n|instanceid|Numerical id of the course, module of course category.
+|p|nonquizmanifestpath|Quiz export: Filepath of non-quiz manifest file relative to root directory.|
|t|token|Security token for webservice.
|h|help|
|x|ignorecat|Regex of categories to ignore - add an extra leading / for Windows.
diff --git a/doc/createwholecourserepo.md b/doc/createwholecourserepo.md
new file mode 100644
index 0000000..5a1f4d4
--- /dev/null
+++ b/doc/createwholecourserepo.md
@@ -0,0 +1,41 @@
+# Creating a repo for a course and all its quizzes
+
+## Prerequisites
+- Set up the [webserver](webservicesetup.md) on the Moodle instance.
+- Set up your [local machine](localsetup.md).
+
+## Exporting
+- From the commandline in the `cli` folder run `createwholecourserepo.php`. There are a number of options you can input. List them all with `php createwholecourserepo.php -h`. You can use -shortname or --longname on the command line followed by a space and the value.
+
+|Short|Long|Required value|
+|-|-|-|
+|i|moodleinstance|Key of Moodle instance in $moodleinstances to use. Should match end of instance URL.|
+|r|rootdirectory|Directory on user's computer containing repos.|
+|d|directory|Directory of repo on user's computer containing "top" folder, relative to root directory.|
+|s|subcategory|Relative subcategory of repo to actually export.|
+|c|coursename|Unique course name for course or module context.
+|q|questioncategoryid|Numerical id of subcategory to actually export.
+|n|instanceid|Numerical id of the course category.
+|t|token|Security token for webservice.
+|h|help|
+|x|ignorecat|Regex of categories to ignore - add an extra leading / for Windows.
+
+This is very similar to [`createrepo.php`](createrepo.md) but normally Gitsync retrieves questions within a Moodle context, returning all or a subselection of question categories with the repo directory structure matching the category structure in Moodle. Courses and quizzes are in separate contexts, however. Use `createwholecourserepo.php` to keep quizzes with their parent course in a single repo.
+
+You can use all the same command line arguments as a basic `createrepo` to narrow down the questions exported. You will need to supply either the course name or its ID. You will also need to supply the destination directory for the course - this should be a child directory of the main repo directory.
+
+Example:
+
+Assume you have correct information in config.php, i.e. the Moodle URL in `$moodleinstances`, and the webservice token stored in `$token`, the default moodle instance in `$instance` and the local root directory for your question files in `$rootdirectory`.
+
+Assume you have a course called "Scratch" in Moodle. You would like all questions from the "top" level, and all sub-categories, to become files in a sub-directory "scratch-wholecourse/scratch-course" of your local `$rootdirectory` directory. You would also like to export the questions belonging to all the course's quiz contexts and the structures of those quizzes into "scratch-wholecourse".
+
+Create and initialise the "scratch-wholecourse" directory using `git init scratch-wholecourse` then run `php createwholecourserepo.php`.
+
+`php createwholecourserepo.php -c "Scratch" -d "scratch-wholecourse/scratch-course" `
+
+Along with the "scratch-course" directory, there should be a directory created by the script for each of the quizzes with names in the format "scratch-course_quiz_sanitised-quiz-name".
+
+### On failure
+
+- If the script fails, discard changes in the repository (e.g. with the `git reset` command) and run the `php createwholecourserepo.php` again once the issue has been dealt with.
\ No newline at end of file
diff --git a/doc/exportquizstructurefrommoodle.md b/doc/exportquizstructurefrommoodle.md
new file mode 100644
index 0000000..dcce245
--- /dev/null
+++ b/doc/exportquizstructurefrommoodle.md
@@ -0,0 +1,36 @@
+# Updating a quiz repo with the latest version of a the quiz structure
+
+## Prerequisites
+- Set up the [webserver](webservicesetup.md) on the Moodle instance.
+- Set up your [local machine](localsetup.md).
+- Import a quiz repo into Moodle using `importrepotomoodle.php` or create a quiz context repo from Moodle using `createrepo.php`.
+
+## Exporting
+- From the commandline in the `cli` folder run `exportquizstructurefrommoodle.php`. There are a number of options you can input. List them all with `php exportquizstructurefrommoodle.php -h`. You can use -shortname or --longname on the command line followed by a space and the value.
+
+|Short|Long|Required value|
+|-|-|-|
+|i|moodleinstance|Key of Moodle instance in $moodleinstances to use. Should match end of instance URL.|
+|r|rootdirectory|Directory on user's computer containing repos.|
+|p|nonquizmanifestpath|Filepath of manifest file of another context relative to root directory.|
+|f|quizmanifestpath|Filepath of quiz manifest file relative to root directory.|
+|t|token|Security token for webservice.|
+|h|help|
+
+Examples:
+
+`php exportquizstructurefrommoodle.php -r "C:\question_repos" -f "quiz_1\instance2_module_stack1_quiz-1_question_manifest.json"`
+
+The manifest file should have been created by createrepo.php if the repo was initially created by exporting questions from Moodle or importrepotomoodle.php if questions were initially imported into Moodle from an existing repo.
+
+The structure of the quiz will be extracted into a file with a name in the format quiz-name_quiz.json. The file contains the layout of the quiz and relative file paths to the questions (`quizfilepath`).
+
+If the quiz contains questions from a context other than its own (most-likely the course context), you will need to include the path to the manifest of the other context (--nonquizmanifestpath). Those questions will have a relative filepath for the other repo (`nonquizfilepath`).
+
+The questions must either be in the quiz context or the context of the nonquizmanifest. If you have questions in a third location, the export will fail.
+
+If you update the quiz structure in Moodle you can export the structure again and the structure file in the repo will be updated.
+
+### On failure
+
+- If the script fails, discard changes in the repository (e.g. with the `git reset` command) and run the `php exportquizstructurefrommoodle.php` again once the issue has been dealt with.
\ No newline at end of file
diff --git a/doc/exportrepofrommoodle.md b/doc/exportrepofrommoodle.md
index 8336194..607f484 100644
--- a/doc/exportrepofrommoodle.md
+++ b/doc/exportrepofrommoodle.md
@@ -12,6 +12,7 @@
|-|-|-|
|i|moodleinstance|Key of Moodle instance in $moodleinstances to use. Should match end of instance URL.|
|r|rootdirectory|Directory on user's computer containing repos.|
+|p|nonquizmanifestpath|Quiz export: Filepath of non-quiz manifest file relative to root directory.|
|f|manifestpath|Filepath of manifest file relative to root directory.|
|s|subcategory|Relative subcategory of repo to actually export.|
|q|questioncategoryid|Numerical id of subcategory to actually export.
diff --git a/doc/exportwholecoursefrommoodle.md b/doc/exportwholecoursefrommoodle.md
new file mode 100644
index 0000000..8d2a693
--- /dev/null
+++ b/doc/exportwholecoursefrommoodle.md
@@ -0,0 +1,36 @@
+# Creating a repo for a course and all its quizzes from Moodle
+
+## Prerequisites
+- Set up the [webserver](webservicesetup.md) on the Moodle instance.
+- Set up your [local machine](localsetup.md).
+- Import a repo into Moodle using `importwholecoursetomoodle.php` or create a repo from Moodle using `createwholecourserepo.php`.
+
+## Exporting
+- From the commandline in the `cli` folder run `exportwholecoursefrommoodle.php`. There are a number of options you can input. List them all with `php exportwholecoursefrommoodle.php -h`. You can use -shortname or --longname on the command line followed by a space and the value.
+
+|Short|Long|Required value|
+|-|-|-|
+|i|moodleinstance|Key of Moodle instance in $moodleinstances to use. Should match end of instance URL.|
+|r|rootdirectory|Directory on user's computer containing repos.|
+|f|manifestpath|Filepath of manifest file relative to root directory.|
+|s|subcategory|Relative subcategory of repo to actually export.|
+|q|questioncategoryid|Numerical id of subcategory to actually export.
+|t|token|Security token for webservice.|
+|h|help|
+|x|ignorecat|Regex of categories to ignore - add an extra leading / for Windows.
+
+Examples:
+
+This is very similar to [`exportrepofrommoodle.php`](exportrepofrommoodle.md) but normally Gitsync retrieves questions within a Moodle context, returning all or a subselection of question categories with the repo directory structure matching the category structure in Moodle. Courses and quizzes are in separate contexts, however. `exportwholecoursefrommoodle.php` keep quizzes with their parent course in a single repo.
+
+`php exportwholecoursefrommoodle.php -f "scratch-wholecourse\scratch-course\moodle1_course_course-1_question_manifest.json"`
+
+The manifest file should have been created by `createwholecourserepo.php` if the repo was initially created by exporting questions from Moodle or `importwholecoursetomoodle.php` if questions were initially imported into Moodle from an existing repo. When creating a whole course manifest, additional information is stored in the manifest linking the local directories of the quizzes to the instances of the quizzes in Moodle.
+
+If you only want to export a certain question category (and its subcategories) within the course you will need to supply the category's name relative to the 'top' category e.g. 'category 1/subcategory 2'. Alternatively you can supply the questioncategoryid which is available in the URL ('&category=XXX') when browsing the category in the question bank.
+
+Export will only be possible if there are no uncommitted changes in the repo. After the export, the manifest will be tidied to remove any entries where the question is no longer in the Moodle. (The manifest is the link between your repo and Moodle and you can't link to something which isn't there.)
+
+### On failure
+
+- If the script fails, discard changes in the repository (e.g. with the `git reset` command) and run the `php exportwholecoursefrommoodle.php` again once the issue has been dealt with. All questions will be exported afresh.
\ No newline at end of file
diff --git a/doc/importquizstructuretomoodle.md b/doc/importquizstructuretomoodle.md
new file mode 100644
index 0000000..5347a40
--- /dev/null
+++ b/doc/importquizstructuretomoodle.md
@@ -0,0 +1,35 @@
+# Importing a quiz structure into Moodle
+
+## Prerequisites
+- Set up the [webserver](webservicesetup.md) on the Moodle instance.
+- Set up your [local machine](localsetup.md).
+- Create a blank quiz in Moodle and then import any questions using `importrepotomoodle.php`.
+
+## Importing
+- From the commandline in the `cli` folder run `importquizstructuretomoodle.php`. There are a number of options you can input. List them all with `php importquizstructuretomoodle.php -h`. You can use -shortname or --longname on the command line followed by a space and the value.
+
+|Short|Long|Required value|
+|-|-|-|
+|i|moodleinstance|Key of Moodle instance in $moodleinstances to use. Should match end of instance URL.|
+|r|rootdirectory|Directory on user's computer containing repos.|
+|p|nonquizmanifestpath|Filepath of manifest file of another context relative to root directory.|
+|f|quizmanifestpath|Filepath of quiz manifest file relative to root directory.|
+|a|quizdatapath|Filepath of quiz data file relative to root directory.
+|t|token|Security token for webservice.|
+|h|help|
+
+Examples:
+
+`php importquizstructuretomoodle.php -r "C:\question_repos" -f "quiz_1\instance2_module_stack1_quiz-1_question_manifest.json"`
+
+The manifest files should have been created by createrepo.php if the repo was initially created by exporting questions from Moodle or importrepotomoodle.php if questions were initially imported into Moodle from an existing repo.
+
+The quizdata file contains the structure of the quiz and the locations of the questions, the quizmanifest and nonquizmanifest link those locations to matching questions in Moodle. You must supply at least one of `quizmanifestpath` and `quizdatapath`. If you supply just one, Gitsync will calculate the location and name of the other based and its normal naming convention. If the quiz contains questions from a context other than its own (most-likely the course context), you will need to include the path to the manifest of the other context (`--nonquizmanifestpath`). In the quizdata file, those questions will have a relative filepath for the other repo (`nonquizfilepath`).
+
+If you have multiple quiz structure files, you will need to supply both `quizmanifestpath` and `quizdatapath`. (This is if you're using the same questions in different quizzes in Moodle but only want to have one question repo.)
+
+If you update the quiz structure in Moodle you can export the structure again and the structure file in the repo will be updated. If you update the quiz structure in the repo and want to update Moodle, create a new quiz and start again.
+
+### On failure
+
+- If the script fails, delete the quiz in Moodle and start again.
\ No newline at end of file
diff --git a/doc/importquiztomoodle.md b/doc/importquiztomoodle.md
new file mode 100644
index 0000000..8e8344f
--- /dev/null
+++ b/doc/importquiztomoodle.md
@@ -0,0 +1,53 @@
+# Importing a quiz into Moodle including both questions and quiz structure
+
+## Prerequisites
+- Set up the [webserver](webservicesetup.md) on the Moodle instance.
+- Set up your [local machine](localsetup.md).
+
+## Importing
+This script is only for the initial import of a quiz into Moodle including both questions and quiz structure. To update the questions later, use `importrepotomoodle.php`.
+- Create a repository of questions with a folder hierarchy akin to question categories i.e. top/category/subcategory or use `createrepo.php` to create a repo from existing questions on Moodle.
+- Each category below top should have a `gitsync_category.xml` file containing a 'category question' with the details of he category. See the `testrepo` folder in this repository for an example.
+- If you have created a repository (or copied one) make sure you have a `.gitignore` file and it ignores gitsync's manifest and temporary files.
+`touch .gitignore`
+`printf '%s\n' '**/*_question_manifest.json' '**/*_manifest_update.tmp' >> .gitignore`
+Commit this update.
+(This will be done automatically when using `createrepo.php`.)
+- You will also need a quiz structure file.
+- From the commandline in the `cli` folder run `importquiztomoodle.php`. There are a number of options you can input. List them all with `php importquiztomoodle.php -h`. You can use -shortname or --longname on the command line followed by a space and the value.
+
+|Short|Long|Required value|
+|-|-|-|
+|i|moodleinstance|Key of Moodle instance in moodleinstances to use. Should match end of instance URL.|
+|r|rootdirectory|Directory on user's computer containing repos.|
+|p|nonquizmanifestpath|Filepath of non-quiz manifest file relative to root directory.|
+|a|quizdatapath|Filepath of quiz data file relative to root directory.
+|d|directory|Directory of repo on users computer containing "top" folder, relative to root directory.|
+|s|subdirectory|Relative subdirectory of repo to actually import.|
+|l|contextlevel|Context in which to place questions. Set to system, coursecategory, course or module
+|c|coursename|Unique course name for course or module context.
+|n|instanceid|Numerical id of the course, module of course category.
+|t|token|Security token for webservice.
+|h|help|
+|x|ignorecat|Regex of categories to ignore - add an extra leading / for Windows.
+
+Examples:
+
+`php importquiztomoodle.php -d 'quizexport' -n 2`
+
+This will import the quiz in the `quizexport` directory to the course with `id` 2. The first quiz structure file to be found in that directory will be used. If you have multiple structure files in your repo you will need to specify `--quizdatapath`.
+
+`php importquiztomoodle.php -d 'quizexport' -f 'course1/instance2_course_course-1_question_manifest.json'`
+
+Import the quiz into the course specified in the manifest file. The quiz structure can contain questions from the manifest file and the quiz context.
+
+## Deletion
+
+A check will be run to see if there are questions in the context/category in the manifest that do not have a file in the repo. These will be listed.
+
+A check will be run to see if there are questions in the context in Moodle that are not in the manifest. These will be listed.
+
+To delete the questions from Moodle and tidy the manifest, run deletefrommoodle.php
+
+### On failure
+- If the script fails, it can be safely run again once the issue has been dealt with. Pending updates to the manifest file are stored in a temporary file in the root directory and these will be picked up at the start of the new run, avoiding multiple new versions of a question being created in Moodle.
diff --git a/doc/importwholecoursetomoodle.md b/doc/importwholecoursetomoodle.md
new file mode 100644
index 0000000..35cb802
--- /dev/null
+++ b/doc/importwholecoursetomoodle.md
@@ -0,0 +1,71 @@
+# Importing a course and all its quizzes into Moodle
+
+## Prerequisites
+- Set up the [webserver](webservicesetup.md) on the Moodle instance.
+- Set up your [local machine](localsetup.md).
+
+## Importing
+This is very similar to `importrepotomoodle.php` but normally Gitsync retrieves questions within a Moodle context, returning all or a subselection of question categories with the repo directory structure matching the category structure in Moodle. Courses and quizzes are in separate contexts, however. `importwholecoursetomoodle.php` and the matching repo creation and export tools keep quizzes with their parent course in a single repo.
+
+- Create a repository where the top-most directory contains question directories for the course and each of its quizzes. Within each of these context directories, store your questions with a folder hierarchy akin to question categories i.e. top/category/subcategory. Alternativelty, use `createwholecourserepo.php` to create a repo from existing questions on Moodle.
+- Each category below top should have a `gitsync_category.xml` file containing a 'category question' with the details of he category. See the `testrepoparent` folder in this repository for an example.
+- If you have created a repository (or copied one) make sure you have a `.gitignore` file and it ignores gitsync's manifest and temporary files.
+`touch .gitignore`
+`printf '%s\n' '**/*_question_manifest.json' '**/*_manifest_update.tmp' >> .gitignore`
+Commit this update.
+(This will be done automatically when using `createrepo.php`.)
+- From the commandline in the `cli` folder run `importwholecoursetomoodle.php`. There are a number of options you can input. List them all with `php importwholecoursetomoodle.php -h`. You can use -shortname or --longname on the command line followed by a space and the value.
+
+|Short|Long|Required value|
+|-|-|-|
+|i|moodleinstance|Key of Moodle instance in moodleinstances to use. Should match end of instance URL.|
+|r|rootdirectory|Directory on user's computer containing repos.|
+|f|manifestpath|Filepath of manifest file relative to root directory.|
+|d|directory|Directory of repo on users computer containing "top" folder, relative to root directory.|
+|s|subdirectory|Relative subdirectory of repo to actually import.|
+|c|coursename|Unique course name.
+|n|instanceid|Numerical id of the course.
+|t|token|Security token for webservice.
+|h|help|
+|x|ignorecat|Regex of categories to ignore - add an extra leading / for Windows.
+
+Examples:
+
+Example 1:
+To update the questions in Moodle from the repo, point the script to your course manifest file. (Quiz structures will not be updated.)
+`php importrepotomoodle.php -f "scratch-wholecourse/scratch-course"`
+
+Example 2:
+To import course context questions, quiz context questions and quiz structures into a course for the first time, you will need to point the script to the course directory and the course to import into (using either coursename -c or id -n). You will also need to specify context level but this will always be course (`-l "course"`).
+`php importwholecoursetomoodle.php -r "C:\question_repos" -d "scratch-wholecourse/scratch-course" -l "course" -c "Course 2"`
+
+## Scenario 1: Importing questions into Moodle from an existing repo
+
+e.g. You have exported questions from one Moodle instance to create the repo and you want to import them into a different instance
+
+Importing will create a manifest file specific to the Moodle instance and context in each of the context directories of the repo. This links files in the repo to specific questionbankentries in the Moodle instance.
+
+You will need to specify the course you want to import the questions and quizzes into. You will need to supply the course name. Alternatively, the instanceid of the course can be supplied. This is available from the URL while browsing the course in Moodle ('?id=XXX').
+
+If you only want to import a certain question category (and its subcategories) within the course you will need to supply the path of the corresponding folder within your repo relative to the 'top' category e.g. 'category-1/subcategory-2'.
+
+## Scenario 2: Re-importing questions into Moodle when the manifest files already exist
+
+You should specify the course manifest file path and context will be extracted from that. You can still enter a subdirectory to only re-import some of the questions.
+
+Import will only be possible if there are not updates to the questions in Moodle which haven't been exported.
+
+Only questions that have changed in the repo since the last import will be imported to Moodle (to avoid creating a new version in Moodle when nothing has changed).
+
+Quiz structure will not be updated.
+
+## Deletion
+
+A check will be run to see if there are questions in the manifest for each context that do not have a file in the repo. These will be listed.
+
+A check will be run to see if there are questions in the course and quizzes in Moodle that are not in the manifest. These will be listed.
+
+To delete the questions from Moodle and tidy the manifest, run `deletefrommoodle.php` for the course or an individual quiz.
+
+### On failure
+- If the script fails, it can be safely run again once the issue has been dealt with. Pending updates to the manifest file are stored in a temporary file in the root directory and these will be picked up at the start of the new run, avoiding multiple new versions of a question being created in Moodle.
diff --git a/doc/usinggit.md b/doc/usinggit.md
index 22ac505..8c24582 100644
--- a/doc/usinggit.md
+++ b/doc/usinggit.md
@@ -4,9 +4,17 @@ It is recommended you at least skim the whole of this document before attempting
- Creating a repo - [createrepo.php](createrepo.md)
- Importing questions to Moodle - [importrepotomoodle.php](importrepotomoodle.md)
- Exporting questions from Moodle - [exportrepofrommoodle.php](exportrepofrommoodle.md)
+- Importing quizzes to Moodle - [importquiztomoodle.php](importquiztomoodle.md)
For detailed information on what happens at each stage of the process, see [Process Details](processdetails.md).
+If you want to track a course and all its quizzes in a single repo, you will need to use different scripts that function in a similar way to the single context scripts:
+- Creating a repo - [createwholecourserepo.php](createwholecourserepo.md)
+- Importing questions to Moodle - [importwholecoursetomoodle.php](importwholecoursetomoodle.md)
+- Exporting questions from Moodle - [exportwholecoursefrommoodle.php](exportwholecoursefrommoodle.md)
+
+(See [Quizzes](#quizzes).)
+
## Maintaining a one-to-one link between a Moodle instance and a Git repo
### Creating a Git repo from questions in Moodle
@@ -139,3 +147,76 @@ An alternative to having the manifest in the repo is a one off copy of the manif
If you have multiple repos and you delete questions in one because you don't want them to appear in a particular Moodle instance then you will need to take care when merging to and from the master repository. The questions need to stay in master and not be re-introduced to your other branch. Run the merge withou committing the result and then go into the repository and revert the deletions/additions as necessary before committing.
`git merge --no-commit --no-ff source_branch_name`
+
+## Quizzes
+
+Quiz contexts can be exported and imported in the same way as other contexts but it is more useful to also include the quiz structure. `createrepo.php` and `exportrepofrommoodle.php` will attempt to export the quiz structure when the contextlevel is set to `module`. This may not work as a quiz can contain questions from multiple contexts. Gitsync can currently handle quizzes that have questions in 2 contexts but this requires the user to specify a manifest filepath for the nonquiz context (either in the initial export/create or later using `exportquizstructurefrommoodle.php`). In most cases, this will be a course manifest file. The quiz structure is stored in a file `quiz-name_quiz.json` at the top of the repo. Unlike the manifest, the quiz structure is independent of Moodle and should be tracked by Git. It holds basic quiz information such as headings and the relative location of questions within the directory of the quiz context and of the secondary context.
+
+When importing a quiz into a different course, `importquiztomoodle.php` should be used. This creates a quiz, imports the quiz context questions and then populates the structure of the quiz. A quiz structure should only be imported once. Update it manually within Moodle. Using `importrepotomoodle.php` for a `module` context will simply update the questions in Moodle.
+
+### Whole course
+
+Normally Gitsync retrieves questions within a Moodle context, returning all or a subselection of question categories, with the repo directory structure matching the category structure in Moodle. Courses and quizzes are in separate contexts, however. There are scripts specifically to export and import a 'whole course' to and from a single Git repo with the course and quizzes in sibling directories. The course and quizzes still have separate manifests so can also be imported/exported individually if required. The manifest for the course links the quiz instance ids in Moodle to the quiz directories in the repo.
+
+## Quiz examples
+
+### To handle a quiz that only uses questions from its own context
+- `createrepo.php` will export the quiz structure along with the questions into the new repo.
+- `exportrepofrommoodle.php` will update the quiz structure along with the questions in the repo.
+- Use `importquiztomoodle.php` for initial import of questions and structure into a Moodle instance. (The quiz will be created within a specified course.)
+- Use `importrepotomoodle.php` to update questions in Moodle.
+- Manually update structure in Moodle.
+
+Example:
+Initialise repo for quiz:
+`git init quizexport`
+Export quiz with `cmid=50` into directory `quizexport` (assuming rootdirectory, token, moodleinstance, usegit, etc, all set in your config file.):
+`php createrepo.php -d 'quizexport' -l module -n 50`
+Export questions/structures again after updates in Moodle:
+`php exportrepofrommoodle.php -f 'quizexport/instance1_module_course-1_quiz-only_question_manifest.json'`
+Import questions again after updates in the repo:
+`php importrepotomoodle.php -f 'quizexport/instance1_module_course-1_quiz-only_question_manifest.json'`
+Create quiz and import into course with `id=2`:
+`php importquiztomoodle.php -d 'quizexport' -n 2`
+
+### To handle a quiz that uses questions from another context
+- `createrepo.php` will export the questions into the new repo but will not export the structure and will list the questions from other contexts unless you also supply a manifest file containing the additional questions.
+- `exportrepofrommoodle.php` will update the questions in the repo (and the quiz structure if you supply the manifest file again).
+- Use `exportquizstructurefrommoodle.php` to export just the quiz structure from Moodle if needed.
+- Use `importquiztomoodle.php` for initial import of questions and structure into a Moodle instance. (The quiz will be created within a specified course.)
+- Use `importrepotomoodle.php` to update questions in Moodle.
+- Manually update structure in Moodle.
+
+Example:
+Initialise repo for quiz:
+`git init quizexport`
+Export quiz with `cmid=50` into directory `quizexport` (assuming rootdirectory, token, moodleinstance, usegit, etc, all set in your config file.):
+`php createrepo.php -d 'quizexport' -l module -n 49 -p 'course1/instance1_course_course-1_question_manifest.json'`
+Export questions/structures again after updates in Moodle:
+`php exportrepofrommoodle.php -f 'quizexport/instance1_module_course-1_mixed-quiz_question_manifest.json' -p 'course1/instance1_course_course-1_question_manifest.json'`
+Import questions again after updates in the repo:
+`php importrepotomoodle.php -f 'quizexport/instance1_module_course-1_mixed-quiz_question_manifest.json'`
+Create quiz and import into course with `id=2`:
+`php importquiztomoodle.php -d 'quizexport' -n 2 -p 'course1/instance1_course_course-1_question_manifest.json'`
+
+### To handle a course and its quizzes in a single repo
+- `createwholecourserepo.php` will export a course context and associated quizzes in sibling directories. As long as the quizzes only use questions from the course and their own context, the quiz structures will be exported.
+- `exportwholecoursefrommoodle.php` will update the questions and quiz structures in the repo.
+- Use `importwholecoursetomoodle.php` for initial import of course questions and quiz structures into Moodle. Also use it to update questions in Moodle. (Quizzes will be created within the course.)
+- Manually update structure in Moodle.
+
+Example:
+Initialise a repo for course and quizzes together:
+`git init course1whole`
+Export course with `id=3` into directory `course1` (assuming rootdirectory, token, moodleinstance, usegit, etc, all set in your config file.):
+`php createwholecourserepo.php -n 3 -d 'course1whole/course1'`
+Export questions/structures again after updates in Moodle:
+`php exportwholecoursefrommoodle.php -f 'course1whole/course1/instance1_course_course-1_question_manifest.json'`
+Import questions again after updates in the repo:
+`php importwholecoursetomoodle.php -f 'course1whole/course1/instance1_course_course-1_question_manifest.json'`
+Import course questions and quizzes into course with `id=2`:
+`php importwholecoursetomoodle.php -d 'course1whole/course1' -l 'course' -n 2`
+
+You can use the normal filters like subcategory and ignorecategory e.g.:
+`php createwholecourserepo.php -n 3 -d 'course1whole/course1' -x '/subcat/'`
+`php importwholecoursetomoodle.php -f 'course1whole/course1/instance1_course_course-1_question_manifest.json' -x '/subcat/'`
\ No newline at end of file
diff --git a/lang/en/qbank_gitsync.php b/lang/en/qbank_gitsync.php
index 2455672..e7df502 100644
--- a/lang/en/qbank_gitsync.php
+++ b/lang/en/qbank_gitsync.php
@@ -24,16 +24,16 @@
defined('MOODLE_INTERNAL') || die();
-$string['pluginname'] = 'Gitsync';
+$string['categoryerror'] = 'Problem with question category: {$a}';
+$string['categoryerrornew'] = 'Problem with question category: {$a}. If the course or module is new, please open the question bank in Moodle to initialise it and try Gitsync again.';
+$string['categorymismatcherror'] = 'Problem with question category: {$a}. The category is not in the supplied context.';
+$string['contexterror'] = 'The context level is invalid: {$a}';
+$string['exporterror'] = 'Could not export question id: {$a}';
+$string['gitsync:deletequestions'] = 'Delete';
$string['gitsync:exportquestions'] = 'Export';
$string['gitsync:importquestions'] = 'Import';
-$string['gitsync:deletequestions'] = 'Delete';
$string['gitsync:listquestions'] = 'List';
-$string['exporterror'] = 'Could not export question id: {$a}';
$string['importerror'] = 'Could not import question from file: {$a}';
$string['importversionerror'] = 'Could not import question : {$a->name} Current version in Moodle is {$a->currentversion}. Last imported version is {$a->importedversion}. Last exported version is {$a->exportedversion}. You need to export the question.';
$string['noquestionerror'] = 'Question does not exist. Questionbankentryid: {$a}';
-$string['contexterror'] = 'The context level is invalid: {$a}';
-$string['categoryerror'] = 'Problem with question category: {$a}';
-$string['categoryerrornew'] = 'Problem with question category: {$a}. If the course is new, please open the question bank in Moodle to initialise it and try Gitsync again.';
-$string['categorymismatcherror'] = 'Problem with question category: {$a}. The category is not in the supplied context.';
+$string['pluginname'] = 'Gitsync';
diff --git a/lib.php b/lib.php
index a2885d3..eb5afce 100644
--- a/lib.php
+++ b/lib.php
@@ -58,7 +58,7 @@ function split_category_path(?string $path): array {
* @return object
*/
function get_context(int $contextlevel, ?string $categoryname = null,
- ?string $coursename = null, ?string $modulename = null, ?string $instanceid = null):object {
+ ?string $coursename = null, ?string $modulename = null, ?string $instanceid = null): object {
global $DB;
if ($instanceid === '') {
$instanceid = null;
@@ -66,6 +66,7 @@ function get_context(int $contextlevel, ?string $categoryname = null,
$result = new \stdClass();
$result->categoryname = null;
$result->coursename = null;
+ $result->courseid = null;
$result->modulename = null;
$result->instanceid = null;
switch ($contextlevel) {
@@ -95,12 +96,13 @@ function get_context(int $contextlevel, ?string $categoryname = null,
$result->contextlevel = 'course';
$result->context = context_course::instance($instanceid);
$result->instanceid = $instanceid;
+ $result->courseid = $instanceid;
return $result;
case \CONTEXT_MODULE:
if (is_null($instanceid)) {
// Assuming here that the module is a quiz.
- $instanceid = $DB->get_field_sql("
- SELECT cm.id
+ $instancedata = $DB->get_record_sql("
+ SELECT cm.id as cmid, q.id as quizid, c.id as courseid
FROM {course_modules} cm
JOIN {quiz} q ON q.course = cm.course AND q.id = cm.instance
JOIN {course} c ON c.id = cm.course
@@ -109,11 +111,14 @@ function get_context(int $contextlevel, ?string $categoryname = null,
AND q.name = :quizname
AND m.name = 'quiz'",
['coursename' => $coursename, 'quizname' => $modulename], $strictness = MUST_EXIST);
+ $instanceid = $instancedata->cmid;
$result->coursename = $coursename;
+ $result->courseid = $instancedata->courseid;
$result->modulename = $modulename;
+ $result->quizid = $instancedata->quizid;
} else {
$instancedata = $DB->get_record_sql("
- SELECT c.fullname as coursename, q.name as modulename
+ SELECT c.fullname as coursename, q.name as modulename, q.id as quizid
FROM {course_modules} cm
JOIN {quiz} q ON q.course = cm.course AND q.id = cm.instance
JOIN {course} c ON c.id = cm.course
@@ -123,6 +128,7 @@ function get_context(int $contextlevel, ?string $categoryname = null,
['instanceid' => $instanceid], $strictness = MUST_EXIST);
$result->coursename = $instancedata->coursename;
$result->modulename = $instancedata->modulename;
+ $result->quizid = $instancedata->quizid;
}
$result->contextlevel = 'module';
$result->context = context_module::instance($instanceid);
@@ -139,7 +145,7 @@ function get_context(int $contextlevel, ?string $categoryname = null,
* @param string $questionbankentryid
* @return stdClass Contains properties of question such as version and context
*/
-function get_question_data(string $questionbankentryid):stdClass {
+function get_question_data(string $questionbankentryid): stdClass {
global $DB;
$questiondata = $DB->get_record_sql("
SELECT qc.contextid as contextid, c.contextlevel as contextlevel,
@@ -164,7 +170,7 @@ function get_question_data(string $questionbankentryid):stdClass {
* @param string $questionbankentryid
* @return stdClass Contains properties of question such as version and context
*/
-function get_minimal_question_data(string $questionbankentryid):stdClass {
+function get_minimal_question_data(string $questionbankentryid): stdClass {
global $DB;
$questiondata = $DB->get_record_sql("
SELECT q.id as questionid, q.name as name, qv.version as version, qv.status as status
diff --git a/testrepo/fakeexport_system_question_manifest.json b/testrepoparent/testrepo/fakeexport_system_question_manifest.json
similarity index 94%
rename from testrepo/fakeexport_system_question_manifest.json
rename to testrepoparent/testrepo/fakeexport_system_question_manifest.json
index 191eb78..f777673 100644
--- a/testrepo/fakeexport_system_question_manifest.json
+++ b/testrepoparent/testrepo/fakeexport_system_question_manifest.json
@@ -4,10 +4,10 @@
"coursename": "",
"modulename": "",
"coursecategory": "",
- "qcategoryname": "/top",
"instanceid": "",
"defaultsubdirectory": "top\/cat-2",
- "defaultsubcategoryid": 5
+ "defaultsubcategoryid": 5,
+ "moodleurl": "fakeurl.com"
},
"questions": [
{
diff --git a/testrepoparent/testrepo/fakeexportquiz_course_course-1_question_manifest.json b/testrepoparent/testrepo/fakeexportquiz_course_course-1_question_manifest.json
new file mode 100644
index 0000000..f593bce
--- /dev/null
+++ b/testrepoparent/testrepo/fakeexportquiz_course_course-1_question_manifest.json
@@ -0,0 +1,43 @@
+{
+ "context": {
+ "contextlevel": 50,
+ "coursename": "",
+ "modulename": "",
+ "coursecategory": "",
+ "instanceid": "",
+ "defaultsubdirectory": "top\/cat-2",
+ "defaultsubcategoryid": 5,
+ "moodleurl": "fakeurl.com"
+ },
+ "questions": [
+ {
+ "questionbankentryid": 35001,
+ "filepath": "\/top\/cat-1\/First-Question.xml",
+ "importedversion": "1",
+ "exportedversion": "1",
+ "format": "xml"
+ },
+ {
+ "questionbankentryid": 35002,
+ "filepath": "\/top\/cat-2\/subcat-2_1\/Third-Question.xml",
+ "importedversion": "6",
+ "exportedversion": "7",
+ "format": "xml"
+ },
+ {
+ "questionbankentryid": 35004,
+ "filepath": "\/top\/cat-2\/subcat-2_1\/Fourth-Question.xml",
+ "importedversion": "1",
+ "exportedversion": "1",
+ "currentcommit": "35004test",
+ "format": "xml"
+ },
+ {
+ "questionbankentryid": 35003,
+ "filepath": "\/top\/cat-2\/Second-Question.xml",
+ "importedversion": "1",
+ "exportedversion": "1",
+ "format": "xml"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/testrepo/fakeignore_system_question_manifest.json b/testrepoparent/testrepo/fakeignore_system_question_manifest.json
similarity index 93%
rename from testrepo/fakeignore_system_question_manifest.json
rename to testrepoparent/testrepo/fakeignore_system_question_manifest.json
index c3786dc..b4cc7b8 100644
--- a/testrepo/fakeignore_system_question_manifest.json
+++ b/testrepoparent/testrepo/fakeignore_system_question_manifest.json
@@ -4,11 +4,11 @@
"coursename": "",
"modulename": "",
"coursecategory": "",
- "qcategoryname": "/top",
"instanceid": "",
"defaultsubdirectory": "top\/cat-2",
"defaultsubcategoryid": 5,
- "defaultignorecat": "/subcat 2_1/"
+ "defaultignorecat": "/subcat 2_1/",
+ "moodleurl": "fakeurl.com"
},
"questions": [
{
diff --git a/testrepoparent/testrepo/fakeimport_system_question_manifest.json b/testrepoparent/testrepo/fakeimport_system_question_manifest.json
new file mode 100644
index 0000000..f777673
--- /dev/null
+++ b/testrepoparent/testrepo/fakeimport_system_question_manifest.json
@@ -0,0 +1,43 @@
+{
+ "context": {
+ "contextlevel": 10,
+ "coursename": "",
+ "modulename": "",
+ "coursecategory": "",
+ "instanceid": "",
+ "defaultsubdirectory": "top\/cat-2",
+ "defaultsubcategoryid": 5,
+ "moodleurl": "fakeurl.com"
+ },
+ "questions": [
+ {
+ "questionbankentryid": 35001,
+ "filepath": "\/top\/cat-1\/First-Question.xml",
+ "importedversion": "1",
+ "exportedversion": "1",
+ "format": "xml"
+ },
+ {
+ "questionbankentryid": 35002,
+ "filepath": "\/top\/cat-2\/subcat-2_1\/Third-Question.xml",
+ "importedversion": "6",
+ "exportedversion": "7",
+ "format": "xml"
+ },
+ {
+ "questionbankentryid": 35004,
+ "filepath": "\/top\/cat-2\/subcat-2_1\/Fourth-Question.xml",
+ "importedversion": "1",
+ "exportedversion": "1",
+ "currentcommit": "35004test",
+ "format": "xml"
+ },
+ {
+ "questionbankentryid": 35003,
+ "filepath": "\/top\/cat-2\/Second-Question.xml",
+ "importedversion": "1",
+ "exportedversion": "1",
+ "format": "xml"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/testrepoparent/testrepo/fakeimportquiz_course_course-1_question_manifest.json b/testrepoparent/testrepo/fakeimportquiz_course_course-1_question_manifest.json
new file mode 100644
index 0000000..0e6288c
--- /dev/null
+++ b/testrepoparent/testrepo/fakeimportquiz_course_course-1_question_manifest.json
@@ -0,0 +1,43 @@
+{
+ "context": {
+ "contextlevel": 50,
+ "coursename": "",
+ "modulename": "",
+ "coursecategory": "",
+ "instanceid": "5",
+ "defaultsubdirectory": "top\/cat-2",
+ "defaultsubcategoryid": 5,
+ "moodleurl": "fakeurl.com"
+ },
+ "questions": [
+ {
+ "questionbankentryid": "35001",
+ "filepath": "\/top\/cat-1\/First-Question.xml",
+ "importedversion": "1",
+ "exportedversion": "1",
+ "format": "xml"
+ },
+ {
+ "questionbankentryid": "35002",
+ "filepath": "\/top\/cat-2\/subcat-2_1\/Third-Question.xml",
+ "importedversion": "6",
+ "exportedversion": "7",
+ "format": "xml"
+ },
+ {
+ "questionbankentryid": "35004",
+ "filepath": "\/top\/cat-2\/subcat-2_1\/Fourth-Question.xml",
+ "importedversion": "1",
+ "exportedversion": "1",
+ "currentcommit": "35004test",
+ "format": "xml"
+ },
+ {
+ "questionbankentryid": "35003",
+ "filepath": "\/top\/cat-2\/Second-Question.xml",
+ "importedversion": "1",
+ "exportedversion": "1",
+ "format": "xml"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/testrepo/top/cat-1/First-Question.xml b/testrepoparent/testrepo/top/cat-1/First-Question.xml
similarity index 100%
rename from testrepo/top/cat-1/First-Question.xml
rename to testrepoparent/testrepo/top/cat-1/First-Question.xml
diff --git a/testrepo/top/cat-1/gitsync_category.xml b/testrepoparent/testrepo/top/cat-1/gitsync_category.xml
similarity index 100%
rename from testrepo/top/cat-1/gitsync_category.xml
rename to testrepoparent/testrepo/top/cat-1/gitsync_category.xml
diff --git a/testrepo/top/cat-2/Second-Question.xml b/testrepoparent/testrepo/top/cat-2/Second-Question.xml
similarity index 100%
rename from testrepo/top/cat-2/Second-Question.xml
rename to testrepoparent/testrepo/top/cat-2/Second-Question.xml
diff --git a/testrepo/top/cat-2/gitsync_category.xml b/testrepoparent/testrepo/top/cat-2/gitsync_category.xml
similarity index 100%
rename from testrepo/top/cat-2/gitsync_category.xml
rename to testrepoparent/testrepo/top/cat-2/gitsync_category.xml
diff --git a/testrepo/top/cat-2/subcat-2_1/Fourth-Question.xml b/testrepoparent/testrepo/top/cat-2/subcat-2_1/Fourth-Question.xml
similarity index 100%
rename from testrepo/top/cat-2/subcat-2_1/Fourth-Question.xml
rename to testrepoparent/testrepo/top/cat-2/subcat-2_1/Fourth-Question.xml
diff --git a/testrepo/top/cat-2/subcat-2_1/Third-Question.xml b/testrepoparent/testrepo/top/cat-2/subcat-2_1/Third-Question.xml
similarity index 100%
rename from testrepo/top/cat-2/subcat-2_1/Third-Question.xml
rename to testrepoparent/testrepo/top/cat-2/subcat-2_1/Third-Question.xml
diff --git a/testrepo/top/cat-2/subcat-2_1/gitsync_category.xml b/testrepoparent/testrepo/top/cat-2/subcat-2_1/gitsync_category.xml
similarity index 100%
rename from testrepo/top/cat-2/subcat-2_1/gitsync_category.xml
rename to testrepoparent/testrepo/top/cat-2/subcat-2_1/gitsync_category.xml
diff --git a/testrepoparent/testrepo_quiz_quiz-1/fakeexportquiz_module_course-1_quiz-1_question_manifest.json b/testrepoparent/testrepo_quiz_quiz-1/fakeexportquiz_module_course-1_quiz-1_question_manifest.json
new file mode 100644
index 0000000..62bcf17
--- /dev/null
+++ b/testrepoparent/testrepo_quiz_quiz-1/fakeexportquiz_module_course-1_quiz-1_question_manifest.json
@@ -0,0 +1,28 @@
+{
+ "context": {
+ "contextlevel": 70,
+ "coursename": "",
+ "modulename": "",
+ "coursecategory": "",
+ "instanceid": "",
+ "defaultsubdirectory": "top",
+ "defaultsubcategoryid": 5,
+ "moodleurl": "fakeurl.com"
+ },
+ "questions": [
+ {
+ "questionbankentryid":"36001",
+ "filepath": "\/top\/Quiz-Question.xml",
+ "importedversion": "1",
+ "exportedversion": "1",
+ "format": "xml"
+ },
+ {
+ "questionbankentryid":"36002",
+ "filepath": "\/top\/quiz-cat\/Quiz-Question-2.xml",
+ "importedversion": "1",
+ "exportedversion": "1",
+ "format": "xml"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/testrepoparent/testrepo_quiz_quiz-1/fakeimportquiz_module_course-1_quiz-1_question_manifest.json b/testrepoparent/testrepo_quiz_quiz-1/fakeimportquiz_module_course-1_quiz-1_question_manifest.json
new file mode 100644
index 0000000..4dd58bc
--- /dev/null
+++ b/testrepoparent/testrepo_quiz_quiz-1/fakeimportquiz_module_course-1_quiz-1_question_manifest.json
@@ -0,0 +1,28 @@
+{
+ "context": {
+ "contextlevel": 70,
+ "coursename": "",
+ "modulename": "Import quiz",
+ "coursecategory": "",
+ "instanceid": "1",
+ "defaultsubdirectory": "top",
+ "defaultsubcategoryid": 5,
+ "moodleurl": "fakeurl.com"
+ },
+ "questions": [
+ {
+ "questionbankentryid": "36001",
+ "filepath": "\/top\/Quiz-Question.xml",
+ "importedversion": "1",
+ "exportedversion": "1",
+ "format": "xml"
+ },
+ {
+ "questionbankentryid": "36002",
+ "filepath": "\/top\/quiz-cat\/Quiz-Question-2.xml",
+ "importedversion": "1",
+ "exportedversion": "1",
+ "format": "xml"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/testrepoparent/testrepo_quiz_quiz-1/import-quiz_quiz.json b/testrepoparent/testrepo_quiz_quiz-1/import-quiz_quiz.json
new file mode 100644
index 0000000..322f573
--- /dev/null
+++ b/testrepoparent/testrepo_quiz_quiz-1/import-quiz_quiz.json
@@ -0,0 +1,39 @@
+{
+ "quiz": {
+ "name": "Quiz 1",
+ "intro": "Quiz intro",
+ "introformat": "0",
+ "questionsperpage": "0",
+ "grade": "100.00000",
+ "navmethod": "free"
+ },
+ "sections": [
+ {
+ "firstslot": "1",
+ "heading": "Heading 1",
+ "shufflequestions": 0
+ },
+ {
+ "firstslot": "2",
+ "heading": "Heading 2",
+ "shufflequestions": 0
+ }
+ ],
+ "questions": [
+ {
+ "quizfilepath": "\/top\/Quiz-Question.xml",
+ "slot": "1",
+ "page": "1",
+ "requireprevious": 0,
+ "maxmark": "1.0000000"
+ }
+ ],
+ "feedback": [
+ {
+ "feedbacktext": "Quiz feedback",
+ "feedbacktextformat": "0",
+ "mingrade": "0.0000000",
+ "maxgrade": "50.000000"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/testrepoparent/testrepo_quiz_quiz-1/top/Quiz-Question.xml b/testrepoparent/testrepo_quiz_quiz-1/top/Quiz-Question.xml
new file mode 100644
index 0000000..342bae7
--- /dev/null
+++ b/testrepoparent/testrepo_quiz_quiz-1/top/Quiz-Question.xml
@@ -0,0 +1,25 @@
+
+
+
+
+ Quiz Question
+
+
+ This is a test question.
]]>
+
+
+
+
+ 1
+ 0.3333333
+ 0
+
+ 0
+
+ This is a test answer.
+
+
+
+
+
+
\ No newline at end of file
diff --git a/testrepoparent/testrepo_quiz_quiz-1/top/gitsync_category.xml b/testrepoparent/testrepo_quiz_quiz-1/top/gitsync_category.xml
new file mode 100644
index 0000000..2322c43
--- /dev/null
+++ b/testrepoparent/testrepo_quiz_quiz-1/top/gitsync_category.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ top
+
+
+ First imported folder
+
+
+
+
\ No newline at end of file
diff --git a/testrepoparent/testrepo_quiz_quiz-1/top/quiz-cat/Quiz-Question-2.xml b/testrepoparent/testrepo_quiz_quiz-1/top/quiz-cat/Quiz-Question-2.xml
new file mode 100644
index 0000000..66b71f5
--- /dev/null
+++ b/testrepoparent/testrepo_quiz_quiz-1/top/quiz-cat/Quiz-Question-2.xml
@@ -0,0 +1,25 @@
+
+
+
+
+ Quiz Question 2
+
+
+ This is a test question.
]]>
+
+
+
+
+ 1
+ 0.3333333
+ 0
+
+ 0
+
+ This is a test answer.
+
+
+
+
+
+
\ No newline at end of file
diff --git a/testrepoparent/testrepo_quiz_quiz-1/top/quiz-cat/Quiz-Question-3.xml b/testrepoparent/testrepo_quiz_quiz-1/top/quiz-cat/Quiz-Question-3.xml
new file mode 100644
index 0000000..b109781
--- /dev/null
+++ b/testrepoparent/testrepo_quiz_quiz-1/top/quiz-cat/Quiz-Question-3.xml
@@ -0,0 +1,25 @@
+
+
+
+
+ Quiz Question 3
+
+
+ This is a test question.
]]>
+
+
+
+
+ 1
+ 0.3333333
+ 0
+
+ 0
+
+ This is a test answer.
+
+
+
+
+
+
\ No newline at end of file
diff --git a/testrepoparent/testrepo_quiz_quiz-1/top/quiz-cat/gitsync_category.xml b/testrepoparent/testrepo_quiz_quiz-1/top/quiz-cat/gitsync_category.xml
new file mode 100644
index 0000000..fbdd8e2
--- /dev/null
+++ b/testrepoparent/testrepo_quiz_quiz-1/top/quiz-cat/gitsync_category.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ top/quiz-cat
+
+
+ First imported folder
+
+
+
+
\ No newline at end of file
diff --git a/tests/cli_helper_test.php b/tests/cli_helper_test.php
index e67ebe8..b6aa191 100644
--- a/tests/cli_helper_test.php
+++ b/tests/cli_helper_test.php
@@ -28,6 +28,7 @@
global $CFG;
use advanced_testcase;
+use org\bovigo\vfs\vfsStream;
/**
* Allows testing of errors that lead to an exit.
@@ -38,7 +39,7 @@ class fake_cli_helper extends cli_helper {
*
* @return void
*/
- public static function call_exit():void {
+ public static function call_exit(): void {
return;
}
}
@@ -52,7 +53,7 @@ public static function call_exit():void {
*
* @covers \gitsync\cli_helper::class
*/
-class cli_helper_test extends advanced_testcase {
+final class cli_helper_test extends advanced_testcase {
/** @var array defining options for a CLI script */
protected array $options = [
[
@@ -104,6 +105,23 @@ class cli_helper_test extends advanced_testcase {
'variable' => 'fake',
'valuerequired' => false,
],
+ [
+ 'longopt' => 'usegit',
+ 'shortopt' => 'u',
+ 'description' => 'Is the repo controlled using Git?',
+ 'default' => true,
+ 'variable' => 'usegit',
+ 'valuerequired' => true,
+ ],
+ [
+ 'longopt' => 'hidey',
+ 'shortopt' => 'q',
+ 'description' => 'Not settable',
+ 'default' => 'Sneaky',
+ 'variable' => 'hidey',
+ 'valuerequired' => true,
+ 'hidden' => true,
+ ],
];
/**
@@ -113,9 +131,11 @@ class cli_helper_test extends advanced_testcase {
public function test_parse_options(): void {
$helper = new cli_helper($this->options);
$options = $helper->parse_options();
- $this->assertEquals($options['shortopts'], 'i:l:c:m:hf');
+ $this->assertEquals($options['shortopts'], 'i:l:c:m:hfu:q:');
$this->assertEquals($options['longopts'],
- ['moodleinstance:', 'contextlevel:', 'coursename:', 'modulename:', 'help', 'fake']);
+ ['moodleinstance:', 'contextlevel:', 'coursename:',
+ 'modulename:', 'help', 'fake',
+ 'usegit:', 'hidey:']);
}
/**
@@ -130,6 +150,7 @@ public function test_prioritise_options(): void {
'contextlevel' => 'module',
'coursename' => 'Course long',
'c' => 'Course short',
+ 'usegit' => 'false',
];
$options = $helper->prioritise_options($commandlineargs);
@@ -145,6 +166,10 @@ public function test_prioritise_options(): void {
$this->assertEquals($options['coursename'], 'Course long');
// Test option default returned when not set.
$this->assertEquals($options['modulename'], 'Default module');
+ // Test usegit is false when command line set to 'false.
+ $this->assertEquals($options['usegit'], false);
+ // Test hidden option set to default.
+ $this->assertEquals($options['hidey'], 'Sneaky');
}
/**
@@ -208,6 +233,49 @@ public function test_manifest_path(): void {
$manifestpath);
}
+ /**
+ * Quiz structure path name creation
+ * @covers \gitsync\cli_helper\get_quiz_structure_path()
+ */
+ public function test_quiz_structure_path(): void {
+ $helper = new cli_helper($this->options);
+ // Module level, including replacements.
+ $datapath = $helper->get_quiz_structure_path('ModulassertEquals('directoryname/modul-name' . cli_helper::QUIZ_FILE, $datapath);
+ }
+
+ /**
+ * Quiz directory name creation
+ * @covers \gitsync\cli_helper\get_quiz_directory()
+ */
+ public function test_get_quiz_directory(): void {
+ $helper = new cli_helper($this->options);
+ // Module level, including replacements.
+ $quizdir = $helper->get_quiz_directory('directoryname', 'ModulassertEquals('directoryname_quiz_modul-name', $quizdir);
+ }
+
+ /**
+ * Create directory
+ * @covers \gitsync\cli_helper\get_quiz_directory()
+ */
+ public function test_create_directory(): void {
+ global $CFG;
+ $root = vfsStream::setup();
+ vfsStream::copyFromFileSystem($CFG->dirroot . '/question/bank/gitsync/testrepoparent/', $root);
+ $rootpath = vfsStream::url('root');
+ $helper = new cli_helper($this->options);
+ // Module level, including replacements.
+ $quizdir = $helper->get_quiz_directory($rootpath . '/below', 'Modulcreate_directory($quizdir);
+ $helper->create_directory($quizdir);
+ $helper->create_directory($quizdir);
+ $this->assertEquals(true, is_dir($quizdir));
+ $this->assertEquals(true, is_dir($quizdir . '_1'));
+ $this->assertEquals(true, is_dir($quizdir . '_2'));
+ $this->assertEquals(false, is_dir($quizdir . '_3'));
+ }
+
/**
* Validation
* @covers \gitsync\cli_helper\validate_and_clean_args()
@@ -225,7 +293,7 @@ public function test_validation_token(): void {
*/
public function test_validation_subdirectory(): void {
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'system',
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'system',
'subdirectory' => 'cat1', 'questioncategoryid' => 3,
];
$helper->validate_and_clean_args();
@@ -238,32 +306,32 @@ public function test_validation_subdirectory(): void {
*/
public function test_validation_subdirectory_format(): void {
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'system', 'subdirectory' => 'cat1/subcat'];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'system', 'subdirectory' => 'cat1/subcat'];
$helper->validate_and_clean_args();
$this->assertEquals('top/cat1/subcat', $helper->processedoptions['subdirectory']);
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'system', 'subdirectory' => '/top/cat1'];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'system', 'subdirectory' => '/top/cat1'];
$helper->validate_and_clean_args();
$this->assertEquals('top/cat1', $helper->processedoptions['subdirectory']);
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'system', 'subdirectory' => '/top/cat1/'];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'system', 'subdirectory' => '/top/cat1/'];
$helper->validate_and_clean_args();
$this->assertEquals('top/cat1', $helper->processedoptions['subdirectory']);
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'system', 'subdirectory' => 'top/cat1/'];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'system', 'subdirectory' => 'top/cat1/'];
$helper->validate_and_clean_args();
$this->assertEquals('top/cat1', $helper->processedoptions['subdirectory']);
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'system', 'subdirectory' => '/cat1/'];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'system', 'subdirectory' => '/cat1/'];
$helper->validate_and_clean_args();
$this->assertEquals('top/cat1', $helper->processedoptions['subdirectory']);
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'system', 'subdirectory' => ''];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'system', 'subdirectory' => ''];
$helper->validate_and_clean_args();
$this->assertEquals(null, $helper->processedoptions['subdirectory']);
}
@@ -274,32 +342,32 @@ public function test_validation_subdirectory_format(): void {
*/
public function test_validation_subcategory_format(): void {
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'system', 'subcategory' => 'cat1/subcat'];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'system', 'subcategory' => 'cat1/subcat'];
$helper->validate_and_clean_args();
$this->assertEquals('top/cat1/subcat', $helper->processedoptions['subcategory']);
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'system', 'subcategory' => '/top/cat1'];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'system', 'subcategory' => '/top/cat1'];
$helper->validate_and_clean_args();
$this->assertEquals('top/cat1', $helper->processedoptions['subcategory']);
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'system', 'subcategory' => '/top/cat1/'];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'system', 'subcategory' => '/top/cat1/'];
$helper->validate_and_clean_args();
$this->assertEquals('top/cat1', $helper->processedoptions['subcategory']);
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'system', 'subcategory' => 'top/cat1/'];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'system', 'subcategory' => 'top/cat1/'];
$helper->validate_and_clean_args();
$this->assertEquals('top/cat1', $helper->processedoptions['subcategory']);
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'system', 'subcategory' => '/cat1/'];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'system', 'subcategory' => '/cat1/'];
$helper->validate_and_clean_args();
$this->assertEquals('top/cat1', $helper->processedoptions['subcategory']);
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'system', 'subcategory' => ''];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'system', 'subcategory' => ''];
$helper->validate_and_clean_args();
$this->assertEquals('top', $helper->processedoptions['subcategory']);
}
@@ -310,12 +378,14 @@ public function test_validation_subcategory_format(): void {
*/
public function test_validation_manifestpath(): void {
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'system', 'manifestpath' => '/path/subpath/'];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'system',
+ 'manifestpath' => '/path/subpath/'];
$helper->validate_and_clean_args();
$this->assertEquals('path/subpath', $helper->processedoptions['manifestpath']);
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'system', 'manifestpath' => '/path/subpath/',
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'system',
+ 'manifestpath' => '/path/subpath/',
'coursename' => 'course1', 'instanceid' => '2',
];
$helper->validate_and_clean_args();
@@ -329,7 +399,7 @@ public function test_validation_manifestpath(): void {
*/
public function test_validation_instanceid(): void {
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'system',
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'system',
'coursename' => 'course1', 'instanceid' => '2',
];
$helper->validate_and_clean_args();
@@ -343,7 +413,7 @@ public function test_validation_instanceid(): void {
*/
public function test_validation_contextlevel_system(): void {
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'system',
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'system',
'coursename' => 'course1', 'instanceid' => '2',
];
$helper->validate_and_clean_args();
@@ -357,7 +427,7 @@ public function test_validation_contextlevel_system(): void {
*/
public function test_validation_contextlevel_coursecategory(): void {
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'coursecategory',
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'coursecategory',
'coursecategory' => 'cat1',
'coursename' => 'course1', 'modulename' => '2',
];
@@ -372,7 +442,7 @@ public function test_validation_contextlevel_coursecategory(): void {
*/
public function test_validation_contextlevel_course(): void {
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'course',
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'course',
'coursecategory' => 'cat1',
'coursename' => 'course1', 'modulename' => '2',
];
@@ -387,7 +457,7 @@ public function test_validation_contextlevel_course(): void {
*/
public function test_validation_contextlevel_module(): void {
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'module',
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'module',
'coursecategory' => 'cat1',
'coursename' => 'course1', 'modulename' => '2',
];
@@ -402,7 +472,7 @@ public function test_validation_contextlevel_module(): void {
*/
public function test_validation_contextlevel_coursecategory_missing(): void {
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'coursecategory'];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'coursecategory'];
$helper->validate_and_clean_args();
$this->expectOutputRegex('/^\nYou have specified course category level.*instanceid\).\n$/s');
}
@@ -413,7 +483,7 @@ public function test_validation_contextlevel_coursecategory_missing(): void {
*/
public function test_validation_contextlevel_course_missing(): void {
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'course'];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'course'];
$helper->validate_and_clean_args();
$this->expectOutputRegex('/^\nYou have specified course level.*instanceid\).\n$/s');
}
@@ -424,7 +494,7 @@ public function test_validation_contextlevel_course_missing(): void {
*/
public function test_validation_contextlevel_module_missing(): void {
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'module', 'modulename' => 'mod1'];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'module', 'modulename' => 'mod1'];
$helper->validate_and_clean_args();
$this->expectOutputRegex('/^\nYou have specified module level.*instanceid\).\n$/s');
}
@@ -435,7 +505,7 @@ public function test_validation_contextlevel_module_missing(): void {
*/
public function test_validation_contextlevel_coursecategory_instanceid(): void {
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'coursecategory', 'instanceid' => 3];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'coursecategory', 'instanceid' => 3];
$helper->validate_and_clean_args();
$this->expectOutputString('');
}
@@ -446,7 +516,7 @@ public function test_validation_contextlevel_coursecategory_instanceid(): void {
*/
public function test_validation_contextlevel_course_instanceid(): void {
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'course', 'instanceid' => 3];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'course', 'instanceid' => 3];
$helper->validate_and_clean_args();
$this->expectOutputString('');
}
@@ -457,7 +527,7 @@ public function test_validation_contextlevel_course_instanceid(): void {
*/
public function test_validation_contextlevel_module_instanceid(): void {
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'module', 'instanceid' => 3];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'module', 'instanceid' => 3];
$helper->validate_and_clean_args();
$this->expectOutputString('');
}
@@ -479,7 +549,7 @@ public function test_validation_contextlevel_missing(): void {
*/
public function test_validation_contextlevel_manifestpath(): void {
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'manifestpath' => 'path/subpath'];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'manifestpath' => 'path/subpath'];
$helper->validate_and_clean_args();
$this->expectOutputString('');
}
@@ -490,7 +560,7 @@ public function test_validation_contextlevel_manifestpath(): void {
*/
public function test_validation_contextlevel_wrong(): void {
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'contextlevel' => 'lama'];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'contextlevel' => 'lama'];
$helper->validate_and_clean_args();
$this->expectOutputRegex('/Contextlevel should be/');
}
@@ -501,7 +571,7 @@ public function test_validation_contextlevel_wrong(): void {
*/
public function test_validation_ignorecat(): void {
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'manifestpath' => 'path/subpath', 'ignorecat' => '/hello/'];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'manifestpath' => 'path/subpath', 'ignorecat' => '/hello/'];
$helper->validate_and_clean_args();
$this->expectOutputString('');
$this->assertEquals('/hello/', $helper->processedoptions['ignorecat']);
@@ -513,7 +583,7 @@ public function test_validation_ignorecat(): void {
*/
public function test_validation_ignorecat_error(): void {
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'manifestpath' => 'path/subpath', 'ignorecat' => '/hello'];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'manifestpath' => 'path/subpath', 'ignorecat' => '/hello'];
@$helper->validate_and_clean_args();
$this->expectOutputRegex('/problem with your regular expression/');
}
@@ -524,9 +594,21 @@ public function test_validation_ignorecat_error(): void {
*/
public function test_validation_ignorecat_replace(): void {
$helper = new fake_cli_helper([]);
- $helper->processedoptions = ['token' => 'X', 'manifestpath' => 'path/subpath', 'ignorecat' => '//hello\//'];
+ $helper->processedoptions = ['token' => 'X', 'usegit' => true, 'manifestpath' => 'path/subpath',
+ 'ignorecat' => '//hello\//', ];
$helper->validate_and_clean_args();
$this->expectOutputString('');
$this->assertEquals('/hello\//', $helper->processedoptions['ignorecat']);
}
+
+ /**
+ * Validation
+ * @covers \gitsync\cli_helper\validate_and_clean_args()
+ */
+ public function test_usegit(): void {
+ $helper = new fake_cli_helper([]);
+ $helper->processedoptions = ['token' => 'X', 'manifestpath' => 'path/subpath'];
+ $helper->validate_and_clean_args();
+ $this->expectOutputRegex('/^\nAre you using Git?/s');
+ }
}
diff --git a/tests/create_repo_test.php b/tests/create_repo_test.php
index 75fbd84..1dad6df 100644
--- a/tests/create_repo_test.php
+++ b/tests/create_repo_test.php
@@ -35,7 +35,7 @@
*
* @covers \gitsync\create_repo::class
*/
-class create_repo_test extends advanced_testcase {
+final class create_repo_test extends advanced_testcase {
/** @var array mocked output of cli_helper->get_arguments */
public array $options;
/** @var array of instance names and URLs */
@@ -54,9 +54,11 @@ class create_repo_test extends advanced_testcase {
const MOODLE = 'fakeexport';
public function setUp(): void {
+ parent::setUp();
global $CFG;
$this->moodleinstances = [self::MOODLE => 'fakeurl.com'];
- vfsStream::setup();
+ $root = vfsStream::setup();
+ vfsStream::copyFromFileSystem($CFG->dirroot . '/question/bank/gitsync/testrepoparent/', $root);
$this->rootpath = vfsStream::url('root');
// Mock the combined output of command line options and defaults.
@@ -65,6 +67,7 @@ public function setUp(): void {
'rootdirectory' => $this->rootpath,
'directory' => '',
'subcategory' => null,
+ 'nonquizmanifestpath' => null,
'contextlevel' => 'system',
'coursename' => 'Course 1',
'modulename' => 'Test 1',
@@ -74,6 +77,7 @@ public function setUp(): void {
'token' => 'XXXXXX',
'help' => false,
'ignorecat' => null,
+ 'usegit' => false,
];
$this->clihelper = $this->getMockBuilder(\qbank_gitsync\cli_helper::class)->onlyMethods([
'get_arguments', 'check_context',
@@ -92,7 +96,7 @@ public function setUp(): void {
'execute',
])->setConstructorArgs(['xxxx'])->getMock();;
$this->createrepo = $this->getMockBuilder(\qbank_gitsync\create_repo::class)->onlyMethods([
- 'get_curl_request', 'handle_abort',
+ 'get_curl_request', 'call_repo_creation', 'call_export_quiz',
])->setConstructorArgs([$this->clihelper, $this->moodleinstances])->getMock();
$this->createrepo->curlrequest = $this->curl;
$this->createrepo->listcurlrequest = $this->listcurl;
@@ -189,11 +193,7 @@ public function test_temp_file_creation(): void {
$tempfile = fopen($this->createrepo->tempfilepath, 'r');
$firstline = json_decode(fgets($tempfile));
$this->assertEquals('1', $firstline->questionbankentryid);
- $this->assertEquals($firstline->contextlevel, '50');
$this->assertEquals($this->rootpath . '/top/One.xml', $firstline->filepath);
- $this->assertEquals($firstline->coursename, 'Course 1');
- $this->assertEquals($firstline->modulename, 'Module 1');
- $this->assertEquals($firstline->coursecategory, null);
$this->assertEquals($firstline->version, '10');
$this->assertEquals($firstline->format, 'xml');
}
@@ -214,7 +214,7 @@ public function test_process_with_named_subcategory(): void {
"questions": []}')
);
$this->createrepo = $this->getMockBuilder(\qbank_gitsync\create_repo::class)->onlyMethods([
- 'get_curl_request', 'call_exit', 'handle_abort',
+ 'get_curl_request', 'call_exit',
])->setConstructorArgs([$this->clihelper, $this->moodleinstances])->getMock();
$this->createrepo->curlrequest = $this->curl;
@@ -268,7 +268,7 @@ public function test_process_with_subcategory_id(): void {
"questions": []}')
);
$this->createrepo = $this->getMockBuilder(\qbank_gitsync\create_repo::class)->onlyMethods([
- 'get_curl_request', 'call_exit', 'handle_abort',
+ 'get_curl_request', 'call_exit',
])->setConstructorArgs([$this->clihelper, $this->moodleinstances])->getMock();
$this->createrepo->curlrequest = $this->curl;
@@ -305,4 +305,36 @@ public function test_process_with_subcategory_id(): void {
$this->assertEquals("top/Default-for-Test-1/sub-2", $manifest->context->defaultsubdirectory);
$this->assertEquals(123, $manifest->context->defaultsubcategoryid);
}
+
+ /**
+ * Test full course.
+ */
+ public function test_full_course(): void {
+ $this->options['directory'] = 'testrepo';
+ $this->clihelper = $this->getMockBuilder(\qbank_gitsync\cli_helper::class)->onlyMethods([
+ 'get_arguments', 'check_context',
+ ])->setConstructorArgs([[]])->getMock();
+ $this->clihelper->expects($this->any())->method('get_arguments')->will($this->returnValue($this->options));
+ $this->clihelper->expects($this->exactly(1))->method('check_context')->willReturn(
+ json_decode('{"contextinfo":{"contextlevel": "module", "categoryname":"", "coursename":"Course 1",
+ "modulename":"Module 1", "instanceid":"", "qcategoryname":"top", "qcategoryid":123},
+ "questions": [],
+ "quizzes": [{"instanceid":"1", "name":"Quiz 1"}, {"instanceid":"2", "name":"Quiz 2"}]}')
+ );
+ $this->createrepo->create_quiz_directories($this->clihelper, $this->rootpath . '/testrepoparent');
+
+ // Should have created a directory for each quiz and updated the manifest with locations.
+ $this->assertEquals(true, is_dir($this->rootpath . "/testrepo_quiz_quiz-1_1"));
+ $this->assertEquals(true, is_dir($this->rootpath . "/testrepo_quiz_quiz-2"));
+ $this->assertEquals(false, is_dir($this->rootpath . "/testrepo_quiz_quiz-3"));
+
+ $manifestcontents = json_decode(file_get_contents($this->rootpath . '/fakeexport_system_question_manifest.json'));
+ $this->assertEquals('1', $manifestcontents->quizzes[0]->moduleid);
+ $this->assertEquals('2', $manifestcontents->quizzes[1]->moduleid);
+ $this->assertEquals('testrepo_quiz_quiz-1_1', $manifestcontents->quizzes[0]->directory);
+ $this->assertEquals('testrepo_quiz_quiz-2', $manifestcontents->quizzes[1]->directory);
+ $this->expectOutputRegex(
+ '/^\nExporting quiz: Quiz 1.*testrepo_quiz_quiz-1_1.*Exporting quiz: Quiz 2.*testrepo_quiz_quiz-2.*$/s'
+ );
+ }
}
diff --git a/tests/export_quiz_test.php b/tests/export_quiz_test.php
new file mode 100644
index 0000000..b0554b2
--- /dev/null
+++ b/tests/export_quiz_test.php
@@ -0,0 +1,382 @@
+.
+
+/**
+ * Unit tests for export quiz command line script for gitsync
+ *
+ * @package qbank_gitsync
+ * @copyright 2023 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace qbank_gitsync;
+
+defined('MOODLE_INTERNAL') || die();
+global $CFG;
+use advanced_testcase;
+use org\bovigo\vfs\vfsStream;
+
+/**
+ * Test the CLI script for exporting a repo from Moodle.
+ * @group qbank_gitsync
+ *
+ * @covers \gitsync\export_repo::class
+ */
+final class export_quiz_test extends advanced_testcase {
+ /** @var array mocked output of cli_helper->get_arguments */
+ public array $options;
+ /** @var array of instance names and URLs */
+ public array $moodleinstances;
+ /** @var cli_helper mocked cli_helper */
+ public cli_helper $clihelper;
+ /** @var curl_request mocked curl_request */
+ public curl_request $curl;
+ /** @var export_quiz mocked export_quiz */
+ public export_quiz $exportquiz;
+ /** @var curl_request mocked curl_request for question list */
+ public curl_request $listcurl;
+ /** @var string root of virtual file system */
+ public string $rootpath;
+ /** MOODLE - Moodle instance value. */
+ const MOODLE = 'fakeexportquiz';
+ /** QUIZNAME - Moodle quiz name value. */
+ const QUIZNAME = 'Quiz 1';
+ /** QUIZINTRO - Moodle quiz intro value. */
+ const QUIZINTRO = 'Quiz intro';
+ /** FEEDBACK - Quiz feedback value. */
+ const FEEDBACK = 'Quiz feedback';
+ /** HEADING1 - heading value. */
+ const HEADING1 = 'Heading 1';
+ /** HEADING2 - heading value. */
+ const HEADING2 = 'Heading 2';
+ /** COURSENAME - course name value. */
+ const COURSENAME = 'Course 1';
+ /** @var array expected quiz details output */
+ protected array $quizoutput = [
+ 'quiz' => [
+ 'name' => self::QUIZNAME,
+ 'intro' => self::QUIZINTRO,
+ 'introformat' => '0',
+ 'questionsperpage' => '0',
+ 'grade' => '100.00000',
+ 'navmethod' => 'free',
+ ],
+ 'sections' => [
+ [
+ 'firstslot' => '1',
+ 'heading' => self::HEADING1,
+ 'shufflequestions' => 0,
+ ],
+ [
+ 'firstslot' => '2',
+ 'heading' => self::HEADING2,
+ 'shufflequestions' => 0,
+ ],
+ ],
+ 'questions' => [
+ [
+ 'questionbankentryid' => '36001',
+ 'slot' => '1',
+ 'page' => '1',
+ 'requireprevious' => 0,
+ 'maxmark' => '1.0000000',
+ ],
+ ],
+ 'feedback' => [
+ [
+ 'feedbacktext' => self::FEEDBACK,
+ 'feedbacktextformat' => '0',
+ 'mingrade' => '0.0000000',
+ 'maxgrade' => '50.000000',
+ ],
+ ],
+ ];
+
+ public function setUp(): void {
+ parent::setUp();
+ global $CFG;
+ $this->moodleinstances = [self::MOODLE => 'fakeurl.com'];
+ // Copy test repo to virtual file stream.
+ $root = vfsStream::setup();
+ vfsStream::copyFromFileSystem($CFG->dirroot . '/question/bank/gitsync/testrepoparent/', $root);
+ $this->rootpath = vfsStream::url('root');
+
+ // Mock the combined output of command line options and defaults.
+ $this->options = [
+ 'moodleinstance' => self::MOODLE,
+ 'rootdirectory' => $this->rootpath,
+ 'nonquizmanifestpath' => '/testrepo/' . self::MOODLE . '_course_course-1' . cli_helper::MANIFEST_FILE,
+ 'quizmanifestpath' => '/testrepo_quiz_quiz-1/' . self::MOODLE . '_module_course-1_quiz-1' . cli_helper::MANIFEST_FILE,
+ 'coursename' => null,
+ 'modulename' => null,
+ 'instanceid' => null,
+ 'token' => 'XXXXXX',
+ 'help' => false,
+ 'subcall' => false,
+ ];
+
+ }
+
+ /**
+ * Mock set up
+ *
+ * @return void
+ */
+ public function set_up_mocks() {
+ $this->clihelper = $this->getMockBuilder(\qbank_gitsync\cli_helper::class)->onlyMethods([
+ 'get_arguments', 'check_context',
+ ])->setConstructorArgs([[]])->getMock();
+ $this->clihelper->expects($this->any())->method('get_arguments')->will($this->returnValue($this->options));
+ $this->clihelper->expects($this->any())->method('check_context')->willReturn(
+ json_decode('{"contextinfo":{"contextlevel": "module", "categoryname":"", "coursename":"Course 1",
+ "modulename":"Module 1", "instanceid":"", "qcategoryname":"top"},
+ "questions": []}')
+ );
+ // Mock call to webservice.
+ $this->curl = $this->getMockBuilder(\qbank_gitsync\curl_request::class)->onlyMethods([
+ 'execute',
+ ])->setConstructorArgs(['xxxx'])->getMock();
+ $this->listcurl = $this->getMockBuilder(\qbank_gitsync\curl_request::class)->onlyMethods([
+ 'execute',
+ ])->setConstructorArgs(['xxxx'])->getMock();
+ $this->exportquiz = $this->getMockBuilder(\qbank_gitsync\export_quiz::class)->onlyMethods([
+ 'get_curl_request', 'call_exit',
+ ])->setConstructorArgs([$this->clihelper, $this->moodleinstances])->getMock();
+ $this->exportquiz->curlrequest = $this->curl;
+ $this->exportquiz->listcurlrequest = $this->listcurl;
+ }
+
+ /**
+ * Test the full process.
+ */
+ public function test_process(): void {
+ $this->set_up_mocks();
+ $this->curl->expects($this->exactly(1))->method('execute')->willReturnOnConsecutiveCalls(
+ json_encode($this->quizoutput)
+ );
+
+ $this->exportquiz->process();
+
+ $quizstructure = file_get_contents($this->rootpath . '/testrepo_quiz_quiz-1/' . 'quiz-1' . cli_helper::QUIZ_FILE);
+ // Check question files updated.
+ $quizstructure = json_decode($quizstructure);
+ $this->assertEquals('/top/Quiz-Question.xml', $quizstructure->questions[0]->quizfilepath);
+
+ $this->expectOutputRegex('/^Quiz data exported to:\n.*testrepo_quiz_quiz-1\/quiz-1_quiz.json\n$/s');
+ }
+
+ /**
+ * Test message if export JSON broken.
+ */
+ public function test_broken_json_on_export(): void {
+ $this->set_up_mocks();
+ $this->curl->expects($this->any())->method('execute')->willReturn(
+ '{"quiz": "}'
+ );
+
+ $this->exportquiz->process();
+
+ $this->expectOutputRegex('/Broken JSON returned from Moodle:' .
+ '.*{"quiz": <\/Question>"}/s');
+ }
+
+ /**
+ * Test message if export exception.
+ */
+ public function test_exception_on_export(): void {
+ $this->set_up_mocks();
+ $this->curl->expects($this->any())->method('execute')->willReturn(
+ '{"exception":"moodle_exception","message":"No token"}'
+ );
+
+ $this->exportquiz->process();
+
+ $this->expectOutputRegex('/No token/');
+ }
+
+ /**
+ * Test message if manifest file update issue.
+ */
+ public function test_manifest_file_update_error(): void {
+ $this->set_up_mocks();
+ $this->curl->expects($this->any())->method('execute')->willReturn(
+ json_encode($this->quizoutput)
+ );
+ $filepath = cli_helper::get_quiz_structure_path(self::QUIZNAME, dirname($this->exportquiz->quizmanifestpath));
+ file_put_contents($filepath, '');
+ chmod($filepath, 0000);
+
+ @$this->exportquiz->process();
+ $this->expectOutputRegex('/\nUnable to update quiz structure file.*Aborting.*$/s');
+ }
+
+ /**
+ * Test if quiz context questions.
+ */
+ public function test_quiz_context_questions(): void {
+ $this->quizoutput['questions'][] =
+ [
+ 'questionbankentryid' => '36002',
+ 'slot' => '2',
+ 'page' => '2',
+ 'requireprevious' => 0,
+ 'maxmark' => '1.0000000',
+ ];
+ $this->set_up_mocks();
+ $this->curl->expects($this->any())->method('execute')->willReturn(
+ json_encode($this->quizoutput)
+ );
+ $this->exportquiz->process();
+ $structurecontents = json_decode(file_get_contents($this->exportquiz->filepath));
+ $this->assertEquals(2, count($structurecontents->questions));
+ $this->assertEquals(false, isset($structurecontents->questions[0]->questionbankentryid));
+ $this->assertEquals(false, isset($structurecontents->questions[0]->nonquizfilepath));
+ $this->assertEquals("/top/Quiz-Question.xml", $structurecontents->questions[0]->quizfilepath);
+ $this->assertEquals(false, isset($structurecontents->questions[1]->questionbankentryid));
+ $this->assertEquals(false, isset($structurecontents->questions[1]->nonquizfilepath));
+ $this->assertEquals("/top/quiz-cat/Quiz-Question-2.xml", $structurecontents->questions[1]->quizfilepath);
+ $this->expectOutputRegex('/Quiz data exported to.*testrepo_quiz_quiz-1\/quiz-1_quiz.json.*$/s');
+ }
+
+ /**
+ * Test if course context questions.
+ */
+ public function test_course_context_questions(): void {
+ $this->quizoutput['questions'] = [
+ [
+ 'questionbankentryid' => '35002',
+ 'slot' => '2',
+ 'page' => '2',
+ 'requireprevious' => 0,
+ 'maxmark' => '1.0000000',
+ ],
+ [
+ 'questionbankentryid' => '35003',
+ 'slot' => '2',
+ 'page' => '2',
+ 'requireprevious' => 0,
+ 'maxmark' => '1.0000000',
+ ],
+ ];
+ $this->set_up_mocks();
+ $this->curl->expects($this->any())->method('execute')->willReturn(
+ json_encode($this->quizoutput)
+ );
+ $this->exportquiz->process();
+ $structurecontents = json_decode(file_get_contents($this->exportquiz->filepath));
+ $this->assertEquals(2, count($structurecontents->questions));
+ $this->assertEquals(false, isset($structurecontents->questions[0]->questionbankentryid));
+ $this->assertEquals(false, isset($structurecontents->questions[0]->quizfilepath));
+ $this->assertEquals("/top/cat-2/subcat-2_1/Third-Question.xml", $structurecontents->questions[0]->nonquizfilepath);
+ $this->assertEquals(false, isset($structurecontents->questions[1]->questionbankentryid));
+ $this->assertEquals(false, isset($structurecontents->questions[1]->quizfilepath));
+ $this->assertEquals("/top/cat-2/Second-Question.xml", $structurecontents->questions[1]->nonquizfilepath);
+ $this->expectOutputRegex('/Quiz data exported to.*testrepo_quiz_quiz-1\/quiz-1_quiz.json.*$/s');
+ }
+
+ /**
+ * Test if missing questions.
+ */
+ public function test_missing_questions(): void {
+ $this->quizoutput['questions'] = [
+ [
+ 'questionbankentryid' => '35002',
+ 'slot' => '2',
+ 'page' => '2',
+ 'requireprevious' => 0,
+ 'maxmark' => '1.0000000',
+ ],
+ [
+ 'questionbankentryid' => '36001',
+ 'slot' => '2',
+ 'page' => '2',
+ 'requireprevious' => 0,
+ 'maxmark' => '1.0000000',
+ ],
+ [
+ 'questionbankentryid' => '37001',
+ 'slot' => '2',
+ 'page' => '2',
+ 'requireprevious' => 0,
+ 'maxmark' => '1.0000000',
+ ],
+ ];
+ $this->set_up_mocks();
+ $this->curl->expects($this->any())->method('execute')->willReturn(
+ json_encode($this->quizoutput)
+ );
+ $this->exportquiz->process();
+ $this->assertEquals(false, is_file($this->exportquiz->filepath));
+ $this->expectOutputRegex('/\nQuestion: 37001\nThis question is in the quiz but not in the supplied manifest files\n' .
+ 'Questions must either be in the repo.*testrepo_quiz_quiz-1\/quiz-1_quiz.json not updated.\n$/s');
+ }
+
+ /**
+ * Test if mixed questions.
+ */
+ public function test_mixed_questions(): void {
+ $this->quizoutput['questions'] = [
+ [
+ 'questionbankentryid' => '35001',
+ 'slot' => '2',
+ 'page' => '2',
+ 'requireprevious' => 0,
+ 'maxmark' => '1.0000000',
+ ],
+ [
+ 'questionbankentryid' => '35002',
+ 'slot' => '2',
+ 'page' => '2',
+ 'requireprevious' => 0,
+ 'maxmark' => '1.0000000',
+ ],
+ [
+ 'questionbankentryid' => '36001',
+ 'slot' => '3',
+ 'page' => '3',
+ 'requireprevious' => 0,
+ 'maxmark' => '1.0000000',
+ ],
+ [
+ 'questionbankentryid' => '36002',
+ 'slot' => '4',
+ 'page' => '4',
+ 'requireprevious' => 0,
+ 'maxmark' => '1.0000000',
+ ],
+ ];
+ $this->set_up_mocks();
+ $this->curl->expects($this->any())->method('execute')->willReturn(
+ json_encode($this->quizoutput)
+ );
+ $this->exportquiz->process();
+ $structurecontents = json_decode(file_get_contents($this->exportquiz->filepath));
+ $this->assertEquals(4, count($structurecontents->questions));
+ $this->assertEquals(false, isset($structurecontents->questions[0]->questionbankentryid));
+ $this->assertEquals(false, isset($structurecontents->questions[0]->quizfilepath));
+ $this->assertEquals("/top/cat-1/First-Question.xml", $structurecontents->questions[0]->nonquizfilepath);
+ $this->assertEquals(false, isset($structurecontents->questions[1]->questionbankentryid));
+ $this->assertEquals(false, isset($structurecontents->questions[1]->quizfilepath));
+ $this->assertEquals("/top/cat-2/subcat-2_1/Third-Question.xml", $structurecontents->questions[1]->nonquizfilepath);
+ $this->assertEquals(false, isset($structurecontents->questions[2]->questionbankentryid));
+ $this->assertEquals(false, isset($structurecontents->questions[2]->nonquizfilepath));
+ $this->assertEquals("/top/Quiz-Question.xml", $structurecontents->questions[2]->quizfilepath);
+ $this->assertEquals(false, isset($structurecontents->questions[3]->questionbankentryid));
+ $this->assertEquals(false, isset($structurecontents->questions[3]->nonquizfilepath));
+ $this->assertEquals("/top/quiz-cat/Quiz-Question-2.xml", $structurecontents->questions[3]->quizfilepath);
+ $this->expectOutputRegex('/Quiz data exported to.*testrepo_quiz_quiz-1\/quiz-1_quiz.json.*$/s');
+ }
+}
diff --git a/tests/export_repo_test.php b/tests/export_repo_test.php
index dd7d362..bb374b9 100644
--- a/tests/export_repo_test.php
+++ b/tests/export_repo_test.php
@@ -38,7 +38,7 @@ class fake_export_cli_helper extends cli_helper {
*
* @return void
*/
- public static function call_exit():void {
+ public static function call_exit(): void {
return;
}
@@ -47,7 +47,7 @@ public static function call_exit():void {
*
* @return void
*/
- public static function handle_abort():void {
+ public static function handle_abort(): void {
return;
}
}
@@ -59,7 +59,7 @@ public static function handle_abort():void {
*
* @covers \gitsync\export_repo::class
*/
-class export_repo_test extends advanced_testcase {
+final class export_repo_test extends advanced_testcase {
/** @var array mocked output of cli_helper->get_arguments */
public array $options;
/** @var array of instance names and URLs */
@@ -78,23 +78,26 @@ class export_repo_test extends advanced_testcase {
const MOODLE = 'fakeexport';
public function setUp(): void {
+ parent::setUp();
global $CFG;
$this->moodleinstances = [self::MOODLE => 'fakeurl.com'];
// Copy test repo to virtual file stream.
$root = vfsStream::setup();
- vfsStream::copyFromFileSystem($CFG->dirroot . '/question/bank/gitsync/testrepo/', $root);
+ vfsStream::copyFromFileSystem($CFG->dirroot . '/question/bank/gitsync/testrepoparent/testrepo/', $root);
$this->rootpath = vfsStream::url('root');
// Mock the combined output of command line options and defaults.
$this->options = [
'moodleinstance' => self::MOODLE,
'rootdirectory' => $this->rootpath,
+ 'nonquizmanifestpath' => null,
'subcategory' => null,
'qcategoryid' => null,
'manifestpath' => '/' . self::MOODLE . '_system' . cli_helper::MANIFEST_FILE,
'token' => 'XXXXXX',
'help' => false,
'ignorecat' => null,
+ 'usegit' => true,
];
$this->clihelper = $this->getMockBuilder(\qbank_gitsync\cli_helper::class)->onlyMethods([
'get_arguments', 'check_context',
@@ -113,7 +116,7 @@ public function setUp(): void {
'execute',
])->setConstructorArgs(['xxxx'])->getMock();
$this->exportrepo = $this->getMockBuilder(\qbank_gitsync\export_repo::class)->onlyMethods([
- 'get_curl_request', 'call_exit', 'handle_abort',
+ 'get_curl_request', 'call_exit', 'call_repo_creation', 'call_export_quiz', 'call_export_repo',
])->setConstructorArgs([$this->clihelper, $this->moodleinstances])->getMock();
$this->exportrepo->curlrequest = $this->curl;
$this->exportrepo->listcurlrequest = $this->listcurl;
@@ -139,7 +142,7 @@ public function replace_mock_default() {
"questions": []}')
);
$this->exportrepo = $this->getMockBuilder(\qbank_gitsync\export_repo::class)->onlyMethods([
- 'get_curl_request', 'call_exit', 'handle_abort',
+ 'get_curl_request', 'call_exit',
])->setConstructorArgs([$this->clihelper, $this->moodleinstances])->getMock();
$this->exportrepo->curlrequest = $this->curl;
@@ -422,4 +425,133 @@ public function test_check_content_default_warning(): void {
$this->expectOutputRegex('/Using default question category from manifest file./');
}
+ /**
+ * Test full course where quizzes are not in manifest.
+ */
+ public function test_full_course_not_in_manifest(): void {
+ global $CFG;
+ $root = vfsStream::setup();
+ vfsStream::copyFromFileSystem($CFG->dirroot . '/question/bank/gitsync/testrepoparent/', $root);
+ $this->rootpath = vfsStream::url('root');
+ $this->options['rootdirectory'] = $this->rootpath;
+ $this->options['manifestpath'] = '/testrepo/' . self::MOODLE . '_system' . cli_helper::MANIFEST_FILE;
+ $this->clihelper = $this->getMockBuilder(\qbank_gitsync\cli_helper::class)->onlyMethods([
+ 'get_arguments', 'check_context',
+ ])->setConstructorArgs([[]])->getMock();
+ $this->clihelper->expects($this->any())->method('get_arguments')->will($this->returnValue($this->options));
+ $this->clihelper->expects($this->exactly(2))->method('check_context')->willReturn(
+ json_decode('{"contextinfo":{"contextlevel": "module", "categoryname":"", "coursename":"Course 1",
+ "modulename":"Module 1", "instanceid":"", "qcategoryname":"top", "qcategoryid":123},
+ "questions": [],
+ "quizzes": [{"instanceid":"1", "name":"Quiz 1"}, {"instanceid":"2", "name":"Quiz 2"}]}')
+ );
+ $this->exportrepo = $this->getMockBuilder(\qbank_gitsync\export_repo::class)->onlyMethods([
+ 'get_curl_request', 'call_exit', 'call_repo_creation', 'call_export_quiz', 'call_export_repo',
+ ])->setConstructorArgs([$this->clihelper, $this->moodleinstances])->getMock();
+
+ $this->exportrepo->curlrequest = $this->curl;
+ $this->exportrepo->listcurlrequest = $this->listcurl;
+ $this->exportrepo->update_quiz_directories($this->clihelper, $this->rootpath . '/testrepoparent');
+
+ // Should have created a directory for each quiz and updated the manifest with locations.
+ $this->assertEquals(true, is_dir($this->rootpath . "/testrepo_quiz_quiz-1"));
+ $this->assertEquals(true, is_dir($this->rootpath . "/testrepo_quiz_quiz-2"));
+ $this->assertEquals(false, is_dir($this->rootpath . "/testrepo_quiz_quiz-3"));
+
+ $manifestcontents = json_decode(file_get_contents($this->rootpath . '/testrepo/fakeexport_system_question_manifest.json'));
+ $this->assertEquals('1', $manifestcontents->quizzes[0]->moduleid);
+ $this->assertEquals('2', $manifestcontents->quizzes[1]->moduleid);
+ $this->assertEquals('testrepo_quiz_quiz-1_1', $manifestcontents->quizzes[0]->directory);
+ $this->assertEquals('testrepo_quiz_quiz-2', $manifestcontents->quizzes[1]->directory);
+ $this->expectOutputRegex(
+ '/^\nExporting quiz: Quiz 1.*testrepo_quiz_quiz-1_1.*Exporting quiz: Quiz 2.*testrepo_quiz_quiz-2.*$/s'
+ );
+ }
+
+ /**
+ * Test full course where quizzes are in manifest but there's no directories.
+ */
+ public function test_full_course_in_manifest_no_directories(): void {
+ global $CFG;
+ $root = vfsStream::setup();
+ vfsStream::copyFromFileSystem($CFG->dirroot . '/question/bank/gitsync/testrepoparent/', $root);
+ $this->rootpath = vfsStream::url('root');
+ $this->options['rootdirectory'] = $this->rootpath;
+ $this->options['manifestpath'] = '/testrepo/' . self::MOODLE . '_system' . cli_helper::MANIFEST_FILE;
+ $this->clihelper = $this->getMockBuilder(\qbank_gitsync\cli_helper::class)->onlyMethods([
+ 'get_arguments', 'check_context',
+ ])->setConstructorArgs([[]])->getMock();
+ $this->clihelper->expects($this->any())->method('get_arguments')->will($this->returnValue($this->options));
+ $this->clihelper->expects($this->exactly(2))->method('check_context')->willReturn(
+ json_decode('{"contextinfo":{"contextlevel": "module", "categoryname":"", "coursename":"Course 1",
+ "modulename":"Module 1", "instanceid":"", "qcategoryname":"top", "qcategoryid":123},
+ "questions": [],
+ "quizzes": [{"instanceid":"1", "name":"Quiz 1"}, {"instanceid":"2", "name":"Quiz 2"}]}')
+ );
+ $this->exportrepo = $this->getMockBuilder(\qbank_gitsync\export_repo::class)->onlyMethods([
+ 'get_curl_request', 'call_exit', 'call_repo_creation', 'call_export_quiz', 'call_export_repo',
+ ])->setConstructorArgs([$this->clihelper, $this->moodleinstances])->getMock();
+
+ $this->exportrepo->curlrequest = $this->curl;
+ $this->exportrepo->listcurlrequest = $this->listcurl;
+ $holder1 = new \StdClass();
+ $holder1->moduleid = '1';
+ $holder1->directory = '/quiz_1_dir';
+ $holder2 = new \StdClass();
+ $holder2->moduleid = '2';
+ $holder2->directory = '/quiz_2_dir';
+ $this->exportrepo->manifestcontents->quizzes = [$holder1, $holder2];
+ $this->exportrepo->update_quiz_directories($this->clihelper, $this->rootpath . '/testrepoparent');
+
+ // Should have created a directory for each quiz and but not updated the manifest.
+ $this->assertEquals(true, is_dir($this->rootpath . "/quiz_1_dir"));
+ $this->assertEquals(true, is_dir($this->rootpath . "/quiz_2_dir"));
+ $this->assertEquals(false, is_dir($this->rootpath . "/testrepo_quiz_quiz-2"));
+
+ $manifestcontents = json_decode(file_get_contents($this->exportrepo->manifestpath));
+ $this->assertEquals(false, isset($manifestcontents->quizzes));
+ $this->expectOutputRegex(
+ '/^\nExporting quiz: Quiz 1.*quiz_1_dir.*Exporting quiz: Quiz 2.*quiz_2_dir.*$/s'
+ );
+ }
+
+ /**
+ * Test full course where quizzes are in manifest and directory already exists.
+ */
+ public function test_full_course_in_manifest_existing_directories(): void {
+ global $CFG;
+ $root = vfsStream::setup();
+ vfsStream::copyFromFileSystem($CFG->dirroot . '/question/bank/gitsync/testrepoparent/', $root);
+ $this->rootpath = vfsStream::url('root');
+ $this->options['rootdirectory'] = $this->rootpath;
+ $this->options['manifestpath'] = '/testrepo/' . self::MOODLE . '_system' . cli_helper::MANIFEST_FILE;
+ $this->clihelper = $this->getMockBuilder(\qbank_gitsync\cli_helper::class)->onlyMethods([
+ 'get_arguments', 'check_context',
+ ])->setConstructorArgs([[]])->getMock();
+ $this->clihelper->expects($this->any())->method('get_arguments')->will($this->returnValue($this->options));
+ $this->clihelper->expects($this->exactly(2))->method('check_context')->willReturn(
+ json_decode('{"contextinfo":{"contextlevel": "module", "categoryname":"", "coursename":"Course 1",
+ "modulename":"Module 1", "instanceid":"", "qcategoryname":"top", "qcategoryid":123},
+ "questions": [],
+ "quizzes": [{"instanceid":"1", "name":"Quiz 1"}]}')
+ );
+ $this->exportrepo = $this->getMockBuilder(\qbank_gitsync\export_repo::class)->onlyMethods([
+ 'get_curl_request', 'call_exit', 'call_repo_creation', 'call_export_quiz', 'call_export_repo',
+ ])->setConstructorArgs([$this->clihelper, $this->moodleinstances])->getMock();
+
+ $this->exportrepo->curlrequest = $this->curl;
+ $this->exportrepo->listcurlrequest = $this->listcurl;
+ $holder1 = new \StdClass();
+ $holder1->moduleid = '1';
+ $holder1->directory = '/testrepo_quiz_quiz-1';
+ $this->exportrepo->manifestcontents->quizzes = [$holder1];
+ $this->exportrepo->update_quiz_directories($this->clihelper, $this->rootpath . '/testrepoparent');
+
+ // Should have not updated the manifest.
+ $manifestcontents = json_decode(file_get_contents($this->exportrepo->manifestpath));
+ $this->assertEquals(false, isset($manifestcontents->quizzes));
+ $this->expectOutputRegex(
+ '/^\nExporting quiz: Quiz 1.*testrepo_quiz_quiz-1\n$/s'
+ );
+ }
}
diff --git a/tests/export_trait_test.php b/tests/export_trait_test.php
index b162a9a..98087e3 100644
--- a/tests/export_trait_test.php
+++ b/tests/export_trait_test.php
@@ -35,7 +35,7 @@
*
* @covers \gitsync\export_repo::class
*/
-class export_trait_test extends advanced_testcase {
+final class export_trait_test extends advanced_testcase {
/** @var array mocked output of cli_helper->get_arguments */
public array $options;
/** @var array of instance names and URLs */
@@ -54,23 +54,26 @@ class export_trait_test extends advanced_testcase {
const MOODLE = 'fakeexport';
public function setUp(): void {
+ parent::setUp();
global $CFG;
$this->moodleinstances = [self::MOODLE => 'fakeurl.com'];
// Copy test repo to virtual file stream.
$root = vfsStream::setup();
- vfsStream::copyFromFileSystem($CFG->dirroot . '/question/bank/gitsync/testrepo/', $root);
+ vfsStream::copyFromFileSystem($CFG->dirroot . '/question/bank/gitsync/testrepoparent/testrepo/', $root);
$this->rootpath = vfsStream::url('root');
// Mock the combined output of command line options and defaults.
$this->options = [
'moodleinstance' => self::MOODLE,
'rootdirectory' => $this->rootpath,
+ 'nonquizmanifestpath' => null,
'subcategory' => null,
'qcategoryid' => null,
'manifestpath' => '/' . self::MOODLE . '_system' . cli_helper::MANIFEST_FILE,
'token' => 'XXXXXX',
'help' => false,
'ignorecat' => null,
+ 'usegit' => true,
];
$this->clihelper = $this->getMockBuilder(\qbank_gitsync\cli_helper::class)->onlyMethods([
'get_arguments', 'check_context',
@@ -89,7 +92,7 @@ public function setUp(): void {
'execute',
])->setConstructorArgs(['xxxx'])->getMock();
$this->exportrepo = $this->getMockBuilder(\qbank_gitsync\export_repo::class)->onlyMethods([
- 'get_curl_request', 'call_exit', 'handle_abort',
+ 'get_curl_request', 'call_exit',
])->setConstructorArgs([$this->clihelper, $this->moodleinstances])->getMock();
$this->exportrepo->curlrequest = $this->curl;
$this->exportrepo->listcurlrequest = $this->listcurl;
@@ -102,7 +105,7 @@ public function setUp(): void {
*
* @return void
*/
- public function set_curl_output():void {
+ public function set_curl_output(): void {
$this->curl->expects($this->exactly(4))->method('execute')->willReturnOnConsecutiveCalls(
'{"question": "' .
'top/Source 2/cat 2/subcat 2_1' .
@@ -135,7 +138,7 @@ public function set_curl_output():void {
*
* @return void
*/
- public function set_curl_output_same_name():void {
+ public function set_curl_output_same_name(): void {
$this->curl->expects($this->exactly(5))->method('execute')->willReturnOnConsecutiveCalls(
'{"question": "' .
'top/Source 2/cat 2/subcat 2_1' .
@@ -305,8 +308,8 @@ public function test_category_xml_error(): void {
"questioncategory": "subcat 2_1"}]}');
$this->curl->expects($this->exactly(1))->method('execute')->willReturnOnConsecutiveCalls(
'{"question": "' .
- 'top/Source 2/cat 2/subcat 2_1' .
- 'Third Question", "version": "10"}',
+ 'top/Source 2/cat 2/subcat 2_1' .
+ 'Third Question", "version": "10"}',
);
@$this->exportrepo->export_to_repo_main_process($questions);
diff --git a/tests/external/delete_question_test.php b/tests/external/delete_question_test.php
index 9186869..b75f84c 100644
--- a/tests/external/delete_question_test.php
+++ b/tests/external/delete_question_test.php
@@ -44,7 +44,7 @@
* @group qbank_gitsync
*
*/
-class delete_question_test extends externallib_advanced_testcase {
+final class delete_question_test extends externallib_advanced_testcase {
/** @var \core_question_generator plugin generator */
protected \core_question_generator $generator;
/** @var \stdClass generated course object */
@@ -61,6 +61,7 @@ class delete_question_test extends externallib_advanced_testcase {
const QNAME = 'Example short answer question';
public function setUp(): void {
+ parent::setUp();
global $DB;
$this->resetAfterTest();
$this->generator = $this->getDataGenerator()->get_plugin_generator('core_question');
diff --git a/tests/external/export_question_test.php b/tests/external/export_question_test.php
index 152cea5..79905d6 100644
--- a/tests/external/export_question_test.php
+++ b/tests/external/export_question_test.php
@@ -44,7 +44,7 @@
*
* @covers \gitsync\external\export_question::execute
*/
-class export_question_test extends externallib_advanced_testcase {
+final class export_question_test extends externallib_advanced_testcase {
/** @var \core_question_generator plugin generator */
protected \core_question_generator $generator;
/** @var \stdClass generated course object */
@@ -61,6 +61,7 @@ class export_question_test extends externallib_advanced_testcase {
const QNAME = 'Example short answer question';
public function setUp(): void {
+ parent::setUp();
global $DB;
$this->resetAfterTest();
$this->generator = $this->getDataGenerator()->get_plugin_generator('core_question');
diff --git a/tests/external/export_quiz_data_test.php b/tests/external/export_quiz_data_test.php
new file mode 100644
index 0000000..13629de
--- /dev/null
+++ b/tests/external/export_quiz_data_test.php
@@ -0,0 +1,226 @@
+.
+
+/**
+ * Unit tests for export_quiz_data function of gitsync webservice
+ *
+ * @package qbank_gitsync
+ * @copyright 2024 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace qbank_gitsync\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+
+use context_course;
+use externallib_advanced_testcase;
+use external_api;
+use required_capability_exception;
+use require_login_exception;
+use moodle_exception;
+
+/**
+ * Test the export_quiz_data webservice function.
+ * @runTestsInSeparateProcesses
+ * @group qbank_gitsync
+ *
+ * @covers \gitsync\external\export_quiz_data::execute
+ */
+final class export_quiz_data_test extends externallib_advanced_testcase {
+ /** @var \core_question_generator plugin generator */
+ protected \core_question_generator $generator;
+ /** @var \mod_quiz_generator plugin generator */
+ protected \mod_quiz_generator $quizgenerator;
+ /** @var \stdClass generated course object */
+ protected \stdClass $course;
+ /** @var \stdClass generated quiz object */
+ protected \stdClass $quiz;
+ /** @var \stdClass generated question_category object */
+ protected \stdClass $qcategory;
+ /** @var \stdClass generated question object */
+ protected \stdClass $q;
+ /** @var int quix module id for generated quiz */
+ protected int $quizmoduleid;
+
+ /** @var int question bank entry id for generated question */
+ protected int $qbankentryid;
+ /** @var \stdClass generated user object */
+ protected \stdClass $user;
+ /** Name of question to be generated and exported. */
+ const QNAME = 'Example short answer question';
+ /** Name of quiz to be generated and exported. */
+ const QUIZNAME = 'Example quiz';
+
+ public function setUp(): void {
+ parent::setUp();
+ global $DB;
+ $this->resetAfterTest();
+ $this->generator = $this->getDataGenerator()->get_plugin_generator('core_question');
+ $this->course = $this->getDataGenerator()->create_course();
+ $this->qcategory = $this->generator->create_question_category(
+ ['contextid' => \context_course::instance($this->course->id)->id]);
+ $user = $this->getDataGenerator()->create_user();
+ $this->user = $user;
+ $this->setUser($user);
+ $this->q = $this->generator->create_question('shortanswer', null,
+ ['name' => self::QNAME, 'category' => $this->qcategory->id]);
+ $this->qbankentryid = $DB->get_field('question_versions', 'questionbankentryid',
+ ['questionid' => $this->q->id], $strictness = MUST_EXIST);
+ $q2 = $this->generator->create_question('shortanswer', null,
+ ['name' => self::QNAME . '2', 'category' => $this->qcategory->id]);
+
+ $quizgenerator = new \testing_data_generator();
+ $this->quizgenerator = $quizgenerator->get_plugin_generator('mod_quiz');
+
+ $this->quiz = $this->quizgenerator->create_instance(['course' => $this->course->id,
+ 'name' => self::QUIZNAME, 'questionsperpage' => 0,
+ 'grade' => 100.0, 'sumgrades' => 2, 'preferredbehaviour' => 'immediatefeedback']);
+
+ $this->quizmoduleid = $this->quiz->cmid;
+ \quiz_add_quiz_question($this->q->id, $this->quiz);
+ \quiz_add_quiz_question($q2->id, $this->quiz);
+ if (class_exists('\mod_quiz\quiz_settings')) {
+ $quizobj = \mod_quiz\quiz_settings::create($this->quiz->id);
+ } else {
+ $quizobj = \quiz::create($this->quiz->id);
+ }
+ \mod_quiz\structure::create_for_quiz($quizobj);
+
+ }
+
+ /**
+ * Test the execute function when capabilities are present.
+ */
+ public function test_capabilities(): void {
+ global $DB;
+ // Set the required capabilities - webservice access and export rights on course.
+ $context = context_course::instance($this->course->id);
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+
+ $returnvalue = export_quiz_data::execute($this->quizmoduleid, null, null);
+
+ // We need to execute the return values cleaning process to simulate
+ // the web service server.
+ $returnvalue = external_api::clean_returnvalue(
+ export_quiz_data::execute_returns(),
+ $returnvalue
+ );
+
+ // Assert that there was a response.
+ // The actual response is tested in other tests.
+ $this->assertNotNull($returnvalue);
+ }
+
+ /**
+ * Test the execute function fails when not logged in.
+ */
+ public function test_not_logged_in(): void {
+ global $DB;
+ $this->setUser();
+ $this->expectException(require_login_exception::class);
+ // Exception messages don't seem to get translated.
+ $this->expectExceptionMessage('not logged in');
+ export_quiz_data::execute($this->quizmoduleid, null, null);
+ }
+
+ /**
+ * Test the execute function fails when no webservice export capability assigned.
+ */
+ public function test_no_webservice_access(): void {
+ global $DB;
+ $context = context_course::instance($this->course->id);
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+ $this->unassignUserCapability('qbank/gitsync:listquestions', \context_system::instance()->id, $managerroleid);
+ $this->expectException(required_capability_exception::class);
+ $this->expectExceptionMessage('you do not currently have permissions to do that (List)');
+ export_quiz_data::execute($this->quizmoduleid, null, null);
+ }
+
+ /**
+ * Test the execute function fails when user has no access to supplied context.
+ */
+ public function test_export_capability(): void {
+ $this->expectException(require_login_exception::class);
+ $this->expectExceptionMessage('Not enrolled');
+ export_quiz_data::execute($this->quizmoduleid, null, null);
+ }
+
+ /**
+ * Test the execute function fails when the question is not accessible in the supplied context.
+ */
+ public function test_question_is_in_supplied_context(): void {
+ global $DB;
+ $context = context_course::instance($this->course->id);
+ $course2 = $this->getDataGenerator()->create_course();
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+ $quiz2 = $this->quizgenerator->create_instance(['course' => $course2, 'questionsperpage' => 0,
+ 'grade' => 100.0, 'sumgrades' => 2, 'preferredbehaviour' => 'immediatefeedback']);
+ $this->expectException(moodle_exception::class);
+ $this->expectExceptionMessage('Not enrolled');
+ // User has list capability on course 1 but not course 2.
+ export_quiz_data::execute($quiz2->cmid, null, null);
+ }
+
+ /**
+ * Test output of execute function.
+ */
+ public function test_export_with_moduleid(): void {
+ global $DB;
+ // Set the required capabilities - webservice access and export rights on course.
+ $context = context_course::instance($this->course->id);
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+ $returnvalue = export_quiz_data::execute($this->quizmoduleid, null, null);;
+
+ $returnvalue = external_api::clean_returnvalue(
+ export_quiz_data::execute_returns(),
+ $returnvalue
+ );
+ $this->assertEquals(self::QUIZNAME, $returnvalue['quiz']['name']);
+ $this->assertEquals(2, count($returnvalue['questions']));
+ $this->assertEquals($this->qbankentryid, $returnvalue['questions'][0]['questionbankentryid']);
+ $this->assertEquals(1, count($returnvalue['sections']));
+ }
+
+ /**
+ * Test output of execute function.
+ */
+ public function test_export_with_name(): void {
+ global $DB;
+ // Set the required capabilities - webservice access and export rights on course.
+ $context = context_course::instance($this->course->id);
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+ $returnvalue = export_quiz_data::execute(null, $this->course->fullname, self::QUIZNAME);;
+
+ $returnvalue = external_api::clean_returnvalue(
+ export_quiz_data::execute_returns(),
+ $returnvalue
+ );
+ $this->assertEquals(self::QUIZNAME, $returnvalue['quiz']['name']);
+ $this->assertEquals(2, count($returnvalue['questions']));
+ $this->assertEquals($this->qbankentryid, $returnvalue['questions'][0]['questionbankentryid']);
+ $this->assertEquals(1, count($returnvalue['sections']));
+ }
+}
diff --git a/tests/external/get_question_list_test.php b/tests/external/get_question_list_test.php
index a8d1864..d02fea6 100644
--- a/tests/external/get_question_list_test.php
+++ b/tests/external/get_question_list_test.php
@@ -44,7 +44,7 @@
*
* @covers \gitsync\external\get_question_list::execute
*/
-class get_question_list_test extends externallib_advanced_testcase {
+final class get_question_list_test extends externallib_advanced_testcase {
/** @var \core_question_generator plugin generator */
protected \core_question_generator $generator;
/** @var \stdClass generated course object */
@@ -61,6 +61,7 @@ class get_question_list_test extends externallib_advanced_testcase {
const QNAME = 'Example short answer question';
public function setUp(): void {
+ parent::setUp();
global $DB;
$this->resetAfterTest();
$this->generator = $this->getDataGenerator()->get_plugin_generator('core_question');
@@ -178,6 +179,21 @@ public function test_list(): void {
['name' => self::QNAME . '2', 'category' => $qcategory2->id]);
$qbankentryid2 = $DB->get_field('question_versions', 'questionbankentryid',
['questionid' => $q2->id], $strictness = MUST_EXIST);
+ $quizgenerator = new \testing_data_generator();
+ $quizgenerator = $quizgenerator->get_plugin_generator('mod_quiz');
+
+ $quiz = $quizgenerator->create_instance(['course' => $this->course->id,
+ 'name' => 'Quiz 1', 'questionsperpage' => 0,
+ 'grade' => 100.0, 'sumgrades' => 2, 'preferredbehaviour' => 'immediatefeedback']);
+
+ \quiz_add_quiz_question($this->q->id, $quiz);
+ \quiz_add_quiz_question($q2->id, $quiz);
+ if (class_exists('\mod_quiz\quiz_settings')) {
+ $quizobj = \mod_quiz\quiz_settings::create($quiz->id);
+ } else {
+ $quizobj = \quiz::create($quiz->id);
+ }
+ \mod_quiz\structure::create_for_quiz($quizobj);
$sink = $this->redirectEvents();
$returnvalue = get_question_list::execute('top', 50, $this->course->fullname, null, null,
null, null, false, ['']);
@@ -207,6 +223,9 @@ public function test_list(): void {
$this->assertEquals($this->course->id, $returnvalue['contextinfo']['instanceid']);
$this->assertEquals(null, $returnvalue['contextinfo']['categoryname']);
$this->assertEquals(null, $returnvalue['contextinfo']['modulename']);
+ $this->assertEquals(1, count($returnvalue['quizzes']));
+ $this->assertEquals('Quiz 1', $returnvalue['quizzes'][0]['name']);
+ $this->assertEquals($quiz->cmid, $returnvalue['quizzes'][0]['instanceid']);
$events = $sink->get_events();
$this->assertEquals(count($events), 0);
@@ -346,7 +365,7 @@ public function test_question_category_is_in_supplied_context(): void {
*
* @return void
*/
- public function test_get_category_path() {
+ public function test_get_category_path(): void {
$contextid = \context_course::instance($this->course->id)->id;
$qcategory2 = $this->generator->create_question_category(
['contextid' => $contextid, 'parent' => $this->qcategory->id, 'name' => "Tim's questions"]);
diff --git a/tests/external/import_question_test.php b/tests/external/import_question_test.php
index a2cc443..640d254 100644
--- a/tests/external/import_question_test.php
+++ b/tests/external/import_question_test.php
@@ -46,7 +46,7 @@
*
* @covers \gitsync\external\import_question::execute
*/
-class import_question_test extends externallib_advanced_testcase {
+final class import_question_test extends externallib_advanced_testcase {
/** @var core_question_generator plugin generator */
protected \core_question_generator $generator;
/** @var generated course object */
@@ -63,6 +63,7 @@ class import_question_test extends externallib_advanced_testcase {
const QNAME = 'Example STACK question';
public function setUp(): void {
+ parent::setUp();
global $CFG;
$this->resetAfterTest();
$this->generator = $this->getDataGenerator()->get_plugin_generator('core_question');
@@ -72,7 +73,7 @@ public function setUp(): void {
$user = $this->getDataGenerator()->create_user();
$this->user = $user;
$this->setUser($user);
- $this->testrepo = $CFG->dirroot . '/question/bank/gitsync/testrepo/';
+ $this->testrepo = $CFG->dirroot . '/question/bank/gitsync/testrepoparent/testrepo/';
$this->fileinfo = ['contextid' => '', 'component' => '', 'filearea' => '', 'userid' => '',
'itemid' => '', 'filepath' => '', 'filename' => '',
];
@@ -84,7 +85,7 @@ public function setUp(): void {
* @param string $contentpath Path to test file containing question
* @return void
*/
- public function upload_file(string $contentpath):void {
+ public function upload_file(string $contentpath): void {
global $USER;
$content = file_get_contents($contentpath);
$context = \context_user::instance($USER->id);
@@ -114,7 +115,7 @@ public function upload_file(string $contentpath):void {
*
* @return context_course
*/
- public function give_capabilities():context_course {
+ public function give_capabilities(): context_course {
global $DB;
// Set the required capabilities - webservice access and export rights on course.
$context = context_course::instance($this->course->id);
diff --git a/tests/external/import_quiz_data_test.php b/tests/external/import_quiz_data_test.php
new file mode 100644
index 0000000..4510d23
--- /dev/null
+++ b/tests/external/import_quiz_data_test.php
@@ -0,0 +1,376 @@
+.
+
+/**
+ * Unit tests for import_quiz_data function of gitsync webservice
+ *
+ * @package qbank_gitsync
+ * @copyright 2024 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace qbank_gitsync\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+
+use context_course;
+use externallib_advanced_testcase;
+use external_api;
+use required_capability_exception;
+use require_login_exception;
+use moodle_exception;
+
+/**
+ * Test the import_quiz_data webservice function.
+ * @runTestsInSeparateProcesses
+ * @group qbank_gitsync
+ *
+ * @covers \gitsync\external\import_quiz_data::execute
+ */
+final class import_quiz_data_test extends externallib_advanced_testcase {
+ /** @var \core_question_generator plugin generator */
+ protected \core_question_generator $generator;
+ /** @var \stdClass generated course object */
+ protected \stdClass $course;
+ /** @var \stdClass generated question_category object */
+ protected \stdClass $qcategory;
+ /** @var \stdClass generated question object */
+ protected \stdClass $q;
+ /** @var int question bank entry id for generated question */
+ protected int $qbankentryid;
+ /** @var \stdClass generated question object */
+ protected \stdClass $q2;
+ /** @var int question bank entry id for generated question */
+ protected int $qbankentryid2;
+ /** @var \stdClass generated user object */
+ protected \stdClass $user;
+ /** Name of question to be generated and exported. */
+ const QNAME = 'Example short answer question';
+ /** QUIZNAME - Moodle quiz name value. */
+ const QUIZNAME = 'Example quiz';
+ /** QUIZINTRO - Moodle quiz intro value. */
+ const QUIZINTRO = 'Quiz intro';
+ /** FEEDBACK - Quiz feedback value. */
+ const FEEDBACK = 'Quiz feedback';
+ /** HEADING1 - heading value. */
+ const HEADING1 = 'Heading 1';
+ /** HEADING2 - heading value. */
+ const HEADING2 = 'Heading 2';
+ /** @var array input parameters */
+ protected array $quizinput = [
+ 'quiz' => [
+ 'name' => self::QUIZNAME,
+ 'intro' => self::QUIZINTRO,
+ 'introformat' => '0',
+ 'coursename' => null,
+ 'courseid' => null,
+ 'questionsperpage' => '0',
+ 'grade' => '100.00000',
+ 'navmethod' => 'free',
+ 'cmid' => null,
+ ],
+ 'sections' => [
+ [
+ 'firstslot' => '1',
+ 'heading' => self::HEADING1,
+ 'shufflequestions' => 0,
+ ],
+ [
+ 'firstslot' => '2',
+ 'heading' => self::HEADING2,
+ 'shufflequestions' => 0,
+ ],
+ ],
+ 'questions' => [
+ [
+ 'questionbankentryid' => null,
+ 'slot' => '1',
+ 'page' => '1',
+ 'requireprevious' => 0,
+ 'maxmark' => '1.0000000',
+ ],
+ [
+ 'questionbankentryid' => null,
+ 'slot' => '2',
+ 'page' => '2',
+ 'requireprevious' => 0,
+ 'maxmark' => '2.0000000',
+ ],
+ ],
+ 'feedback' => [
+ [
+ 'feedbacktext' => self::FEEDBACK,
+ 'feedbacktextformat' => '0',
+ 'mingrade' => '0.0000000',
+ 'maxgrade' => '50.000000',
+ ],
+ ],
+ ];
+
+ public function setUp(): void {
+ parent::setUp();
+ global $DB;
+ $this->resetAfterTest();
+ $this->generator = $this->getDataGenerator()->get_plugin_generator('core_question');
+ $this->course = $this->getDataGenerator()->create_course();
+ $this->qcategory = $this->generator->create_question_category(
+ ['contextid' => \context_course::instance($this->course->id)->id]);
+ $user = $this->getDataGenerator()->create_user();
+ $this->user = $user;
+ $this->setUser($user);
+ $this->q = $this->generator->create_question('shortanswer', null,
+ ['name' => self::QNAME, 'category' => $this->qcategory->id]);
+ $this->qbankentryid = $DB->get_field('question_versions', 'questionbankentryid',
+ ['questionid' => $this->q->id], $strictness = MUST_EXIST);
+ $this->q2 = $this->generator->create_question('shortanswer', null,
+ ['name' => self::QNAME . '2', 'category' => $this->qcategory->id]);
+ $this->qbankentryid2 = $DB->get_field('question_versions', 'questionbankentryid',
+ ['questionid' => $this->q2->id], $strictness = MUST_EXIST);
+ $this->quizinput['quiz']['coursename'] = $this->course->fullname;
+ $this->quizinput['quiz']['courseid'] = $this->course->id;
+ $this->quizinput['questions'][0]['questionbankentryid'] = $this->qbankentryid;
+ $this->quizinput['questions'][1]['questionbankentryid'] = $this->qbankentryid2;
+ }
+
+ /**
+ * Test the execute function when capabilities are present.
+ */
+ public function test_capabilities(): void {
+ global $DB;
+ // Set the required capabilities - webservice access and export rights on course.
+ $context = context_course::instance($this->course->id);
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+
+ $returnvalue = import_quiz_data::execute($this->quizinput['quiz'], $this->quizinput['sections'],
+ $this->quizinput['questions'], $this->quizinput['feedback']);
+
+ // We need to execute the return values cleaning process to simulate
+ // the web service server.
+ $returnvalue = external_api::clean_returnvalue(
+ import_quiz_data::execute_returns(),
+ $returnvalue
+ );
+
+ // Assert that there was a response.
+ // The actual response is tested in other tests.
+ $this->assertNotNull($returnvalue);
+ }
+
+ /**
+ * Test the execute function fails when not logged in.
+ */
+ public function test_not_logged_in(): void {
+ global $DB;
+ $this->setUser();
+ $this->expectException(require_login_exception::class);
+ // Exception messages don't seem to get translated.
+ $this->expectExceptionMessage('not logged in');
+ import_quiz_data::execute($this->quizinput['quiz'], $this->quizinput['sections'],
+ $this->quizinput['questions'], $this->quizinput['feedback']);
+ }
+
+ /**
+ * Test the execute function fails when no webservice export capability assigned.
+ */
+ public function test_no_webservice_access(): void {
+ global $DB;
+ $context = context_course::instance($this->course->id);
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+ $this->unassignUserCapability('qbank/gitsync:importquestions', \context_system::instance()->id, $managerroleid);
+ $this->expectException(required_capability_exception::class);
+ $this->expectExceptionMessage('you do not currently have permissions to do that (Import)');
+ import_quiz_data::execute($this->quizinput['quiz'], $this->quizinput['sections'],
+ $this->quizinput['questions'], $this->quizinput['feedback']);
+ }
+
+ /**
+ * Test the execute function fails when user has no access to supplied context.
+ */
+ public function test_import_capability(): void {
+ $this->expectException(require_login_exception::class);
+ $this->expectExceptionMessage('Not enrolled');
+ import_quiz_data::execute($this->quizinput['quiz'], $this->quizinput['sections'],
+ $this->quizinput['questions'], $this->quizinput['feedback']);
+ }
+
+ /**
+ * Test the execute function fails when the question is not accessible in the supplied context.
+ */
+ public function test_question_is_in_supplied_context(): void {
+ global $DB;
+ $context = context_course::instance($this->course->id);
+ $course2 = $this->getDataGenerator()->create_course();
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+ $this->quizinput['quiz']['coursename'] = $course2->fullname;
+ $this->quizinput['quiz']['courseid'] = $course2->id;
+ $this->expectException(moodle_exception::class);
+ $this->expectExceptionMessage('Not enrolled');
+ // User has import capability on course 1 but not course 2.
+ import_quiz_data::execute($this->quizinput['quiz'], $this->quizinput['sections'],
+ $this->quizinput['questions'], $this->quizinput['feedback']);
+ }
+
+ /**
+ * Test output of execute function.
+ */
+ public function test_import_with_sections_and_feedback(): void {
+ global $DB;
+ // Set the required capabilities - webservice access and export rights on course.
+ $context = context_course::instance($this->course->id);
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+ $returnvalue = import_quiz_data::execute($this->quizinput['quiz'], $this->quizinput['sections'],
+ $this->quizinput['questions'], $this->quizinput['feedback']);
+
+ $returnvalue = external_api::clean_returnvalue(
+ import_quiz_data::execute_returns(),
+ $returnvalue
+ );
+
+ $quizref = $DB->get_field('modules', 'id', ['name' => 'quiz']);
+ $quiz = $DB->get_record('course_modules', ['module' => $quizref]);
+ $this->quizinput['quiz']['cmid'] = $quiz->id;
+ $returnvalue = import_quiz_data::execute($this->quizinput['quiz'], $this->quizinput['sections'],
+ $this->quizinput['questions'], $this->quizinput['feedback']);
+
+ $returnvalue = external_api::clean_returnvalue(
+ import_quiz_data::execute_returns(),
+ $returnvalue
+ );
+
+ $quizzes = $DB->get_records('quiz');
+ $quiz = array_shift($quizzes);
+ $this->assertEquals(self::QUIZNAME, $quiz->name);
+ $this->assertEquals(self::QUIZINTRO, $quiz->intro);
+ $this->assertEquals(0, $quiz->questionsperpage);
+ $this->assertEquals('deferredfeedback', $quiz->preferredbehaviour);
+ $this->assertEquals(2, $quiz->decimalpoints);
+ $this->assertEquals(4352, $quiz->reviewmarks);
+ $this->assertEquals(100, $quiz->grade);
+
+ $sections = $DB->get_records('quiz_sections');
+ $this->assertEquals(2, count($sections));
+ $section1 = array_shift($sections);
+ $section2 = array_shift($sections);
+ $this->assertEquals(self::HEADING1, $section1->heading);
+ $this->assertEquals(1, $section1->firstslot);
+ $this->assertEquals(self::HEADING2, $section2->heading);
+ $this->assertEquals(2, $section2->firstslot);
+
+ $slots = $DB->get_records('quiz_slots');
+ $this->assertEquals(2, count($slots));
+ $slot1 = array_shift($slots);
+ $slot2 = array_shift($slots);
+ $this->assertEquals(0, $slot1->requireprevious);
+ $this->assertEquals(1, $slot1->page);
+ $this->assertEquals(1, $slot1->maxmark);
+ $this->assertEquals(0, $slot2->requireprevious);
+ $this->assertEquals(2, $slot2->page);
+ $this->assertEquals(2, $slot2->maxmark);
+
+ $feedback = $DB->get_records('quiz_feedback');
+ $this->assertEquals(1, count($feedback));
+ $feedback1 = array_shift($feedback);
+ $this->assertEquals(self::FEEDBACK, $feedback1->feedbacktext);
+ $this->assertEquals(0, $feedback1->feedbacktextformat);
+ $this->assertEquals(0, $feedback1->mingrade);
+ $this->assertEquals(50, $feedback1->maxgrade);
+ }
+
+ /**
+ * Test output of execute function.
+ */
+ public function test_import_without_sections_and_feedback(): void {
+ global $DB;
+ // Set the required capabilities - webservice access and export rights on course.
+ $context = context_course::instance($this->course->id);
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+ $returnvalue = import_quiz_data::execute($this->quizinput['quiz'], [],
+ $this->quizinput['questions'], []);
+
+ $quizref = $DB->get_field('modules', 'id', ['name' => 'quiz']);
+ $quiz = $DB->get_record('course_modules', ['module' => $quizref]);
+ $this->quizinput['quiz']['cmid'] = $quiz->id;
+ $returnvalue = import_quiz_data::execute($this->quizinput['quiz'], [],
+ $this->quizinput['questions'], []);
+
+ $returnvalue = external_api::clean_returnvalue(
+ import_quiz_data::execute_returns(),
+ $returnvalue
+ );
+
+ $sections = $DB->get_records('quiz_sections');
+ $this->assertEquals(1, count($sections));
+ $section1 = array_shift($sections);
+ $this->assertEquals('', $section1->heading);
+ $this->assertEquals(1, $section1->firstslot);
+
+ $slots = $DB->get_records('quiz_slots');
+ $this->assertEquals(2, count($slots));
+ $slot1 = array_shift($slots);
+ $slot2 = array_shift($slots);
+ $this->assertEquals(0, $slot1->requireprevious);
+ $this->assertEquals(0, $slot2->requireprevious);
+
+ $feedback = $DB->get_records('quiz_feedback');
+ $this->assertEquals(0, count($feedback));
+ }
+
+ /**
+ * Test output of execute function.
+ */
+ public function test_import_with_require_previous(): void {
+ global $DB;
+ // Set the required capabilities - webservice access and export rights on course.
+ $context = context_course::instance($this->course->id);
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+ $this->quizinput['questions'][1]['requireprevious'] = '1';
+ $returnvalue = import_quiz_data::execute($this->quizinput['quiz'], [],
+ $this->quizinput['questions'], []);
+
+ $returnvalue = external_api::clean_returnvalue(
+ import_quiz_data::execute_returns(),
+ $returnvalue
+ );
+
+ $quizref = $DB->get_field('modules', 'id', ['name' => 'quiz']);
+ $quiz = $DB->get_record('course_modules', ['module' => $quizref]);
+ $this->quizinput['quiz']['cmid'] = $quiz->id;
+ $returnvalue = import_quiz_data::execute($this->quizinput['quiz'], [],
+ $this->quizinput['questions'], []);
+
+ $returnvalue = external_api::clean_returnvalue(
+ import_quiz_data::execute_returns(),
+ $returnvalue
+ );
+
+ $slots = $DB->get_records('quiz_slots');
+ $this->assertEquals(2, count($slots));
+ $slot1 = array_shift($slots);
+ $slot2 = array_shift($slots);
+ $this->assertEquals(0, $slot1->requireprevious);
+ $this->assertEquals(1, $slot2->requireprevious);
+ }
+}
diff --git a/tests/import_quiz_test.php b/tests/import_quiz_test.php
new file mode 100644
index 0000000..542dbdc
--- /dev/null
+++ b/tests/import_quiz_test.php
@@ -0,0 +1,558 @@
+.
+
+/**
+ * Unit tests for import quiz command line script for gitsync
+ *
+ * @package qbank_gitsync
+ * @copyright 2024 University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace qbank_gitsync;
+
+defined('MOODLE_INTERNAL') || die();
+global $CFG;
+use advanced_testcase;
+use org\bovigo\vfs\vfsStream;
+
+/**
+ * Allows testing of errors that lead to an exit.
+ */
+class fake_import_cli_helper extends cli_helper {
+ /**
+ * Override so ignored during testing
+ *
+ * @return void
+ */
+ public static function call_exit(): void {
+ return;
+ }
+
+ /**
+ * Override so ignored during testing
+ *
+ * @return void
+ */
+ public static function handle_abort(): void {
+ return;
+ }
+}
+
+
+/**
+ * Test the CLI script for importing a repo from Moodle.
+ * @group qbank_gitsync
+ *
+ * @covers \gitsync\import_repo::class
+ */
+final class import_quiz_test extends advanced_testcase {
+ /** @var array mocked output of cli_helper->get_arguments */
+ public array $options;
+ /** @var array of instance names and URLs */
+ public array $moodleinstances;
+ /** @var cli_helper mocked cli_helper */
+ public cli_helper $clihelper;
+ /** @var curl_request mocked curl_request */
+ public curl_request $curl;
+ /** @var import_quiz mocked import_quiz */
+ public import_quiz $importquiz;
+ /** @var curl_request mocked curl_request for question list */
+ public curl_request $listcurl;
+ /** @var string root of virtual file system */
+ public string $rootpath;
+ /** MOODLE Moodle instance value */
+ const MOODLE = 'fakeimportquiz';
+ /** QUIZNAME - Moodle quiz name value. */
+ const QUIZNAME = 'Quiz 1';
+ /** QUIZINTRO - Moodle quiz intro value. */
+ const QUIZINTRO = 'Quiz intro';
+ /** FEEDBACK - Quiz feedback value. */
+ const FEEDBACK = 'Quiz feedback';
+ /** HEADING1 - heading value. */
+ const HEADING1 = 'Heading 1';
+ /** HEADING2 - heading value. */
+ const HEADING2 = 'Heading 2';
+ /** COURSENAME - course name value. */
+ const COURSENAME = 'Course 1';
+ /** @var array Expected quiz output. */
+ protected array $quizoutput = [
+ "wstoken" => "XXXXXX",
+ "wsfunction" => "qbank_gitsync_import_quiz_data",
+ "moodlewsrestformat" => "json",
+ "quiz[coursename]" => "Course 1",
+ "quiz[courseid]" => "5",
+ "quiz[name]" => "Quiz 1",
+ "quiz[intro]" => "Quiz intro",
+ "quiz[introformat]" => "0",
+ "quiz[questionsperpage]" => "0",
+ "quiz[grade]" => "100.00000",
+ "quiz[navmethod]" => "free",
+ "quiz[cmid]" => "1",
+ "sections[0][firstslot]" => "1",
+ "sections[0][heading]" => "Heading 1",
+ "sections[0][shufflequestions]" => 0,
+ "sections[1][firstslot]" => "2",
+ "sections[1][heading]" => "Heading 2",
+ "sections[1][shufflequestions]" => 0,
+ "questions[0][slot]" => "1",
+ "questions[0][page]" => "1",
+ "questions[0][requireprevious]" => 0,
+ "questions[0][maxmark]" => "1.0000000",
+ "questions[0][questionbankentryid]" => "36001",
+ "feedback[0][feedbacktext]" => "Quiz feedback",
+ "feedback[0][feedbacktextformat]" => "0",
+ "feedback[0][mingrade]" => "0.0000000",
+ "feedback[0][maxgrade]" => "50.000000",
+ ];
+
+ public function setUp(): void {
+ parent::setUp();
+ global $CFG;
+ $this->moodleinstances = [self::MOODLE => 'fakeurl.com'];
+ // Copy test repo to virtual file stream.
+ $root = vfsStream::setup();
+ vfsStream::copyFromFileSystem($CFG->dirroot . '/question/bank/gitsync/testrepoparent/', $root);
+ $this->rootpath = vfsStream::url('root');
+
+ // Mock the combined output of command line options and defaults.
+ $this->options = [
+ 'moodleinstance' => self::MOODLE,
+ 'rootdirectory' => $this->rootpath,
+ 'nonquizmanifestpath' => '/testrepo/' . self::MOODLE . '_course_course-1' . cli_helper::MANIFEST_FILE,
+ 'quizmanifestpath' => '/testrepo_quiz_quiz-1/' . self::MOODLE . '_module_course-1_quiz-1' . cli_helper::MANIFEST_FILE,
+ 'quizdatapath' => null,
+ 'coursename' => null,
+ 'instanceid' => null,
+ 'token' => 'XXXXXX',
+ 'help' => false,
+ 'subcall' => false,
+ 'usegit' => true,
+ ];
+
+ }
+
+ /**
+ * Mock set up
+ *
+ * @return void
+ */
+ public function set_up_mocks() {
+ $this->clihelper = $this->getMockBuilder(\qbank_gitsync\cli_helper::class)->onlyMethods([
+ 'get_arguments', 'check_context',
+ ])->setConstructorArgs([[]])->getMock();
+ $this->clihelper->expects($this->any())->method('get_arguments')->will($this->returnValue($this->options));
+ $this->clihelper->expects($this->any())->method('check_context')->willReturn(
+ json_decode('{"contextinfo":{"contextlevel": "module", "categoryname":"", "coursename":"Course 1",
+ "courseid":"5", "modulename":"Module 1", "instanceid":"5", "qcategoryname":"top"},
+ "questions": []}')
+ );
+ // Mock call to webservice.
+ $this->curl = $this->getMockBuilder(\qbank_gitsync\curl_request::class)->onlyMethods([
+ 'execute',
+ ])->setConstructorArgs(['xxxx'])->getMock();
+ $this->listcurl = $this->getMockBuilder(\qbank_gitsync\curl_request::class)->onlyMethods([
+ 'execute',
+ ])->setConstructorArgs(['xxxx'])->getMock();
+ $this->importquiz = $this->getMockBuilder(\qbank_gitsync\import_quiz::class)->onlyMethods([
+ 'get_curl_request', 'call_exit', 'handle_abort',
+ ])->setConstructorArgs([$this->clihelper, $this->moodleinstances])->getMock();
+ $this->importquiz->curlrequest = $this->curl;
+ $this->importquiz->listcurlrequest = $this->listcurl;
+ }
+
+ /**
+ * Test the full process.
+ */
+ public function test_process(): void {
+ $this->set_up_mocks();
+ $this->curl->expects($this->exactly(1))->method('execute')->willReturnOnConsecutiveCalls(
+ '{"success": true, "cmid": "23"}'
+ );
+ $this->importquiz->process();
+ $this->assertEquals(json_encode($this->quizoutput), json_encode($this->importquiz->postsettings));
+ $this->expectOutputRegex('/Quiz imported.\n$/s');
+ }
+
+ /**
+ * Test message if import JSON broken.
+ */
+ public function test_broken_json_on_import(): void {
+ $this->set_up_mocks();
+ $this->curl->expects($this->any())->method('execute')->willReturn(
+ '{"quiz": "}'
+ );
+
+ $this->importquiz->process();
+
+ $this->expectOutputRegex('/Broken JSON returned from Moodle:' .
+ '.*{"quiz": <\/Question>"}/s');
+ }
+
+ /**
+ * Test message if import exception.
+ */
+ public function test_exception_on_import(): void {
+ $this->set_up_mocks();
+ $this->curl->expects($this->any())->method('execute')->willReturn(
+ '{"exception":"moodle_exception","message":"No token"}'
+ );
+
+ $this->importquiz->process();
+
+ $this->expectOutputRegex('/No token/');
+ }
+
+ /**
+ * Test message if manifest file open issue.
+ */
+ public function test_manifest_file_open_error(): void {
+ $this->set_up_mocks();
+ chmod($this->importquiz->quizmanifestpath, 0000);
+ @$this->importquiz->__construct($this->clihelper, $this->moodleinstances);
+ $this->expectOutputRegex('/.*Unable to access or parse manifest file:.*testrepo_quiz_quiz-1\/' .
+ 'fakeimportquiz_module_course-1_quiz-1_question_manifest.json.*Aborting.*$/s');
+ }
+
+ /**
+ * Test message if manifest file open issue.
+ */
+ public function test_nonquiz_manifest_file_open_error(): void {
+ $this->set_up_mocks();
+ chmod($this->importquiz->nonquizmanifestpath, 0000);
+ @$this->importquiz->__construct($this->clihelper, $this->moodleinstances);
+ $this->expectOutputRegex(
+ '/.*Unable to access or parse manifest file:.*fakeimportquiz_course_course-1_question_manifest.json.*Aborting.*$/s'
+ );
+ }
+
+ /**
+ * Test message if data file open issue.
+ */
+ public function test_data_file_open_error(): void {
+ $this->options['quizdatapath'] = '/testrepo_quiz_quiz-1/' . 'import-quiz' . cli_helper::QUIZ_FILE;
+ $this->set_up_mocks();
+ chmod($this->importquiz->quizdatapath, 0000);
+ @$this->importquiz->__construct($this->clihelper, $this->moodleinstances);
+ $this->expectOutputRegex(
+ '/.*Unable to access or parse data file:.*testrepo_quiz_quiz-1\/import-quiz_quiz.json.*Aborting.*$/s'
+ );
+ }
+
+ /**
+ * Test validation of supplied datapath info.
+ */
+ public function test_no_data_info(): void {
+ $this->options['quizmanifestpath'] = null;
+ $this->set_up_mocks();
+ $this->expectOutputRegex('/^\nPlease supply a quiz manifest filepath or a quiz data filepath.*Aborting.\n$/s');
+ }
+
+ /**
+ * Test validation of supplied course info.
+ */
+ public function test_no_course_info(): void {
+ $this->options['nonquizmanifestpath'] = null;
+ $this->options['quizmanifestpath'] = null;
+ $this->options['quizdatapath'] = '/testrepo_quiz_quiz-1/' . 'import-quiz' . cli_helper::QUIZ_FILE;
+ $this->set_up_mocks();
+ $this->expectOutputRegex('/^\nYou must identify the course you wish to add the quiz to.*Aborting.\n$/s');
+ }
+
+ /**
+ * Test if quiz context questions.
+ */
+ public function test_quiz_context_questions(): void {
+ $questions = '[
+ {
+ "quizfilepath": "\/top\/Quiz-Question.xml",
+ "slot": "1",
+ "page": "1",
+ "requireprevious": 0,
+ "maxmark": "1.0000000"
+ },
+ {
+ "quizfilepath": "\/top\/quiz-cat\/Quiz-Question-2.xml",
+ "slot": "2",
+ "page": "2",
+ "requireprevious": 0,
+ "maxmark": "1.0000000"
+ }
+ ]';
+ $output = [
+ "questions[0][slot]" => "1",
+ "questions[0][page]" => "1",
+ "questions[0][requireprevious]" => 0,
+ "questions[0][maxmark]" => "1.0000000",
+ "questions[0][questionbankentryid]" => "36001",
+ "questions[1][slot]" => "2",
+ "questions[1][page]" => "2",
+ "questions[1][requireprevious]" => 0,
+ "questions[1][maxmark]" => "1.0000000",
+ "questions[1][questionbankentryid]" => "36002",
+ ];
+ $this->set_up_mocks();
+ $this->curl->expects($this->any())->method('execute')->willReturn(
+ json_encode($this->quizoutput)
+ );
+ $questions = json_decode($questions);
+ $this->quizoutput = array_merge($this->quizoutput, $output);
+ $this->importquiz->quizdatacontents->questions = $questions;
+ $this->importquiz->process();
+ $this->assertEquals([], array_diff_assoc($this->quizoutput, $this->importquiz->postsettings));
+ $this->expectOutputRegex('/Quiz imported.\n$/s');
+ }
+
+ /**
+ * Test if quiz context questions with no course.
+ */
+ public function test_quiz_context_questions_no_course(): void {
+ $this->options['instanceid'] = null;
+ $this->options['nonquizmanifestpath'] = null;
+ $this->options['quizmanifestpath'] = null;
+ $this->options['quizdatapath'] = '/testrepo_quiz_quiz-1/' . 'import-quiz' . cli_helper::QUIZ_FILE;
+ $this->set_up_mocks();
+ $this->expectOutputRegex('/^\nYou must identify the course you wish to add the quiz to.*Aborting.\n$/s');
+ }
+
+ /**
+ * Test if quiz context questions with no course manifest.
+ */
+ public function test_quiz_context_questions_no_course_file(): void {
+ $questions = '[
+ {
+ "quizfilepath": "\/top\/Quiz-Question.xml",
+ "slot": "1",
+ "page": "1",
+ "requireprevious": 0,
+ "maxmark": "1.0000000"
+ },
+ {
+ "quizfilepath": "\/top\/quiz-cat\/Quiz-Question-2.xml",
+ "slot": "2",
+ "page": "2",
+ "requireprevious": 0,
+ "maxmark": "1.0000000"
+ }
+ ]';
+ $output = [
+ "questions[0][slot]" => "1",
+ "questions[0][page]" => "1",
+ "questions[0][requireprevious]" => 0,
+ "questions[0][maxmark]" => "1.0000000",
+ "questions[0][questionbankentryid]" => "36001",
+ "questions[1][slot]" => "2",
+ "questions[1][page]" => "2",
+ "questions[1][requireprevious]" => 0,
+ "questions[1][maxmark]" => "1.0000000",
+ "questions[1][questionbankentryid]" => "36002",
+ ];
+ $this->options['nonquizmanifestpath'] = null;
+ $this->options['coursename'] = 'Course 1';
+ $this->set_up_mocks();
+ $this->curl->expects($this->any())->method('execute')->willReturn(
+ json_encode($this->quizoutput)
+ );
+ $questions = json_decode($questions);
+ $this->quizoutput = array_merge($this->quizoutput, $output);
+ $this->importquiz->quizdatacontents->questions = $questions;
+ $this->importquiz->process();
+ $this->assertEquals([], array_diff_assoc($this->quizoutput, $this->importquiz->postsettings));
+ $this->expectOutputRegex('/Quiz imported.\n$/s');
+ }
+
+ /**
+ * Test if course context questions.
+ */
+ public function test_course_context_questions(): void {
+ $questions = '[
+ {
+ "nonquizfilepath": "\/top\/cat-2\/subcat-2_1\/Fourth-Question.xml",
+ "slot": "1",
+ "page": "1",
+ "requireprevious": 0,
+ "maxmark": "1.0000000"
+ },
+ {
+ "nonquizfilepath": "\/top\/cat-1\/First-Question.xml",
+ "slot": "2",
+ "page": "2",
+ "requireprevious": 0,
+ "maxmark": "1.0000000"
+ }
+ ]';
+ $output = [
+ "questions[0][slot]" => "1",
+ "questions[0][page]" => "1",
+ "questions[0][requireprevious]" => 0,
+ "questions[0][maxmark]" => "1.0000000",
+ "questions[0][questionbankentryid]" => "35004",
+ "questions[1][slot]" => "2",
+ "questions[1][page]" => "2",
+ "questions[1][requireprevious]" => 0,
+ "questions[1][maxmark]" => "1.0000000",
+ "questions[1][questionbankentryid]" => "35001",
+ ];
+ $this->set_up_mocks();
+ $this->curl->expects($this->any())->method('execute')->willReturn(
+ json_encode($this->quizoutput)
+ );
+ $questions = json_decode($questions);
+ $this->quizoutput = array_merge($this->quizoutput, $output);
+ $this->importquiz->quizdatacontents->questions = $questions;
+ $this->importquiz->process();
+ $this->assertEquals([], array_diff_assoc($this->quizoutput, $this->importquiz->postsettings));
+ $this->expectOutputRegex('/Quiz imported.\n$/s');
+ }
+
+ /**
+ * Test if no quiz manifest.
+ */
+ public function test_course_context_questions_no_quiz_manifest(): void {
+ $questions = '[
+ {
+ "nonquizfilepath": "\/top\/cat-2\/subcat-2_1\/Fourth-Question.xml",
+ "slot": "1",
+ "page": "1",
+ "requireprevious": 0,
+ "maxmark": "1.0000000"
+ },
+ {
+ "nonquizfilepath": "\/top\/cat-1\/First-Question.xml",
+ "slot": "2",
+ "page": "2",
+ "requireprevious": 0,
+ "maxmark": "1.0000000"
+ }
+ ]';
+ $this->options['quizmanifestpath'] = null;
+ $this->options['quizdatapath'] = '/testrepo_quiz_quiz-1/' . 'import-quiz' . cli_helper::QUIZ_FILE;
+ $this->set_up_mocks();
+ $this->curl->expects($this->any())->method('execute')->willReturn(
+ json_encode($this->quizoutput)
+ );
+ $questions = json_decode($questions);
+ $this->quizoutput["quiz[cmid]"] = '';
+ unset($this->quizoutput["questions[0][slot]"]);
+ unset($this->quizoutput["questions[0][page]"]);
+ unset($this->quizoutput["questions[0][requireprevious]"]);
+ unset($this->quizoutput["questions[0][maxmark]"]);
+ unset($this->quizoutput["questions[0][questionbankentryid]"]);
+ $this->importquiz->quizdatacontents->questions = $questions;
+ $this->importquiz->process();
+ $this->assertEquals([], array_diff_assoc($this->quizoutput, $this->importquiz->postsettings));
+ $this->expectOutputRegex('/Quiz imported.\n$/s');
+ }
+
+ /**
+ * Test if missing questions.
+ */
+ public function test_missing_questions(): void {
+ $questions = '[
+ {
+ "nonquizfilepath": "\/top\/cat-2\/subcat-2_1\/Fake-Question.xml",
+ "slot": "1",
+ "page": "1",
+ "requireprevious": 0,
+ "maxmark": "1.0000000"
+ },
+ {
+ "nonquizfilepath": "\/top\/cat-1\/First-Question.xml",
+ "slot": "2",
+ "page": "2",
+ "requireprevious": 0,
+ "maxmark": "1.0000000"
+ }
+ ]';
+ $this->set_up_mocks();
+ $this->curl->expects($this->any())->method('execute')->willReturn(
+ json_encode($this->quizoutput)
+ );
+ $questions = json_decode($questions);
+ $this->importquiz->quizdatacontents->questions = $questions;
+ $this->importquiz->process();
+ $this->expectOutputRegex('/.*Question: Non-quiz repo: \/top\/cat-2\/subcat-2_1\/Fake-Question.xml\nThis' .
+ ' question is in the quiz but not in the supplied manifest file.*/s');
+ }
+
+ /**
+ * Test if mixed context questions.
+ */
+ public function test_mixed_context_questions(): void {
+ $questions = '[
+ {
+ "quizfilepath": "\/top\/Quiz-Question.xml",
+ "slot": "1",
+ "page": "1",
+ "requireprevious": 0,
+ "maxmark": "1.0000000"
+ },
+ {
+ "quizfilepath": "\/top\/quiz-cat\/Quiz-Question-2.xml",
+ "slot": "2",
+ "page": "2",
+ "requireprevious": 0,
+ "maxmark": "1.0000000"
+ },
+ {
+ "nonquizfilepath": "\/top\/cat-2\/subcat-2_1\/Fourth-Question.xml",
+ "slot": "3",
+ "page": "3",
+ "requireprevious": 0,
+ "maxmark": "1.0000000"
+ },
+ {
+ "nonquizfilepath": "\/top\/cat-1\/First-Question.xml",
+ "slot": "4",
+ "page": "4",
+ "requireprevious": 0,
+ "maxmark": "1.0000000"
+ }
+ ]';
+ $output = [
+ "questions[0][slot]" => "1",
+ "questions[0][page]" => "1",
+ "questions[0][requireprevious]" => 0,
+ "questions[0][maxmark]" => "1.0000000",
+ "questions[0][questionbankentryid]" => "36001",
+ "questions[1][slot]" => "2",
+ "questions[1][page]" => "2",
+ "questions[1][requireprevious]" => 0,
+ "questions[1][maxmark]" => "1.0000000",
+ "questions[1][questionbankentryid]" => "36002",
+ "questions[2][slot]" => "3",
+ "questions[2][page]" => "3",
+ "questions[2][requireprevious]" => 0,
+ "questions[2][maxmark]" => "1.0000000",
+ "questions[2][questionbankentryid]" => "35004",
+ "questions[3][slot]" => "4",
+ "questions[3][page]" => "4",
+ "questions[3][requireprevious]" => 0,
+ "questions[3][maxmark]" => "1.0000000",
+ "questions[3][questionbankentryid]" => "35001",
+ ];
+ $this->set_up_mocks();
+ $this->curl->expects($this->any())->method('execute')->willReturn(
+ json_encode($this->quizoutput)
+ );
+ $questions = json_decode($questions);
+ $this->quizoutput = array_merge($this->quizoutput, $output);
+ $this->importquiz->quizdatacontents->questions = $questions;
+ $this->importquiz->process();
+ $this->assertEquals([], array_diff_assoc($this->quizoutput, $this->importquiz->postsettings));
+ $this->expectOutputRegex('/Quiz imported.\n$/s');
+ }
+}
diff --git a/tests/import_repo_test.php b/tests/import_repo_test.php
index 2400c1b..fd674b5 100644
--- a/tests/import_repo_test.php
+++ b/tests/import_repo_test.php
@@ -39,7 +39,7 @@ class fake_helper extends cli_helper {
*
* @return void
*/
- public static function call_exit():void {
+ public static function call_exit(): void {
return;
}
@@ -48,7 +48,7 @@ public static function call_exit():void {
*
* @return void
*/
- public static function handle_abort():void {
+ public static function handle_abort(): void {
return;
}
}
@@ -59,7 +59,7 @@ public static function handle_abort():void {
*
* @covers \gitsync\import_repo::class
*/
-class import_repo_test extends advanced_testcase {
+final class import_repo_test extends advanced_testcase {
/** @var array mocked output of cli_helper->get_arguments */
public array $options;
/** @var array of instance names and URLs */
@@ -81,14 +81,15 @@ class import_repo_test extends advanced_testcase {
/** @var array used to store output of multiple calls to a function */
public array $results;
/** name of moodle instance for purpose of tests */
- const MOODLE = 'fakeexport';
+ const MOODLE = 'fakeimport';
public function setUp(): void {
+ parent::setUp();
global $CFG;
$this->moodleinstances = [self::MOODLE => 'fakeurl.com'];
// Copy test repo to virtual file stream.
$root = vfsStream::setup();
- vfsStream::copyFromFileSystem($CFG->dirroot . '/question/bank/gitsync/testrepo/', $root);
+ vfsStream::copyFromFileSystem($CFG->dirroot . '/question/bank/gitsync/testrepoparent/testrepo/', $root);
$this->rootpath = vfsStream::url('root');
// Mock the combined output of command line options and defaults.
@@ -148,7 +149,7 @@ public function setUp(): void {
*
* @return void
*/
- public function replace_mock_default() {
+ public function replace_mock_default(): void {
$this->clihelper = $this->getMockBuilder(\qbank_gitsync\cli_helper::class)->onlyMethods([
'get_arguments', 'check_context',
])->setConstructorArgs([$this->options])->getMock();
@@ -203,7 +204,7 @@ public function test_process(): void {
* Test the full process with manifest path.
*/
public function test_process_manifest_path(): void {
- $this->options["manifestpath"] = 'fakeexport_system_question_manifest.json';
+ $this->options["manifestpath"] = 'fakeimport_system_question_manifest.json';
$this->replace_mock_default();
// The test repo has 2 categories and 1 subcategory. 1 question in each category and 2 in subcategory.
// We expect 3 category calls to the webservice and 3 question calls as using cat 2 subdirectory
@@ -236,7 +237,7 @@ public function test_process_manifest_path(): void {
* Test the full process with manifest path and subdirectory.
*/
public function test_process_manifest_path_and_subdirectory(): void {
- $this->options["manifestpath"] = 'fakeexport_system_question_manifest.json';
+ $this->options["manifestpath"] = 'fakeimport_system_question_manifest.json';
$this->options["subdirectory"] = 'top/cat-2/subcat-2_1';
$this->replace_mock_default();
// The test repo has 2 categories and 1 subcategory. 1 question in each category and 2 in subcategory.
@@ -451,12 +452,8 @@ function() {
$this->assertEquals(4, count(file($this->importrepo->tempfilepath)));
$tempfile = fopen($this->importrepo->tempfilepath, 'r');
$firstline = json_decode(fgets($tempfile));
- $this->assertStringContainsString('3500', $firstline->questionbankentryid);
- $this->assertEquals($firstline->contextlevel, '10');
$this->assertStringContainsString($this->rootpath . '/top/cat-', $firstline->filepath);
- $this->assertEquals($firstline->coursename, 'Course 1');
- $this->assertEquals($firstline->modulename, 'Test 1');
- $this->assertEquals($firstline->coursecategory, 'Cat 1');
+ $this->assertEquals($firstline->version, '2');
$this->assertEquals($firstline->format, 'xml');
}
@@ -597,11 +594,8 @@ function() {
$tempfile = fopen($this->importrepo->tempfilepath, 'r');
$firstline = json_decode(fgets($tempfile));
$this->assertStringContainsString('3500', $firstline->questionbankentryid);
- $this->assertEquals($firstline->contextlevel, '10');
$this->assertStringContainsString($this->rootpath . '/top/cat-', $firstline->filepath);
- $this->assertEquals($firstline->coursename, 'Course 1');
- $this->assertEquals($firstline->modulename, 'Test 1');
- $this->assertEquals($firstline->coursecategory, 'Cat 1');
+ $this->assertEquals($firstline->version, '2');
$this->assertEquals($firstline->format, 'xml');
$this->assertEquals($this->importrepo->listpostsettings["qcategoryname"], 'top/cat 2/subcat 2_1');
}
@@ -786,7 +780,7 @@ public function test_manifest_file(): void {
$this->assertArrayHasKey('/top/cat-2/subcat-2_1/Fourth-Question.xml', $manifestentries);
$context = $manifestcontents->context;
- $this->assertEquals($context->contextlevel, '10');
+ $this->assertEquals($context->contextlevel, '70');
$this->assertEquals($context->coursename, 'Course 1');
$this->assertEquals($context->modulename, 'Module 1');
$this->assertEquals($context->coursecategory, '');
@@ -834,7 +828,7 @@ public function test_manifest_file_with_subdirectory(): void {
$this->assertArrayHasKey('/top/cat-2/subcat-2_1/Fourth-Question.xml', $manifestentries);
$context = $manifestcontents->context;
- $this->assertEquals($context->contextlevel, '10');
+ $this->assertEquals($context->contextlevel, '70');
$this->assertEquals($context->coursename, 'Course 1');
$this->assertEquals($context->modulename, 'Module 1');
$this->assertEquals($context->coursecategory, '');
@@ -880,7 +874,7 @@ public function test_manifest_file_with_subdirectory_and_ignore(): void {
$this->assertArrayHasKey('/top/cat-2/Second-Question.xml', $manifestentries);
$context = $manifestcontents->context;
- $this->assertEquals($context->contextlevel, '10');
+ $this->assertEquals($context->contextlevel, '70');
$this->assertEquals($context->coursename, 'Course 1');
$this->assertEquals($context->modulename, 'Module 1');
$this->assertEquals($context->coursecategory, '');
@@ -927,7 +921,7 @@ public function test_manifest_file_with_ignore(): void {
$this->assertArrayHasKey('/top/cat-1/First-Question.xml', $manifestentries);
$context = $manifestcontents->context;
- $this->assertEquals($context->contextlevel, '10');
+ $this->assertEquals($context->contextlevel, '70');
$this->assertEquals($context->coursename, 'Course 1');
$this->assertEquals($context->modulename, 'Module 1');
$this->assertEquals($context->coursecategory, '');
@@ -1108,6 +1102,7 @@ public function test_delete_no_file_questions(): void {
"format":"xml"
}]}';
$this->importrepo->manifestcontents = json_decode($manifestcontents);
+
file_put_contents($this->importrepo->manifestpath, $manifestcontents);
// Delete 2 of the files.
@@ -1210,7 +1205,7 @@ public function test_delete_questions_exception(): void {
* Check abort if question version in Moodle doesn't match a version in manifest.
* @covers \gitsync\import_repo\check_question_versions()
*/
- public function test_check_question_versions():void {
+ public function test_check_question_versions(): void {
$this->listcurl->expects($this->exactly(1))->method('execute')->willReturnOnConsecutiveCalls(
'{"contextinfo":{"contextlevel": "module", "categoryname":"", "coursename":"Course 1",
"modulename":"Module 1", "instanceid":"", "qcategoryname":"top"},
@@ -1232,7 +1227,7 @@ public function test_check_question_versions():void {
* Test version check passes if exported version matches.
* @covers \gitsync\import_repo\check_question_versions()
*/
- public function test_check_question_export_version_success():void {
+ public function test_check_question_export_version_success(): void {
$this->listcurl->expects($this->exactly(1))->method('execute')->willReturnOnConsecutiveCalls(
'{"contextinfo":{"contextlevel": "module", "categoryname":"", "coursename":"Course 1",
"modulename":"Module 1", "instanceid":"", "qcategoryname":"top"},
@@ -1248,7 +1243,7 @@ public function test_check_question_export_version_success():void {
* Test version check passes if imported version matches.
* @covers \gitsync\import_repo\check_question_versions()
*/
- public function test_check_question_import_version_success():void {
+ public function test_check_question_import_version_success(): void {
$this->listcurl->expects($this->exactly(1))->method('execute')->willReturnOnConsecutiveCalls(
'{"contextinfo":{"contextlevel": "module", "categoryname":"", "coursename":"Course 1",
"modulename":"Module 1", "instanceid":"", "qcategoryname":"top"},
@@ -1264,7 +1259,7 @@ public function test_check_question_import_version_success():void {
* Check abort if question version in Moodle doesn't match a version in manifest.
* @covers \gitsync\import_repo\check_question_versions()
*/
- public function test_check_question_versions_moved_question():void {
+ public function test_check_question_versions_moved_question(): void {
$this->listcurl->expects($this->exactly(2))->method('execute')->willReturnOnConsecutiveCalls(
'{"contextinfo":{"contextlevel": "module", "categoryname":"", "coursename":"Course 1",
"modulename":"Module 1", "instanceid":"", "qcategoryname":"top"},
@@ -1288,7 +1283,7 @@ public function test_check_question_versions_moved_question():void {
* Test version check passes if imported version matches.
* @covers \gitsync\import_repo\check_question_versions()
*/
- public function test_check_question_import_version_success_moved_question():void {
+ public function test_check_question_import_version_success_moved_question(): void {
$this->listcurl->expects($this->exactly(2))->method('execute')->willReturnOnConsecutiveCalls(
'{"contextinfo":{"contextlevel": "module", "categoryname":"", "coursename":"Course 1",
"modulename":"Module 1", "instanceid":"", "qcategoryname":"top"},
@@ -1368,6 +1363,7 @@ public function test_check_content(): void {
"modulename":"Module 1", "instanceid":"", "qcategoryname":"top", "qcategoryid":1},
"questions": []}',
);
+ $clihelper->processedoptions = $this->options;
$clihelper->check_context($this->importrepo);
$this->expectOutputRegex('/^\nPreparing to.*import_repo.*Question subdirectory: top\n$/s');
}
@@ -1429,4 +1425,150 @@ public function test_check_content_default_warning(): void {
$this->expectOutputRegex('/Using default subdirectory from manifest file./');
}
+ /**
+ * Test the full course process. Quiz structure imported into new instance.
+ */
+ public function test_full_course(): void {
+ global $CFG;
+ $root = vfsStream::setup();
+ vfsStream::copyFromFileSystem($CFG->dirroot . '/question/bank/gitsync/testrepoparent/', $root);
+ $this->rootpath = vfsStream::url('root');
+ $this->options['rootdirectory'] = $this->rootpath;
+ $this->options['manifestpath'] = '/testrepo/' . self::MOODLE . '_system' . cli_helper::MANIFEST_FILE;
+ $this->clihelper = $this->getMockBuilder(\qbank_gitsync\cli_helper::class)->onlyMethods([
+ 'get_arguments', 'check_context',
+ ])->setConstructorArgs([[]])->getMock();
+ $this->clihelper->expects($this->any())->method('get_arguments')->will($this->returnValue($this->options));
+ $this->clihelper->expects($this->any())->method('check_context')->willReturnOnConsecutiveCalls(
+ json_decode('{"contextinfo":{"contextlevel": "course", "categoryname":"", "coursename":"Course 1",
+ "modulename":"", "instanceid":"", "qcategoryname":"", "qcategoryid":null},
+ "questions": [], "quizzes": [{"instanceid":"1", "name":"Quiz 1"}]}'),
+ json_decode('{"contextinfo":{"contextlevel": "course", "categoryname":"", "coursename":"Course 1",
+ "modulename":"", "instanceid":"", "qcategoryname":"", "qcategoryid":null},
+ "questions": [], "quizzes": [{"instanceid":"1", "name":"Quiz 1"}]}'),
+ json_decode('{"contextinfo":{"contextlevel": "course", "categoryname":"", "coursename":"Course 1",
+ "modulename":"", "instanceid":"", "qcategoryname":"", "qcategoryid":null},
+ "questions": [], "quizzes": [
+ {"instanceid":"1", "name":"Quiz 1"},
+ {"instanceid":"2", "name":"Quiz 2"}
+ ]}')
+ );
+ $this->importrepo = $this->getMockBuilder(\qbank_gitsync\import_repo::class)->onlyMethods([
+ 'get_curl_request', 'call_exit', 'call_import_repo', 'call_import_quiz_data',
+ ])->setConstructorArgs([$this->clihelper, $this->moodleinstances])->getMock();
+
+ $this->importrepo->update_quizzes($this->clihelper, $this->rootpath . '/testrepoparent');
+
+ // Check quiz added to manifest file.
+ $manifestcontents = json_decode(file_get_contents($this->rootpath . '/testrepo/fakeimport_system_question_manifest.json'));
+ $this->assertEquals('2', $manifestcontents->quizzes[0]->moduleid);
+ $this->assertEquals('testrepo_quiz_quiz-1', $manifestcontents->quizzes[0]->directory);
+ $this->expectOutputRegex(
+ '/^\nCreating quiz: Quiz 1\n\nImporting quiz context: Quiz 1\n\nImporting quiz structure: Quiz 1\n/'
+ );
+ }
+
+ /**
+ * Test the full course process. Quiz not created.
+ */
+ public function test_full_course_quiz_create_fail(): void {
+ global $CFG;
+ $root = vfsStream::setup();
+ vfsStream::copyFromFileSystem($CFG->dirroot . '/question/bank/gitsync/testrepoparent/', $root);
+ $this->rootpath = vfsStream::url('root');
+ $this->options['rootdirectory'] = $this->rootpath;
+ $this->options['manifestpath'] = '/testrepo/' . self::MOODLE . '_system' . cli_helper::MANIFEST_FILE;
+ $this->clihelper = $this->getMockBuilder(\qbank_gitsync\cli_helper::class)->onlyMethods([
+ 'get_arguments', 'check_context',
+ ])->setConstructorArgs([[]])->getMock();
+ $this->clihelper->expects($this->any())->method('get_arguments')->will($this->returnValue($this->options));
+ $this->clihelper->expects($this->any())->method('check_context')->willReturn(
+ json_decode('{"contextinfo":{"contextlevel": "course", "categoryname":"", "coursename":"Course 1",
+ "modulename":"", "instanceid":"", "qcategoryname":"", "qcategoryid":null},
+ "questions": [], "quizzes": [{"instanceid":"1", "name":"Quiz Wrong"}]}')
+ );
+ $this->importrepo = $this->getMockBuilder(\qbank_gitsync\import_repo::class)->onlyMethods([
+ 'get_curl_request', 'call_exit', 'call_import_repo', 'call_import_quiz_data',
+ ])->setConstructorArgs([$this->clihelper, $this->moodleinstances])->getMock();
+
+ $this->importrepo->update_quizzes($this->clihelper, $this->rootpath . '/testrepoparent');
+
+ // Check quiz added to manifest file.
+ $manifestcontents = json_decode(file_get_contents($this->rootpath . '/testrepo/fakeimport_system_question_manifest.json'));
+ $this->assertEquals(false, isset($manifestcontents->quizzes));
+ $this->expectOutputRegex('/.*Quiz was not created for some reason.\n Aborting..*/');
+ }
+
+ /**
+ * Test the full course process. Quiz already imported.
+ */
+ public function test_full_course_quiz_already_imported(): void {
+ global $CFG;
+ $root = vfsStream::setup();
+ vfsStream::copyFromFileSystem($CFG->dirroot . '/question/bank/gitsync/testrepoparent/', $root);
+ $this->rootpath = vfsStream::url('root');
+
+ $this->options['rootdirectory'] = $this->rootpath;
+ $this->options['manifestpath'] = '/testrepo/' . self::MOODLE . '_system' . cli_helper::MANIFEST_FILE;
+ $this->clihelper = $this->getMockBuilder(\qbank_gitsync\cli_helper::class)->onlyMethods([
+ 'get_arguments', 'check_context',
+ ])->setConstructorArgs([[]])->getMock();
+ $this->clihelper->expects($this->any())->method('get_arguments')->will($this->returnValue($this->options));
+ $this->clihelper->expects($this->exactly(2))->method('check_context')->willReturn(
+ json_decode('{"contextinfo":{"contextlevel": "course", "categoryname":"", "coursename":"Course 1",
+ "modulename":"", "instanceid":"", "qcategoryname":"", "qcategoryid":null},
+ "questions": [], "quizzes": [{"instanceid":"1", "name":"Quiz 1"}]}')
+ );
+ $this->importrepo = $this->getMockBuilder(\qbank_gitsync\import_repo::class)->onlyMethods([
+ 'get_curl_request', 'call_exit', 'call_import_repo', 'call_import_quiz_data',
+ ])->setConstructorArgs([$this->clihelper, $this->moodleinstances])->getMock();
+ copy($this->rootpath . '/testrepo_quiz_quiz-1/fakeexportquiz_module_course-1_quiz-1_question_manifest.json',
+ $this->rootpath . '/testrepo_quiz_quiz-1/fakeimport_module_course-1_quiz-1_question_manifest.json');
+ $holder1 = new \StdClass();
+ $holder1->moduleid = '1';
+ $holder1->directory = 'testrepo_quiz_quiz-1';
+ $this->importrepo->manifestcontents->quizzes = [$holder1];
+ $this->importrepo->update_quizzes($this->clihelper, $this->rootpath . '/testrepoparent');
+
+ // Check quiz added to manifest file.
+ $this->assertEquals(1, count($this->importrepo->manifestcontents->quizzes));
+ $this->expectOutputRegex('/^\nImporting quiz context: Quiz 1\n$/');
+ }
+
+ /**
+ * Test the full course process. Extra quiz in Moodle.
+ */
+ public function test_full_course_quiz_in_moodle(): void {
+ global $CFG;
+ $root = vfsStream::setup();
+ vfsStream::copyFromFileSystem($CFG->dirroot . '/question/bank/gitsync/testrepoparent/', $root);
+ $this->rootpath = vfsStream::url('root');
+
+ $this->options['rootdirectory'] = $this->rootpath;
+ $this->options['manifestpath'] = '/testrepo/' . self::MOODLE . '_system' . cli_helper::MANIFEST_FILE;
+ $this->clihelper = $this->getMockBuilder(\qbank_gitsync\cli_helper::class)->onlyMethods([
+ 'get_arguments', 'check_context',
+ ])->setConstructorArgs([[]])->getMock();
+ $this->clihelper->expects($this->any())->method('get_arguments')->will($this->returnValue($this->options));
+ $this->clihelper->expects($this->exactly(2))->method('check_context')->willReturn(
+ json_decode('{"contextinfo":{"contextlevel": "course", "categoryname":"", "coursename":"Course 1",
+ "modulename":"", "instanceid":"", "qcategoryname":"", "qcategoryid":null},
+ "questions": [], "quizzes": [{"instanceid":"1", "name":"Quiz 1"},
+ {"instanceid":"2", "name":"Quiz 2"}]}')
+ );
+ $this->importrepo = $this->getMockBuilder(\qbank_gitsync\import_repo::class)->onlyMethods([
+ 'get_curl_request', 'call_exit', 'call_import_repo', 'call_import_quiz_data',
+ ])->setConstructorArgs([$this->clihelper, $this->moodleinstances])->getMock();
+ copy($this->rootpath . '/testrepo_quiz_quiz-1/fakeexportquiz_module_course-1_quiz-1_question_manifest.json',
+ $this->rootpath . '/testrepo_quiz_quiz-1/fakeimport_module_course-1_quiz-1_question_manifest.json');
+ $holder1 = new \StdClass();
+ $holder1->moduleid = '1';
+ $holder1->directory = 'testrepo_quiz_quiz-1';
+ $this->importrepo->manifestcontents->quizzes = [$holder1];
+ $this->importrepo->update_quizzes($this->clihelper, $this->rootpath . '/testrepoparent');
+
+ // Check quiz added to manifest file.
+ $this->assertEquals(1, count($this->importrepo->manifestcontents->quizzes));
+ $this->expectOutputRegex('/^\nImporting quiz context: Quiz 1\n\nQuiz Quiz 2 is in Moodle but not in the manifest./');
+ }
}
diff --git a/tests/lib_test.php b/tests/lib_test.php
index 641ab06..8fead89 100644
--- a/tests/lib_test.php
+++ b/tests/lib_test.php
@@ -37,12 +37,12 @@
* Tests for library function in lib.php
* @group qbank_gitsync
*/
-class lib_test extends \advanced_testcase {
+final class lib_test extends \advanced_testcase {
/**
* Test the category path is split correctly.
* @covers \gitsync\lib.php\split_category_path()
*/
- public function test_split_category_path() {
+ public function test_split_category_path(): void {
$path = '$course$/Tim\'s questions/Tricky things like // //// ' .
'and so on/Category name ending in // / // and one that ' .
'starts with one/Matematically/span> ' .
@@ -62,7 +62,7 @@ public function test_split_category_path() {
* Test the category path is cleaned correctly.
* @covers \gitsync\lib.php\split_category_path()
*/
- public function test_split_category_path_cleans() {
+ public function test_split_category_path_cleans(): void {
$path = 'Nasty thing/evil>';
$this->assertEquals(['Nasty thing'], split_category_path($path));
}
@@ -71,7 +71,7 @@ public function test_split_category_path_cleans() {
* Test the correct context is returned at each level
* @covers \gitsync\lib.php\get_context()
*/
- public function test_get_context() {
+ public function test_get_context(): void {
define('QUIZ_TEST', 'Quiz test');
define('CAT_NAME', 'Cat1');
$this->resetAfterTest();
@@ -144,7 +144,7 @@ public function test_get_context() {
* @covers \gitsync\lib.php\get_question_data()
* @covers \gitsync\lib.php\get_minimal_question_data()
*/
- public function test_get_question_data() {
+ public function test_get_question_data(): void {
global $DB;
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
diff --git a/tests/tidy_trait_test.php b/tests/tidy_trait_test.php
index 0af8dc4..963823c 100644
--- a/tests/tidy_trait_test.php
+++ b/tests/tidy_trait_test.php
@@ -35,7 +35,7 @@
*
* @covers \gitsync\export_repo::class
*/
-class tidy_trait_test extends advanced_testcase {
+final class tidy_trait_test extends advanced_testcase {
/** @var array mocked output of cli_helper->get_arguments */
public array $options;
/** @var array of instance names and URLs */
@@ -54,11 +54,12 @@ class tidy_trait_test extends advanced_testcase {
const MOODLE = 'fakeexport';
public function setUp(): void {
+ parent::setUp();
global $CFG;
$this->moodleinstances = [self::MOODLE => 'fakeurl.com'];
// Copy test repo to virtual file stream.
$root = vfsStream::setup();
- vfsStream::copyFromFileSystem($CFG->dirroot . '/question/bank/gitsync/testrepo/', $root);
+ vfsStream::copyFromFileSystem($CFG->dirroot . '/question/bank/gitsync/testrepoparent/testrepo/', $root);
$this->rootpath = vfsStream::url('root');
// Mock the combined output of command line options and defaults.
@@ -66,11 +67,13 @@ public function setUp(): void {
'moodleinstance' => self::MOODLE,
'rootdirectory' => $this->rootpath,
'subcategory' => 'top',
+ 'nonquizmanifestpath' => null,
'qcategoryid' => null,
'manifestpath' => '/' . self::MOODLE . '_system' . cli_helper::MANIFEST_FILE,
'token' => 'XXXXXX',
'help' => false,
'ignorecat' => null,
+ 'usegit' => true,
];
$this->clihelper = $this->getMockBuilder(\qbank_gitsync\cli_helper::class)->onlyMethods([
'get_arguments', 'check_context',
@@ -101,7 +104,7 @@ public function setUp(): void {
* Check entry is removed from manifest if question no longer in Moodle.
* @covers \gitsync\tidy_trait\tidy_manifest()
*/
- public function test_tidy_manifest():void {
+ public function test_tidy_manifest(): void {
$this->listcurl->expects($this->exactly(2))->method('execute')->willReturn(
'{"contextinfo":{"contextlevel": "module", "categoryname":"", "coursename":"Course 1",
"modulename":"Module 1", "instanceid":"", "qcategoryname":"top"},
@@ -189,7 +192,7 @@ public function test_exception_on_tidy_request_2(): void {
* Check nothing removed normal pass.
* @covers \gitsync\tidy_trait\tidy_manifest()
*/
- public function test_tidy_manifest_nothing_removed():void {
+ public function test_tidy_manifest_nothing_removed(): void {
$this->listcurl->expects($this->exactly(1))->method('execute')->willReturn(
'{"contextinfo":{"contextlevel": "module", "categoryname":"", "coursename":"Course 1",
"modulename":"Module 1", "instanceid":"", "qcategoryname":"top"},
@@ -215,7 +218,7 @@ public function test_tidy_manifest_nothing_removed():void {
* Check nothing removed with two passes.
* @covers \gitsync\tidy_trait\tidy_manifest()
*/
- public function test_tidy_manifest_nothing_removed_two_passes():void {
+ public function test_tidy_manifest_nothing_removed_two_passes(): void {
$this->listcurl->expects($this->exactly(2))->method('execute')->willReturnOnConsecutiveCalls(
'{"contextinfo":{"contextlevel": "module", "categoryname":"", "coursename":"Course 1",
"modulename":"Module 1", "instanceid":"", "qcategoryname":"top"},
diff --git a/version.php b/version.php
index 0e9a653..cb05157 100644
--- a/version.php
+++ b/version.php
@@ -24,14 +24,14 @@
defined('MOODLE_INTERNAL') || die();
-$plugin->version = 2024052400;
+$plugin->version = 2024121000;
// Question versions functionality of Moodle 4 required.
// Question delete fix for Moodle 4.1.5 required.
// NB 4.2.0 and 4.2.1 do not have the fix.
$plugin->requires = 2022112805;
$plugin->component = 'qbank_gitsync';
$plugin->maturity = MATURITY_BETA;
-$plugin->release = '0.9.0 for Moodle 4.1+';
+$plugin->release = '0.10.0 for Moodle 4.1+';
$plugin->dependencies = [
'qbank_importasversion' => 2024041600,