diff --git a/amd/src/syncscale.js b/amd/src/syncscale.js
new file mode 100644
index 000000000..fdb196ea9
--- /dev/null
+++ b/amd/src/syncscale.js
@@ -0,0 +1,96 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/*
+ * @package local_catquiz
+ * @copyright Wunderbyte GmbH
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import {get_string as getString} from 'core/str';
+import Ajax from 'core/ajax';
+import Notification from 'core/notification';
+
+const SELECTORS = {
+ FORMCONTAINER: '#lcq_select_context_form',
+ CALCULATEBUTTON: '#sync_button'
+};
+/**
+ * Add event listener to buttons.
+ */
+export const init = () => {
+
+ const syncButton = document.querySelector(SELECTORS.SYNCBUTTON);
+ // Hardcoded values for testing.
+ const centralurl = 'https://192.168.56.6';
+ const wstoken = '2f36a8dc27525b97a93e50186035d49e';
+ const scalelabel = 'simulation';
+
+ const contextId = parseInt(calculateButton.dataset.contextid);
+ calculateButton.onclick = () => {
+ updateParameters(contextId);
+ };
+
+ const updateParameters = async(contextid) => {
+ const urlParams = new URLSearchParams(window.location.search);
+ const catscaleid = urlParams.get('scaleid');
+
+ // Fallback if the translation can not be loaded
+ let errorMessage = 'Something went wrong';
+ try {
+ errorMessage = await getString('somethingwentwrong', 'local_catquiz');
+ } catch (error) {
+ // We already have a fallback message, nothing to do here.
+ }
+ Ajax.call([{
+ methodname: 'local_catquiz_update_parameters',
+ args: {contextid, catscaleid},
+ done: async function(res) {
+ if (res.success) {
+ disableButton();
+ // Fallback if the translation can not be loaded
+ let successMessage = 'Recalculation was scheduled';
+ try {
+ successMessage = await getString('recalculationscheduled', 'local_catquiz');
+ } catch (error) {
+ // We already have a fallback message, nothing to do here.
+ }
+
+ Notification.addNotification({
+ message: successMessage,
+ type: 'success'
+ });
+ } else {
+ disableButton();
+ Notification.addNotification({
+ message: errorMessage,
+ type: 'danger'
+ });
+ }
+ },
+ fail: () => {
+ disableButton();
+ Notification.addNotification({
+ message: errorMessage,
+ type: 'danger'
+ });
+ },
+ }]);
+ };
+
+ const disableButton = () => {
+ document.querySelector(SELECTORS.CALCULATEBUTTON).setAttribute('disabled', true);
+ };
+};
\ No newline at end of file
diff --git a/classes/external/client_fetch_parameters.php b/classes/external/client_fetch_parameters.php
new file mode 100644
index 000000000..8d0a780ff
--- /dev/null
+++ b/classes/external/client_fetch_parameters.php
@@ -0,0 +1,205 @@
+.
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir . '/externallib.php');
+require_once($CFG->libdir . '/filelib.php');
+
+namespace local_catquiz\external;
+
+use core_external\external_api;
+use core_external\external_function_parameters;
+use core_external\external_single_structure;
+use core_external\external_value;
+use core_external\external_multiple_structure;
+use local_catquiz\catscale;
+
+/**
+ * This class contains a list of webservice functions related to the catquiz Module by Wunderbyte.
+ *
+ * @package local_catquiz
+ * @copyright 2024 Wunderbyte GmbH
+ * @author David Szkiba
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class fetch_parameters extends external_api {
+ /**
+ * Returns description of method parameters
+ * @return external_function_parameters
+ */
+ public static function execute_parameters() {
+ return new external_function_parameters([
+ 'centralurl' => new external_value(PARAM_URL, 'URL of central instance'),
+ 'token' => new external_value(PARAM_TEXT, 'Web service token'),
+ 'scalelabel' => new external_value(PARAM_TEXT, 'Label of the scale to sync'),
+ ]);
+ }
+
+ /**
+ * Returns description of method return values
+ * @return external_single_structure
+ */
+ public static function execute_returns() {
+ return new external_single_structure([
+ 'status' => new external_value(PARAM_BOOL, 'Status of the sync'),
+ 'duration' => new external_value(PARAM_FLOAT, 'Duration in seconds'),
+ 'synced' => new external_value(PARAM_INT, 'Number of parameters synced'),
+ 'errors' => new external_value(PARAM_INT, 'Number of errors encountered'),
+ 'message' => new external_value(PARAM_TEXT, 'Status message'),
+ 'warnings' => new external_multiple_structure(
+ new external_single_structure([
+ 'item' => new external_value(PARAM_TEXT, 'Item identifier'),
+ 'warning' => new external_value(PARAM_TEXT, 'Warning message'),
+ ])
+ ),
+ ]);
+ }
+
+ /**
+ * Fetches and synchronizes item parameters from a central instance
+ *
+ * @param string $centralurl URL of the central Moodle instance
+ * @param string $token Web service token for authentication
+ * @param string $scalelabel Label of the scale to synchronize
+ * @return array Returns status information about the sync operation including:
+ * - status (bool): Whether any parameters were successfully synced
+ * - duration (float): Time taken for the operation in seconds
+ * - synced (int): Number of parameters successfully synchronized
+ * - errors (int): Number of errors encountered
+ * - message (string): Status message describing the operation
+ * - warnings (array): List of specific warnings/errors encountered
+ * @throws \invalid_parameter_exception If parameters are invalid
+ */
+ public static function execute(string $centralurl, string $token, string $scalelabel) {
+ global $DB;
+
+ $params = self::validate_parameters(self::execute_parameters(), [
+ 'centralurl' => $centralurl,
+ 'token' => $token,
+ 'scalelabel' => $scalelabel,
+ ]);
+
+ $starttime = microtime(true);
+ $warnings = [];
+ $stored = 0;
+ $errors = 0;
+
+ // Get the local scale.
+ $scale = $DB->get_record('local_catquiz_catscales', ['label' => $params['scalelabel']], '*', MUST_EXIST);
+
+ // Call central instance.
+ $serverurl = rtrim($params['centralurl'], '/') . '/webservice/rest/server.php';
+ $wsparams = [
+ 'wstoken' => $params['token'],
+ 'wsfunction' => 'local_catquiz_fetch_item_parameters',
+ 'moodlewsrestformat' => 'json',
+ 'scalelabel' => $params['scalelabel'],
+ ];
+ $CFG->curlsecurityblockedhosts = ''; // TODO: Remove.
+ $curl = new \curl();
+ $curl->setopt(['CURLOPT_SSL_VERIFYPEER' => false, 'CURLOPT_SSL_VERIFYHOST' => false]);
+ $response = $curl->post($serverurl, $wsparams);
+ unset($CFG->curlsecurityblockedhosts);
+ $result = json_decode($response);
+
+ if (!$result || isset($result->exception)) {
+ return [
+ 'status' => false,
+ 'duration' => 0,
+ 'synced' => 0,
+ 'errors' => 1,
+ 'message' => $result->message ?? 'Invalid response from server',
+ 'warnings' => [],
+ ];
+ }
+
+ // Create a mapping of catscale labels to IDs.
+ $scalemapping = [];
+ $catscaleids = [$scale->id, ...catscale::get_subscale_ids($scale->id)];
+ [$inscalesql, $inscaleparams] = $DB->get_in_or_equal($catscaleids, SQL_PARAMS_NAMED, 'scaleid');
+
+ $sql = <<get_records_sql($sql, $inscaleparams);
+ foreach ($scalerecords as $s) {
+ $scalemapping[$s->label] = $s->id;
+ }
+
+ // Create new context.
+ $source = "Fetch from " . parse_url($params['centralurl'], PHP_URL_HOST);
+ $newcontext = \local_catquiz\data\dataapi::create_new_context_for_scale(
+ $scale->id,
+ $scale->name,
+ $source,
+ false
+ );
+
+ // Process and store parameters.
+ foreach ($result->parameters as $param) {
+ $questionid = \local_catquiz\remote\hash\question_hasher::get_questionid_from_hash($param->questionhash);
+ if (!$questionid) {
+ $warnings[] = ['item' => $param->questionhash, 'warning' => 'Question not found locally'];
+ $errors++;
+ continue;
+ }
+
+ try {
+ $itemrecord = new \stdClass();
+ $itemrecord->componentid = $questionid;
+ $itemrecord->componentname = 'question';
+ $itemrecord->catscaleid = $scale->id;
+ $itemrecord->contextid = $newcontext->id;
+ $itemrecord->status = LOCAL_CATQUIZ_TESTITEM_STATUS_ACTIVE;
+ $itemid = $DB->insert_record('local_catquiz_items', $itemrecord);
+
+ $paramrecord = new \stdClass();
+ $paramrecord->itemid = $itemid;
+ $paramrecord->componentid = $questionid;
+ $paramrecord->componentname = 'question';
+ $paramrecord->contextid = $newcontext->id;
+ $paramrecord->model = $param->model;
+ $paramrecord->difficulty = $param->difficulty;
+ $paramrecord->discrimination = $param->discrimination;
+ $paramrecord->status = $param->status;
+ $paramrecord->json = $param->json;
+
+ $itemparam = \local_catquiz\local\model\model_item_param::from_record($paramrecord);
+ $itemparam->save();
+ $stored++;
+ } catch (\Exception $e) {
+ debugging('Error storing parameter: ' . $e->getMessage(), DEBUG_DEVELOPER);
+ $warnings[] = ['item' => $param->questionhash, 'warning' => $e->getMessage()];
+ $errors++;
+ }
+ }
+
+ $duration = microtime(true) - $starttime;
+
+ return [
+ 'status' => $stored > 0,
+ 'duration' => round($duration, 2),
+ 'synced' => $stored,
+ 'errors' => $errors,
+ 'message' => $stored > 0 ? "Successfully synced $stored parameters" : 'No parameters were synced',
+ 'warnings' => $warnings,
+ ];
+ }
+}
diff --git a/classes/external/fetch_item_parameters.php b/classes/external/fetch_item_parameters.php
index 17d5ac975..1e1c55944 100644
--- a/classes/external/fetch_item_parameters.php
+++ b/classes/external/fetch_item_parameters.php
@@ -129,11 +129,7 @@ public static function execute($scalelabel, $questionhashes = [], $models = [])
// Get all relevant scale IDs (parent scale and subscales)
$catscaleids = [$scale->id, ...catscale::get_subscale_ids($scale->id)];
// Get the latest context for this scale.
- $contextid = catscale::get_context_id($scale->id);
- if (!$contextid) {
- throw new moodle_exception('nocontextfound', 'local_catquiz');
- }
-
+ $contextid = $scale->contextid;
// Get all questions assigned to this scale.
[$inscalesql, $inscaleparams] = $DB->get_in_or_equal($catscaleids, SQL_PARAMS_NAMED, 'scaleid');
diff --git a/db/upgrade.php b/db/upgrade.php
index 7e6f9bf74..92761d6c4 100644
--- a/db/upgrade.php
+++ b/db/upgrade.php
@@ -989,7 +989,7 @@ function xmldb_local_catquiz_upgrade($oldversion) {
// This is a bit unconventional. The table already exists with old, long
// names on a moodle instance that supports longer table names but can't be
// created on a different instance that has stricter naming rules.
- if ($oldversion < 2024110802) {
+ if ($oldversion < 2024111804) {
// Check if old table exists first.
if ($dbman->table_exists('local_catquiz_question_hashmap')) {
// Rename the table.
@@ -1054,7 +1054,7 @@ function xmldb_local_catquiz_upgrade($oldversion) {
}
}
- upgrade_plugin_savepoint(true, 2024110802, 'local', 'catquiz');
+ upgrade_plugin_savepoint(true, 2024111804, 'local', 'catquiz');
}
diff --git a/templates/catscalemanager/catscales.mustache b/templates/catscalemanager/catscales.mustache
index b13b1b690..d21f89a2c 100644
--- a/templates/catscalemanager/catscales.mustache
+++ b/templates/catscalemanager/catscales.mustache
@@ -41,6 +41,9 @@
require(['local_catquiz/calculatescales'], function(init) {
init.init();
});
+ require(['local_catquiz/syncscale'], function(init) {
+ init.init();
+ });
});
{{/js}}
{{/scaledetailview}}
diff --git a/version.php b/version.php
index 3f5c70d36..c9193fdcb 100644
--- a/version.php
+++ b/version.php
@@ -26,7 +26,7 @@
$plugin->component = 'local_catquiz';
$plugin->release = '1.1.1';
-$plugin->version = 2024111802;
+$plugin->version = 2024111804;
$plugin->requires = 2022041900;
$plugin->maturity = MATURITY_STABLE;
$plugin->dependencies = [