diff --git a/mod/lti/amd/build/course_tools_list.min.js b/mod/lti/amd/build/course_tools_list.min.js
new file mode 100644
index 0000000000000..991eba8c9d2a8
--- /dev/null
+++ b/mod/lti/amd/build/course_tools_list.min.js
@@ -0,0 +1,3 @@
+define("mod_lti/course_tools_list",["exports","core/notification","core/pending","core/ajax","core/toast","core/prefetch","core/str","core_table/dynamic","core_table/local/dynamic/selectors"],(function(_exports,_notification,_pending,_ajax,_toast,_prefetch,_str,_dynamic,Selectors){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_notification=_interopRequireDefault(_notification),_pending=_interopRequireDefault(_pending),_ajax=_interopRequireDefault(_ajax),Selectors=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(Selectors);_exports.init=()=>{(0,_prefetch.prefetchStrings)("mod_lti",["deletecoursetool","deletecoursetoolconfirm","coursetooldeleted"]),(0,_prefetch.prefetchStrings)("core",["delete"]),document.addEventListener("click",(event=>{const courseToolDelete=event.target.closest('[data-action="course-tool-delete"]');if(courseToolDelete){event.preventDefault();const triggerElement=courseToolDelete.closest(".dropdown").querySelector(".dropdown-toggle");_notification.default.saveCancelPromise((0,_str.get_string)("deletecoursetool","mod_lti"),(0,_str.get_string)("deletecoursetoolconfirm","mod_lti",courseToolDelete.dataset.courseToolName),(0,_str.get_string)("delete","core"),{triggerElement:triggerElement}).then((()=>{const pendingPromise=new _pending.default("mod_lti/course_tools:delete"),request={methodname:"mod_lti_delete_course_tool_type",args:{tooltypeid:courseToolDelete.dataset.courseToolId}};return _ajax.default.call([request])[0].then((0,_toast.add)((0,_str.get_string)("coursetooldeleted","mod_lti"))).then((()=>{const tableRoot=triggerElement.closest(Selectors.main.region);return(0,_dynamic.refreshTableContent)(tableRoot)})).then(pendingPromise.resolve).catch(_notification.default.exception)})).catch((()=>{}))}}))}}));
+
+//# sourceMappingURL=course_tools_list.min.js.map
\ No newline at end of file
diff --git a/mod/lti/amd/build/course_tools_list.min.js.map b/mod/lti/amd/build/course_tools_list.min.js.map
new file mode 100644
index 0000000000000..13e18320ae405
--- /dev/null
+++ b/mod/lti/amd/build/course_tools_list.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"course_tools_list.min.js","sources":["../src/course_tools_list.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Course LTI External tools list management.\n *\n * @module mod_lti/course_tools_list\n * @copyright 2023 Jake Dallimore \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n\"use strict\";\n\nimport Notification from 'core/notification';\nimport Pending from 'core/pending';\nimport Ajax from 'core/ajax';\nimport {add as addToast} from 'core/toast';\nimport {prefetchStrings} from 'core/prefetch';\nimport {get_string as getString} from 'core/str';\nimport {refreshTableContent} from 'core_table/dynamic';\nimport * as Selectors from 'core_table/local/dynamic/selectors';\n\n/**\n * Initialise module.\n */\nexport const init = () => {\n prefetchStrings('mod_lti', [\n 'deletecoursetool',\n 'deletecoursetoolconfirm',\n 'coursetooldeleted'\n ]);\n\n prefetchStrings('core', [\n 'delete',\n ]);\n\n document.addEventListener('click', event => {\n\n const courseToolDelete = event.target.closest('[data-action=\"course-tool-delete\"]');\n if (courseToolDelete) {\n event.preventDefault();\n\n // Use triggerElement to return focus to the action menu toggle.\n const triggerElement = courseToolDelete.closest('.dropdown').querySelector('.dropdown-toggle');\n Notification.saveCancelPromise(\n getString('deletecoursetool', 'mod_lti'),\n getString('deletecoursetoolconfirm', 'mod_lti', courseToolDelete.dataset.courseToolName),\n getString('delete', 'core'),\n {triggerElement}\n ).then(() => {\n const pendingPromise = new Pending('mod_lti/course_tools:delete');\n\n const request = {\n methodname: 'mod_lti_delete_course_tool_type',\n args: {tooltypeid: courseToolDelete.dataset.courseToolId}\n };\n return Ajax.call([request])[0]\n .then(addToast(getString('coursetooldeleted', 'mod_lti')))\n .then(() => {\n const tableRoot = triggerElement.closest(Selectors.main.region);\n return refreshTableContent(tableRoot);\n })\n .then(pendingPromise.resolve)\n .catch(Notification.exception);\n }).catch(() => {\n return;\n });\n }\n });\n};\n"],"names":["document","addEventListener","event","courseToolDelete","target","closest","preventDefault","triggerElement","querySelector","saveCancelPromise","dataset","courseToolName","then","pendingPromise","Pending","request","methodname","args","tooltypeid","courseToolId","Ajax","call","tableRoot","Selectors","main","region","resolve","catch","Notification","exception"],"mappings":"8/CAqCoB,mCACA,UAAW,CACvB,mBACA,0BACA,oDAGY,OAAQ,CACpB,WAGJA,SAASC,iBAAiB,SAASC,cAEzBC,iBAAmBD,MAAME,OAAOC,QAAQ,yCAC1CF,iBAAkB,CAClBD,MAAMI,uBAGAC,eAAiBJ,iBAAiBE,QAAQ,aAAaG,cAAc,0CAC9DC,mBACT,mBAAU,mBAAoB,YAC9B,mBAAU,0BAA2B,UAAWN,iBAAiBO,QAAQC,iBACzE,mBAAU,SAAU,QACpB,CAACJ,eAAAA,iBACHK,MAAK,WACGC,eAAiB,IAAIC,iBAAQ,+BAE7BC,QAAU,CACZC,WAAY,kCACZC,KAAM,CAACC,WAAYf,iBAAiBO,QAAQS,sBAEzCC,cAAKC,KAAK,CAACN,UAAU,GACvBH,MAAK,eAAS,mBAAU,oBAAqB,aAC7CA,MAAK,WACIU,UAAYf,eAAeF,QAAQkB,UAAUC,KAAKC,eACjD,gCAAoBH,cAE9BV,KAAKC,eAAea,SACpBC,MAAMC,sBAAaC,cACzBF,OAAM"}
\ No newline at end of file
diff --git a/mod/lti/amd/src/course_tools_list.js b/mod/lti/amd/src/course_tools_list.js
new file mode 100644
index 0000000000000..ca234c11436da
--- /dev/null
+++ b/mod/lti/amd/src/course_tools_list.js
@@ -0,0 +1,82 @@
+// 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 .
+
+/**
+ * Course LTI External tools list management.
+ *
+ * @module mod_lti/course_tools_list
+ * @copyright 2023 Jake Dallimore
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+"use strict";
+
+import Notification from 'core/notification';
+import Pending from 'core/pending';
+import Ajax from 'core/ajax';
+import {add as addToast} from 'core/toast';
+import {prefetchStrings} from 'core/prefetch';
+import {get_string as getString} from 'core/str';
+import {refreshTableContent} from 'core_table/dynamic';
+import * as Selectors from 'core_table/local/dynamic/selectors';
+
+/**
+ * Initialise module.
+ */
+export const init = () => {
+ prefetchStrings('mod_lti', [
+ 'deletecoursetool',
+ 'deletecoursetoolconfirm',
+ 'coursetooldeleted'
+ ]);
+
+ prefetchStrings('core', [
+ 'delete',
+ ]);
+
+ document.addEventListener('click', event => {
+
+ const courseToolDelete = event.target.closest('[data-action="course-tool-delete"]');
+ if (courseToolDelete) {
+ event.preventDefault();
+
+ // Use triggerElement to return focus to the action menu toggle.
+ const triggerElement = courseToolDelete.closest('.dropdown').querySelector('.dropdown-toggle');
+ Notification.saveCancelPromise(
+ getString('deletecoursetool', 'mod_lti'),
+ getString('deletecoursetoolconfirm', 'mod_lti', courseToolDelete.dataset.courseToolName),
+ getString('delete', 'core'),
+ {triggerElement}
+ ).then(() => {
+ const pendingPromise = new Pending('mod_lti/course_tools:delete');
+
+ const request = {
+ methodname: 'mod_lti_delete_course_tool_type',
+ args: {tooltypeid: courseToolDelete.dataset.courseToolId}
+ };
+ return Ajax.call([request])[0]
+ .then(addToast(getString('coursetooldeleted', 'mod_lti')))
+ .then(() => {
+ const tableRoot = triggerElement.closest(Selectors.main.region);
+ return refreshTableContent(tableRoot);
+ })
+ .then(pendingPromise.resolve)
+ .catch(Notification.exception);
+ }).catch(() => {
+ return;
+ });
+ }
+ });
+};
diff --git a/mod/lti/classes/external/delete_course_tool_type.php b/mod/lti/classes/external/delete_course_tool_type.php
new file mode 100644
index 0000000000000..cd03f2c40585e
--- /dev/null
+++ b/mod/lti/classes/external/delete_course_tool_type.php
@@ -0,0 +1,81 @@
+.
+
+namespace mod_lti\external;
+
+use core_external\external_api;
+use core_external\external_function_parameters;
+use core_external\external_value;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/mod/lti/locallib.php');
+
+/**
+ * External function to delete a course tool type.
+ *
+ * @package mod_lti
+ * @copyright 2023 Jake Dallimore
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class delete_course_tool_type extends external_api {
+
+ /**
+ * Get parameter definition.
+ *
+ * @return external_function_parameters
+ */
+ public static function execute_parameters(): external_function_parameters {
+ return new external_function_parameters([
+ 'tooltypeid' => new external_value(PARAM_INT, 'Tool type ID'),
+ ]);
+ }
+
+ /**
+ * Delete a course tool type.
+ *
+ * @param int $tooltypeid the id of the course external tool type.
+ * @return bool true
+ * @throws \invalid_parameter_exception if the provided id refers to a site level tool which cannot be deleted.
+ */
+ public static function execute(int $tooltypeid): bool {
+
+ ['tooltypeid' => $tooltypeid] = self::validate_parameters(self::execute_parameters(), ['tooltypeid' => $tooltypeid]);
+
+ global $DB;
+ $course = (int) $DB->get_field('lti_types', 'course', ['id' => $tooltypeid]);
+ if ($course == get_site()->id) {
+ throw new \invalid_parameter_exception('This is a site-level tool and cannot be deleted via this service');
+ }
+
+ $context = \context_course::instance($course);
+ self::validate_context($context);
+ require_capability('mod/lti:addcoursetool', $context);
+
+ \lti_delete_type($tooltypeid);
+ return true;
+ }
+
+ /**
+ * Get service returns definition.
+ *
+ * @return external_value
+ */
+ public static function execute_returns(): external_value {
+ return new external_value(PARAM_BOOL, 'Success');
+ }
+}
diff --git a/mod/lti/coursetools.php b/mod/lti/coursetools.php
index dd5a5492a1993..e1192d0b63364 100644
--- a/mod/lti/coursetools.php
+++ b/mod/lti/coursetools.php
@@ -21,6 +21,7 @@
* @copyright 2023 Jake Dallimore
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
+
use mod_lti\output\course_tools_page;
require_once("../../config.php");
@@ -39,7 +40,7 @@
}
// Page setup.
-global $PAGE;
+global $PAGE, $OUTPUT;
$pagetitle = get_string('courseexternaltools', 'mod_lti');
$pageurl = new moodle_url('/mod/lti/coursetools.php', ['id' => $course->id]);
$PAGE->set_pagelayout('incourse');
@@ -59,4 +60,3 @@
$PAGE->requires->js_call_amd('mod_lti/course_tools_list', 'init');
echo $OUTPUT->footer();
-
diff --git a/mod/lti/db/services.php b/mod/lti/db/services.php
index 75e6cb1fb69ea..93c385382cbb3 100644
--- a/mod/lti/db/services.php
+++ b/mod/lti/db/services.php
@@ -146,6 +146,14 @@
'ajax' => true
),
+ 'mod_lti_delete_course_tool_type' => array(
+ 'classname' => 'mod_lti\external\delete_course_tool_type',
+ 'description' => 'Delete a course tool type',
+ 'type' => 'write',
+ 'capabilities' => 'mod/lti:addcoursetool',
+ 'ajax' => true
+ ),
+
'mod_lti_is_cartridge' => array(
'classname' => 'mod_lti_external',
'methodname' => 'is_cartridge',
diff --git a/mod/lti/lang/en/lti.php b/mod/lti/lang/en/lti.php
index 7746b48173e1a..7278d957821e8 100644
--- a/mod/lti/lang/en/lti.php
+++ b/mod/lti/lang/en/lti.php
@@ -130,6 +130,7 @@
$string['courseinformation'] = 'Course information';
$string['courselink'] = 'Go to course';
$string['coursemisconf'] = 'Course is misconfigured';
+$string['coursetooldeleted'] = 'Course tool deleted';
$string['createdon'] = 'Created on';
$string['curllibrarymissing'] = 'PHP cURL extension required for the External tool.';
$string['custom'] = 'Custom parameters';
@@ -158,6 +159,8 @@
$string['delegate_tool'] = 'As specified in Deep Linking definition or Delegate to teacher';
$string['delete'] = 'Delete';
$string['delete_confirmation'] = 'Are you sure you want to delete this preconfigured tool?';
+$string['deletecoursetool'] = 'Delete a course tool';
+$string['deletecoursetoolconfirm'] = 'Are you sure you want to delete this course tool?';
$string['deletetype'] = 'Delete preconfigured tool';
$string['display_description'] = 'Display activity description when launched';
$string['display_description_help'] = 'If selected, the activity description (specified above) will display above the tool provider\'s content.
diff --git a/mod/lti/tests/behat/managecoursetools.feature b/mod/lti/tests/behat/managecoursetools.feature
index 8dfea62403205..fd7791ae51dd0 100644
--- a/mod/lti/tests/behat/managecoursetools.feature
+++ b/mod/lti/tests/behat/managecoursetools.feature
@@ -96,3 +96,23 @@ Feature: Manage course tools
And I should see "Test tool 20" in the "reportbuilder-table" "table"
And I click on "2" "link" in the "page" "region"
And I should see "Test tool 1" in the "reportbuilder-table" "table"
+
+ @javascript
+ Scenario: Delete a course tool
+ Given the following "mod_lti > course tools" exist:
+ | name | description | baseurl | course |
+ | Test tool | Example description | https://example.com/tool | C1 |
+ | Another tool | Example 123 | https://another.example.com/tool | C1 |
+ And I am on the "Course 1" course page logged in as teacher1
+ And I navigate to "LTI External tools" in current page administration
+ When I open the action menu in "Test tool" "table_row"
+ And I choose "Delete" in the open action menu
+ Then I should see "Are you sure you want to delete this course tool?"
+ And I click on "Cancel" "button" in the "Delete a course tool" "dialogue"
+ And I should see "Test tool" in the "reportbuilder-table" "table"
+ And I open the action menu in "Test tool" "table_row"
+ And I choose "Delete" in the open action menu
+ And I should see "Are you sure you want to delete this course tool?"
+ And I click on "Delete" "button" in the "Delete a course tool" "dialogue"
+ And I should see "Course tool deleted"
+ And I should not see "Test tool" in the "reportbuilder-table" "table"
diff --git a/mod/lti/tests/external/delete_course_tool_type_test.php b/mod/lti/tests/external/delete_course_tool_type_test.php
new file mode 100644
index 0000000000000..3d5ba8313626d
--- /dev/null
+++ b/mod/lti/tests/external/delete_course_tool_type_test.php
@@ -0,0 +1,82 @@
+.
+
+namespace mod_lti\external;
+
+use core_external\external_api;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+
+require_once($CFG->dirroot . '/mod/lti/tests/mod_lti_testcase.php');
+
+/**
+ * PHPUnit tests for delete_course_tool_type external function.
+ *
+ * @package mod_lti
+ * @copyright 2023 Jake Dallimore
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @coversDefaultClass \mod_lti\external\delete_course_tool_type
+ */
+class delete_course_tool_type_test extends \mod_lti_testcase {
+
+ /**
+ * Test delete_course_tool() for a course tool.
+ * @covers ::execute
+ */
+ public function test_delete_course_tool() {
+ $this->resetAfterTest();
+
+ $course = $this->getDataGenerator()->create_course();
+ $editingteacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+ $this->setUser($editingteacher);
+
+ $typeid = lti_add_type(
+ (object) [
+ 'state' => LTI_TOOL_STATE_CONFIGURED,
+ 'course' => $course->id
+ ],
+ (object) [
+ 'lti_typename' => "My course tool",
+ 'lti_toolurl' => 'http://example.com',
+ 'lti_ltiversion' => 'LTI-1p0'
+ ]
+ );
+
+ $data = delete_course_tool_type::execute($typeid);
+ $data = external_api::clean_returnvalue(delete_course_tool_type::execute_returns(), $data);
+
+ $this->assertTrue($data);
+ }
+
+ /**
+ * Test delete_course_tool() for a site tool, which is forbidden.
+ * @covers ::execute
+ */
+ public function test_delete_course_tool_site_tool() {
+ $this->resetAfterTest();
+
+ $course = $this->getDataGenerator()->create_course();
+ $editingteacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+ $this->setUser($editingteacher);
+
+ $type = $this->generate_tool_type(123); // Creates a site tool.
+
+ $this->expectException(\invalid_parameter_exception::class);
+ delete_course_tool_type::execute($type->id);
+ }
+}
diff --git a/mod/lti/tests/generator/lib.php b/mod/lti/tests/generator/lib.php
index 171399cb65dc1..ac037ce6e777f 100644
--- a/mod/lti/tests/generator/lib.php
+++ b/mod/lti/tests/generator/lib.php
@@ -101,12 +101,24 @@ public function create_tool_types(array $type, ?array $config = null) {
lti_add_type((object) $type, (object) $config);
}
- public function create_course_tool_types(array $type, ?array $config = null) {
+ /**
+ * Create a course tool type.
+ *
+ * @param array $type the type info.
+ * @param array|null $config the type configuration.
+ * @return void
+ * @throws coding_exception if any required fields are missing.
+ */
+ public function create_course_tool_types(array $type, ?array $config = null): void {
+ global $SITE;
+
if (!isset($type['baseurl'])) {
- throw new coding_exception('Must specify baseurl when creating a LTI tool type.');
+ throw new coding_exception('Must specify baseurl when creating a course tool type.');
+ }
+ if (!isset($type['course']) || $type['course'] == $SITE->id) {
+ throw new coding_exception('Must specify a non-site course when creating a course tool type.');
}
$type['coursevisible'] = LTI_COURSEVISIBLE_PRECONFIGURED; // The default for course tools.
lti_add_type((object) $type, (object) $config);
-
}
}
diff --git a/mod/lti/tests/mod_lti_testcase.php b/mod/lti/tests/mod_lti_testcase.php
index 7d3a9af71b56a..cdd82f866c7d1 100644
--- a/mod/lti/tests/mod_lti_testcase.php
+++ b/mod/lti/tests/mod_lti_testcase.php
@@ -21,6 +21,7 @@
global $CFG;
require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+require_once($CFG->dirroot . '/mod/lti/locallib.php');
/**
* Abstract base testcase for mod_lti unit tests.
@@ -47,7 +48,8 @@ protected function generate_tool_type(string $uniqueid, ?int $toolproxyid = null
$type->description = "Example description $uniqueid";
$type->toolproxyid = $toolproxyid;
$type->baseurl = $this->getExternalTestFileUrl("/test$uniqueid.html");
- lti_add_type($type, new stdClass());
+
+ $type->id = lti_add_type($type, new stdClass());
return $type;
}
diff --git a/mod/lti/version.php b/mod/lti/version.php
index 38e6946d1f7b7..6bb4402a54c39 100644
--- a/mod/lti/version.php
+++ b/mod/lti/version.php
@@ -48,7 +48,7 @@
defined('MOODLE_INTERNAL') || die;
-$plugin->version = 2023042400; // The current module version (Date: YYYYMMDDXX).
+$plugin->version = 2023070500; // The current module version (Date: YYYYMMDDXX).
$plugin->requires = 2023041800; // Requires this Moodle version.
$plugin->component = 'mod_lti'; // Full name of the plugin (used for diagnostics).
$plugin->cron = 0;