MDL-85738 ai: Add base support for AI access controls

This commit is contained in:
Stevani Andolo
2025-09-03 13:19:54 +08:00
parent 8e37adaddf
commit 29ef751472
27 changed files with 640 additions and 197 deletions
@@ -0,0 +1,11 @@
issueNumber: MDL-85738
notes:
core_ai:
- message: >
- Added `get_enabled_actions_in_course_module` method in public/ai/classes/manager.php to get enabled AI actions in course module.
- Added `is_ai_tools_enabled_in_course` method in public/ai/classes/manager.php to check if AI tools is enabled in course.
- Added `is_action_enabled_in_context` method in public/ai/classes/manager.php to check if an action is enabled in a particular context.
- Added `get_ai_fields_from_course_module` method in public/ai/classes/manager.php to get the AI related fields from the course module.
- Added `is_html_editor_placement_available` method in public/ai/placement/editor/classes/utils.php to check if editor placement is enabled.
- Added `get_actions_available` method in public/ai/placement/editor/classes/utils.php to get available actions for editor placement.
type: improved
+96
View File
@@ -28,6 +28,7 @@ use core\plugininfo\aiprovider as aiproviderplugin;
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class manager {
/**
* Create a new AI manager.
*
@@ -338,6 +339,36 @@ class manager {
}
}
/**
* Check if an action is enabled in a particular context.
*
* @param \context $context The context to use.
* @param string $actionclass The action class name to check.
* @return bool Return true enabled and allowed.
*/
public function is_action_enabled_in_context(\context $context, string $actionclass): bool {
// Only check if we are in a supported context.
if (in_array($context->contextlevel, [CONTEXT_COURSE, CONTEXT_COURSECAT, CONTEXT_MODULE])) {
// Return false if AI tools is not enabled at the course level.
if (!self::is_ai_tools_enabled_in_course($context)) {
return false;
}
if ($context->contextlevel == CONTEXT_MODULE) {
// Detect if this is a newly created module (doesn't have any AI settings yet).
$record = self::get_ai_fields_from_course_module($context->instanceid);
if (is_null($record->enabledaiactions)) {
return true;
}
// Check if the action is one of our enabled ones.
$enabledactions = self::get_enabled_actions_in_course_module($record);
return in_array($actionclass, $enabledactions);
}
}
return true;
}
/**
* Check if an action is available.
* Action is available if it is enabled for at least one enabled provider.
@@ -684,4 +715,69 @@ class manager {
}
return true;
}
/**
* Get the AI related fields from the course module.
*
* @param int $id The course module to check.
* @return \stdClass Return AI related fields.
*/
public static function get_ai_fields_from_course_module(int $id): \stdClass {
global $DB;
return $DB->get_record(
table: 'course_modules',
conditions: ['id' => $id],
fields: 'enableaitools, enabledaiactions',
);
}
/**
* Get the enabled actions in a course module context.
*
* @param \stdClass $record AI related fields from course module.
* @return array An array of enabled actions in the course module.
*/
public static function get_enabled_actions_in_course_module(\stdClass $record): array {
$enabledaiactions = [];
if (is_null($record->enableaitools) || $record->enableaitools) {
// Get AI action settings and determine which ones are enabled.
if (!empty($record->enabledaiactions)) {
$enabledaiactions = array_keys(
array_filter((array) json_decode($record->enabledaiactions), function ($value): bool {
return $value == 1;
})
);
// Set to classname format.
foreach ($enabledaiactions as $key => $action) {
$enabledaiactions[$key] = "core_ai\\aiactions\\{$action}";
}
}
}
return $enabledaiactions;
}
/**
* Check if AI tools are enabled in the course.
*
* @param \context $context The context to check.
* @return bool True if AI tools are enabled in the course, false otherwise.
*/
public static function is_ai_tools_enabled_in_course(\context $context): bool {
global $DB;
if (in_array($context->contextlevel, [CONTEXT_COURSE, CONTEXT_COURSECAT])) {
$courseid = $context->instanceid;
} else {
$courseid = $DB->get_field('course_modules', 'course', ['id' => $context->instanceid]);
}
$enableaitools = $DB->get_field('course', 'enableaitools', ['id' => $courseid]);
if (!is_null($enableaitools) && !$enableaitools) {
return false;
}
return true;
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
define("aiplacement_courseassist/selectors",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;return _exports.default={ELEMENTS:{AIDRAWER:"#ai-drawer",AIDRAWER_BODY:"#ai-drawer .ai-drawer-body",PAGE:"#page",MAIN_REGION:'[role="main"]',AIDRAWER_CLOSE:"#ai-drawer-close",RESPONSE:".course-assist-response",JUMPTO:'.course-assist-controls [data-region="jumpto"]',ACTION:'.course-assist-controls [data-input-type="action"]'},ACTIONS:{SUMMARY:'.course-assist-controls [data-action="summarise"]',EXPLAIN:'.course-assist-controls [data-action="explain"]',RETRY:'.course-assist-controls [data-action="retry"]',DECLINE:'.ai-policy-block [data-action="decline"]',ACCEPT:'.ai-policy-block [data-action="accept"]',REGENERATE:'.course-assist-controls [data-action="regenerate"]',CANCEL:'.course-assist-controls [data-action="cancel"]'}},_exports.default}));
define("aiplacement_courseassist/selectors",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;return _exports.default={ELEMENTS:{AIDRAWER:"#ai-drawer",AIDRAWER_BODY:"#ai-drawer .ai-drawer-body",PAGE:"#page",MAIN_REGION:'[role="main"]',AIDRAWER_CLOSE:"#ai-drawer-close",RESPONSE:".course-assist-response",JUMPTO:'.course-assist-controls [data-region="jumpto"]',ACTION:'.course-assist-controls [data-input-type="action"]'},ACTIONS:{SUMMARY:'.course-assist-controls [data-action="summarise_text"]',EXPLAIN:'.course-assist-controls [data-action="explain_text"]',RETRY:'.course-assist-controls [data-action="retry"]',DECLINE:'.ai-policy-block [data-action="decline"]',ACCEPT:'.ai-policy-block [data-action="accept"]',REGENERATE:'.course-assist-controls [data-action="regenerate"]',CANCEL:'.course-assist-controls [data-action="cancel"]'}},_exports.default}));
//# sourceMappingURL=selectors.min.js.map
@@ -1 +1 @@
{"version":3,"file":"selectors.min.js","sources":["../src/selectors.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 <http://www.gnu.org/licenses/>.\n\n/**\n * Define all of the selectors we will be using on the AI Course assistant.\n *\n * @module aiplacement_courseassist/selectors\n * @copyright 2024 Huong Nguyen <[email protected]>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default {\n ELEMENTS: {\n AIDRAWER: '#ai-drawer',\n AIDRAWER_BODY: '#ai-drawer .ai-drawer-body',\n PAGE: '#page',\n MAIN_REGION: '[role=\"main\"]',\n AIDRAWER_CLOSE: '#ai-drawer-close',\n RESPONSE: '.course-assist-response',\n JUMPTO: '.course-assist-controls [data-region=\"jumpto\"]',\n ACTION: '.course-assist-controls [data-input-type=\"action\"]',\n },\n ACTIONS: {\n SUMMARY: '.course-assist-controls [data-action=\"summarise\"]',\n EXPLAIN: '.course-assist-controls [data-action=\"explain\"]',\n RETRY: '.course-assist-controls [data-action=\"retry\"]',\n DECLINE: '.ai-policy-block [data-action=\"decline\"]',\n ACCEPT: '.ai-policy-block [data-action=\"accept\"]',\n REGENERATE: '.course-assist-controls [data-action=\"regenerate\"]',\n CANCEL: '.course-assist-controls [data-action=\"cancel\"]',\n }\n};\n"],"names":["ELEMENTS","AIDRAWER","AIDRAWER_BODY","PAGE","MAIN_REGION","AIDRAWER_CLOSE","RESPONSE","JUMPTO","ACTION","ACTIONS","SUMMARY","EXPLAIN","RETRY","DECLINE","ACCEPT","REGENERATE","CANCEL"],"mappings":"oLAsBe,CACXA,SAAU,CACNC,SAAU,aACVC,cAAe,6BACfC,KAAM,QACNC,YAAa,gBACbC,eAAgB,mBAChBC,SAAU,0BACVC,OAAQ,iDACRC,OAAQ,sDAEZC,QAAS,CACLC,QAAS,oDACTC,QAAS,kDACTC,MAAO,gDACPC,QAAS,2CACTC,OAAQ,0CACRC,WAAY,qDACZC,OAAQ"}
{"version":3,"file":"selectors.min.js","sources":["../src/selectors.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 <http://www.gnu.org/licenses/>.\n\n/**\n * Define all of the selectors we will be using on the AI Course assistant.\n *\n * @module aiplacement_courseassist/selectors\n * @copyright 2024 Huong Nguyen <[email protected]>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default {\n ELEMENTS: {\n AIDRAWER: '#ai-drawer',\n AIDRAWER_BODY: '#ai-drawer .ai-drawer-body',\n PAGE: '#page',\n MAIN_REGION: '[role=\"main\"]',\n AIDRAWER_CLOSE: '#ai-drawer-close',\n RESPONSE: '.course-assist-response',\n JUMPTO: '.course-assist-controls [data-region=\"jumpto\"]',\n ACTION: '.course-assist-controls [data-input-type=\"action\"]',\n },\n ACTIONS: {\n SUMMARY: '.course-assist-controls [data-action=\"summarise_text\"]',\n EXPLAIN: '.course-assist-controls [data-action=\"explain_text\"]',\n RETRY: '.course-assist-controls [data-action=\"retry\"]',\n DECLINE: '.ai-policy-block [data-action=\"decline\"]',\n ACCEPT: '.ai-policy-block [data-action=\"accept\"]',\n REGENERATE: '.course-assist-controls [data-action=\"regenerate\"]',\n CANCEL: '.course-assist-controls [data-action=\"cancel\"]',\n }\n};\n"],"names":["ELEMENTS","AIDRAWER","AIDRAWER_BODY","PAGE","MAIN_REGION","AIDRAWER_CLOSE","RESPONSE","JUMPTO","ACTION","ACTIONS","SUMMARY","EXPLAIN","RETRY","DECLINE","ACCEPT","REGENERATE","CANCEL"],"mappings":"oLAsBe,CACXA,SAAU,CACNC,SAAU,aACVC,cAAe,6BACfC,KAAM,QACNC,YAAa,gBACbC,eAAgB,mBAChBC,SAAU,0BACVC,OAAQ,iDACRC,OAAQ,sDAEZC,QAAS,CACLC,QAAS,yDACTC,QAAS,uDACTC,MAAO,gDACPC,QAAS,2CACTC,OAAQ,0CACRC,WAAY,qDACZC,OAAQ"}
@@ -80,7 +80,7 @@ const AICourseAssist = class {
if (summariseAction) {
e.preventDefault();
this.openAIDrawer();
this.lastAction = 'summarise';
this.lastAction = 'summarise_text';
this.actionElement.focus();
const isPolicyAccepted = await this.isPolicyAccepted();
if (!isPolicyAccepted) {
@@ -95,7 +95,7 @@ const AICourseAssist = class {
if (explainAction) {
e.preventDefault();
this.openAIDrawer();
this.lastAction = 'explain';
this.lastAction = 'explain_text';
this.actionElement.focus();
const isPolicyAccepted = await this.isPolicyAccepted();
if (!isPolicyAccepted) {
@@ -126,20 +126,26 @@ const AICourseAssist = class {
}
});
// Focus on the AI drawer's close button when the jump-to element is focused.
this.jumpToElement.addEventListener('focus', () => {
this.aiDrawerCloseElement.focus();
});
// Check if there is course assist control region in the page.
if (this.jumpToElement) {
// Focus on the AI drawer's close button when the jump-to element is focused.
this.jumpToElement.addEventListener('focus', () => {
this.aiDrawerCloseElement.focus();
});
}
// Focus on the action element when the AI drawer container receives focus.
this.aiDrawerElement.addEventListener('focus', () => {
this.actionElement.focus();
});
// Remove active from the action element when it loses focus.
this.actionElement.addEventListener('blur', () => {
this.actionElement.classList.remove('active');
});
// Check if the action element exists.
if (this.actionElement) {
// Remove active from the action element when it loses focus.
this.actionElement.addEventListener('blur', () => {
this.actionElement.classList.remove('active');
});
}
}
/**
@@ -323,12 +329,12 @@ const AICourseAssist = class {
let params = {};
switch (action) {
case 'summarise':
case 'summarise_text':
params.method = 'aiplacement_courseassist_summarise_text';
params.heading = await getString('aisummary', 'aiplacement_courseassist');
break;
case 'explain':
case 'explain_text':
params.method = 'aiplacement_courseassist_explain_text';
params.heading = await getString('aiexplain', 'aiplacement_courseassist');
break;
@@ -32,8 +32,8 @@ export default {
ACTION: '.course-assist-controls [data-input-type="action"]',
},
ACTIONS: {
SUMMARY: '.course-assist-controls [data-action="summarise"]',
EXPLAIN: '.course-assist-controls [data-action="explain"]',
SUMMARY: '.course-assist-controls [data-action="summarise_text"]',
EXPLAIN: '.course-assist-controls [data-action="explain_text"]',
RETRY: '.course-assist-controls [data-action="retry"]',
DECLINE: '.ai-policy-block [data-action="decline"]',
ACCEPT: '.ai-policy-block [data-action="accept"]',
@@ -78,7 +78,9 @@ class explain_text extends external_api {
// Check the user has permission to use the AI service.
self::validate_context($context);
if (!utils::is_course_assist_available($context)) {
// Check if AI Placement course assist is available.
if (!utils::is_course_assist_available()) {
throw new \moodle_exception('nocourseassist', 'aiplacement_courseassist');
}
@@ -78,7 +78,9 @@ class summarise_text extends external_api {
// Check the user has permission to use the AI service.
self::validate_context($context);
if (!utils::is_course_assist_available($context)) {
// Check if AI Placement course assist is available.
if (!utils::is_course_assist_available()) {
throw new \moodle_exception('nocourseassist', 'aiplacement_courseassist');
}
@@ -102,6 +102,6 @@ class assist_ui {
}
// Check if the user has permission to use the AI service.
return utils::is_course_assist_available($PAGE->context);
return utils::is_course_assist_available();
}
}
@@ -29,22 +29,17 @@ use core_ai\manager;
*/
class utils {
/**
* Check if AI Placement course assist is available for the context.
* Check if AI Placement course assist is available.
*
* @param \context $context The context.
* @return bool True if AI Placement course assist is available, false otherwise.
*/
public static function is_course_assist_available(\context $context): bool {
public static function is_course_assist_available(): bool {
[$plugintype, $pluginname] = explode('_', \core_component::normalize_componentname('aiplacement_courseassist'), 2);
$pluginmanager = \core_plugin_manager::resolve_plugininfo_class($plugintype);
if (!$pluginmanager::is_plugin_enabled($pluginname)) {
return false;
}
if (empty(self::get_actions_available($context))) {
return false;
}
return true;
}
@@ -52,37 +47,34 @@ class utils {
* Get all the actions available and return action data for template.
*
* @param \context $context The context.
* @param bool $checkcontext If true, check the action is available in context.
* @return array Return the actions available with data.
*/
public static function get_actions_available(\context $context): array {
public static function get_actions_available(\context $context, bool $checkcontext = true): array {
$actions = [];
$actionclasses = [
summarise_text::class,
explain_text::class,
];
$manager = \core\di::get(manager::class);
$providers = $manager->get_providers_for_actions($actionclasses, true);
// Summarise text.
if (has_capability('aiplacement/courseassist:summarise_text', $context)
&& $manager->is_action_available(summarise_text::class)
&& $manager->is_action_enabled('aiplacement_courseassist', summarise_text::class)
&& !empty($providers[summarise_text::class])
&& (!$checkcontext || $manager->is_action_enabled_in_context($context, summarise_text::class))
) {
$actions[] = [
'action' => 'summarise',
'action' => 'summarise_text',
'buttontext' => get_string('summarise', 'aiplacement_courseassist'),
'title' => get_string('summarise_tooltips', 'aiplacement_courseassist'),
];
}
// Explain text.
if (has_capability('aiplacement/courseassist:explain_text', $context)
&& $manager->is_action_available(explain_text::class)
&& $manager->is_action_enabled('aiplacement_courseassist', explain_text::class)
&& !empty($providers[explain_text::class])
&& (!$checkcontext || $manager->is_action_enabled_in_context($context, explain_text::class))
) {
$actions[] = [
'action' => 'explain',
'action' => 'explain_text',
'buttontext' => get_string('explain', 'aiplacement_courseassist'),
'title' => get_string('explain_tooltips', 'aiplacement_courseassist'),
];
@@ -26,7 +26,7 @@
Example context (json):
{
"isdropdown": true,
"action": "explain",
"action": "explain_text",
"buttontext": "Explain",
"title": "Create an AI-generated explanation of the page content"
}
@@ -28,12 +28,12 @@
"isdropdown": true,
"actions": [
{
"action": "summarise",
"action": "summarise_text",
"buttontext": "Summarise",
"title": "Create an AI-generated summary of the page content"
},
{
"action": "explain",
"action": "explain_text",
"buttontext": "Explain",
"title": "Create an AI-generated explanation of the page content"
}
@@ -26,12 +26,12 @@
{
"actions": [
{
"action": "summarise",
"action": "summarise_text",
"buttontext": "Summarise",
"title": "Create an AI-generated summary of the page content"
},
{
"action": "explain",
"action": "explain_text",
"buttontext": "Explain",
"title": "Create an AI-generated explanation of the page content"
}
@@ -28,7 +28,7 @@
{
"content": "<p>Content to display</p>",
"heading": "AI Explain",
"action": "explain"
"action": "explain_text"
}
}}
<section class="course-assist-response card mb-3" data-action-performed="{{action}}">
@@ -16,9 +16,10 @@
namespace aiplacement_courseassist;
use core_ai\aiactions\generate_text;
use core_ai\aiactions\summarise_text;
use core_ai\manager;
use core_ai\ai_test_trait;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/../../../tests/ai_test_trait.php');
/**
* AI Placement course assist utils test.
@@ -29,103 +30,103 @@ use core_ai\manager;
* @covers \aiplacement_courseassist\utils
*/
final class utils_test extends \advanced_testcase {
use ai_test_trait;
/** @var array List of users. */
private array $users;
/** @var \stdClass Course object. */
private \stdClass $course;
/** @var \context_course Course context. */
private \context_course $context;
/** @var \stdClass Teacher role. */
private \stdClass $teacherrole;
public function setUp(): void {
global $DB;
parent::setUp();
$this->resetAfterTest();
$this->users[1] = $this->getDataGenerator()->create_user();
$this->users[2] = $this->getDataGenerator()->create_user();
$this->course = $this->getDataGenerator()->create_course();
$this->context = \context_course::instance($this->course->id);
$this->teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']);
$this->getDataGenerator()->enrol_user($this->users[1]->id, $this->course->id, 'manager');
$this->getDataGenerator()->enrol_user($this->users[2]->id, $this->course->id, 'editingteacher');
}
/**
* Data provider for supported placement tests.
*
* @return array
*/
public static function course_assist_actions_available_provider(): array {
return [
'Two actions' => [
'actionstouse' => [
'summarise_text',
'explain_text',
],
'expectedcount' => 2,
],
'Summarise only' => [
'actionstouse' => [
'summarise_text',
],
'expectedcount' => 1,
],
'Explain only' => [
'actionstouse' => [
'explain_text',
],
'expectedcount' => 1,
],
'No actions' => [
'actionstouse' => [],
'expectedcount' => 0,
],
];
}
/**
* Test is_course_assist_available method.
*/
public function test_is_course_assist_available(): void {
global $DB;
$this->resetAfterTest();
$user1 = $this->getDataGenerator()->create_user();
$user2 = $this->getDataGenerator()->create_user();
$course = $this->getDataGenerator()->create_course();
$context = \context_course::instance($course->id);
$teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']);
$this->getDataGenerator()->enrol_user($user1->id, $course->id, 'manager');
$this->getDataGenerator()->enrol_user($user2->id, $course->id, 'editingteacher');
// Provider is not enabled.
$this->setUser($user1);
$this->assertFalse(utils::is_course_assist_available($context));
// Provider is enabled, but plugin is not enabled.
set_config('enabled', 1, 'aiprovider_openai');
set_config('apikey', '123', 'aiprovider_openai');
set_config('enabled', 0, 'aiplacement_courseassist');
$this->assertFalse(utils::is_course_assist_available($context));
// Plugin is enabled but user does not have capability.
assign_capability('aiplacement/courseassist:summarise_text', CAP_PROHIBIT, $teacherrole->id, $context);
assign_capability('aiplacement/courseassist:explain_text', CAP_PROHIBIT, $teacherrole->id, $context);
$this->setUser($user2);
set_config('enabled', 1, 'aiplacement_courseassist');
$this->assertFalse(utils::is_course_assist_available($context));
$this->assertTrue(utils::is_course_assist_available());
// Plugin is enabled, user has capability and placement action is not available.
$this->setUser($user1);
set_config('summarise_text', 0, 'aiplacement_courseassist');
set_config('explain_text', 0, 'aiplacement_courseassist');
$this->assertFalse(utils::is_course_assist_available($context));
// Plugin is enabled, user has capability and provider action is not available.
$this->setUser($user1);
set_config('summarise_text', 0, 'aiprovider_openai');
set_config('summarise_text', 1, 'aiplacement_courseassist');
set_config('explain_text', 0, 'aiprovider_openai');
set_config('explain_text', 1, 'aiplacement_courseassist');
$this->assertFalse(utils::is_course_assist_available($context));
// Plugin is enabled, user has capability, placement action is available and provider action is available.
$mockmanager = $this->createMock(manager::class);
$mockmanager->method('is_action_available')->willReturn(true);
$mockmanager->method('is_action_enabled')->willReturn(true);
$mockmanager->method('get_providers_for_actions')->willReturn([
summarise_text::class => ['aiprovider_openai'],
]);
\core\di::set(manager::class, function() use ($mockmanager) {
return $mockmanager;
});
$this->setUser($user1);
set_config('summarise_text', 1, 'aiplacement_courseassist');
set_config('explain_text', 1, 'aiprovider_openai');
set_config('explain_text', 1, 'aiplacement_courseassist');
$this->assertTrue(utils::is_course_assist_available($context));
set_config('enabled', 0, 'aiplacement_courseassist');
$this->assertFalse(utils::is_course_assist_available());
}
/**
* Test get_actions_available method.
*
* @param array $actionstouse The actions to use.
* @param int $expectedcount Expected count of actions.
* @dataProvider course_assist_actions_available_provider
*/
public function test_get_actions_available(): void {
global $DB;
$this->resetAfterTest();
$user1 = $this->getDataGenerator()->create_user();
$course = $this->getDataGenerator()->create_course();
$context = \context_course::instance($course->id);
$this->getDataGenerator()->enrol_user($user1->id, $course->id, 'manager');
$this->setUser($user1);
public function test_get_actions_available(
array $actionstouse,
int $expectedcount,
): void {
$this->setUser($this->users[2]);
// Set up the provider with the required action config.
$this->create_ai_provider($actionstouse, \aiprovider_openai\provider::class);
set_config('enabled', 1, 'aiplacement_courseassist');
// Two actions enabled.
set_config('enabled', 1, 'aiprovider_openai');
set_config('apikey', '123', 'aiprovider_openai');
set_config('explain_text', 1, 'aiplacement_courseassist');
set_config('summarise_text', 1, 'aiplacement_courseassist');
$manager = \core\di::get(manager::class);
$manager->create_provider_instance(
classname: '\aiprovider_openai\provider',
name: 'dummy',
enabled: true,
config: ['apikey' => '123'],
);
$this->assertCount(2, utils::get_actions_available($context));
// Enable the actions and check the count.
foreach ($actionstouse as $action) {
set_config($action, 1, 'aiplacement_courseassist');
}
$actions = utils::get_actions_available($this->context, true);
$this->assertCount($expectedcount, $actions);
// One action enabled.
set_config('summarise_text', 0, 'aiplacement_courseassist');
$this->assertCount(1, utils::get_actions_available($context));
// No actions enabled.
set_config('explain_text', 0, 'aiplacement_courseassist');
$this->assertCount(0, utils::get_actions_available($context));
// Prohibit the user and check again.
foreach ($actionstouse as $action) {
assign_capability("aiplacement/courseassist:{$action}", CAP_PROHIBIT, $this->teacherrole->id, $this->context);
}
$actions = utils::get_actions_available($this->context, true);
$this->assertCount(0, $actions);
}
}
+55 -5
View File
@@ -16,6 +16,8 @@
namespace aiplacement_editor;
use core_ai\aiactions\generate_image;
use core_ai\aiactions\generate_text;
use core_ai\manager;
/**
@@ -33,16 +35,16 @@ class utils {
* @param \context $context The context.
* @param string $actionname The name of the action.
* @param string $actionclass The class name of the action.
* @return bool True if the action is available, false otherwise.
* @param bool $checkcontext If true, check the action is available in context.
* @return bool If the action is accessible, available, and enable.
*/
public static function is_html_editor_placement_action_available(
\context $context,
string $actionname,
string $actionclass
string $actionclass,
bool $checkcontext = true,
): bool {
[$plugintype, $pluginname] = explode('_', \core_component::normalize_componentname('aiplacement_editor'), 2);
$pluginmanager = \core_plugin_manager::resolve_plugininfo_class($plugintype);
if (!$pluginmanager::is_plugin_enabled($pluginname)) {
if (!self::is_html_editor_placement_available()) {
return false;
}
@@ -51,9 +53,57 @@ class utils {
has_capability("aiplacement/editor:{$actionname}", $context)
&& $aimanager->is_action_available($actionclass)
&& $aimanager->is_action_enabled('aiplacement_editor', $actionclass)
&& (!$checkcontext || $aimanager->is_action_enabled_in_context($context, $actionclass))
) {
return true;
}
return false;
}
/**
* Check if AI Placement HTML editor is available.
*
* @return bool If the placement is enabled.
*/
public static function is_html_editor_placement_available(): bool {
[$plugintype, $pluginname] = explode('_', \core_component::normalize_componentname('aiplacement_editor'), 2);
$pluginmanager = \core_plugin_manager::resolve_plugininfo_class($plugintype);
if (!$pluginmanager::is_plugin_enabled($pluginname)) {
return false;
}
return true;
}
/**
* Get all the actions available for HTML editor placement.
*
* @param \context $context The context.
* @param bool $checkcontext If true, check the action is available in context.
* @return array Return the actions available with data.
*/
public static function get_actions_available(\context $context, bool $checkcontext = true): array {
$actions = [];
// Action generate_text.
if (self::is_html_editor_placement_action_available($context, 'generate_text', generate_text::class, $checkcontext)) {
$actions[] = [
'action' => 'generate_text',
'buttontext' => get_string('action_generate_text', 'core_ai'),
'title' => get_string('action_generate_text_desc', 'core_ai'),
];
}
// Action generate_image.
if (self::is_html_editor_placement_action_available($context, 'generate_image', generate_image::class, $checkcontext)) {
$actions[] = [
'action' => 'generate_image',
'buttontext' => get_string('action_generate_image', 'core_ai'),
'title' => get_string('action_generate_image_desc', 'core_ai'),
];
}
return $actions;
}
}
@@ -49,6 +49,7 @@ final class generate_text_test extends \advanced_testcase {
$mockmanager->method('process_action')->willReturn($response);
$mockmanager->method('is_action_available')->willReturn(true);
$mockmanager->method('is_action_enabled')->willReturn(true);
$mockmanager->method('is_action_enabled_in_context')->willReturn(true);
\core\di::set(\core_ai\manager::class, function() use ($mockmanager) {
return $mockmanager;
});
+97 -59
View File
@@ -16,8 +16,10 @@
namespace aiplacement_editor;
use core_ai\aiactions\generate_image;
use core_ai\aiactions\generate_text;
use core_ai\ai_test_trait;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/../../../tests/ai_test_trait.php');
/**
* Text editor placement utils test.
@@ -25,9 +27,11 @@ use core_ai\aiactions\generate_text;
* @package aiplacement_editor
* @copyright 2024 Huong Nguyen <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \aiplacement_courseassist\utils
* @covers \aiplacement_editor\utils
*/
final class utils_test extends \advanced_testcase {
use ai_test_trait;
/** @var array List of users. */
private array $users;
/** @var \stdClass Course object. */
@@ -52,36 +56,52 @@ final class utils_test extends \advanced_testcase {
}
/**
* Test is_html_editor_placement_action_available method.
* Data provider for supported placement tests.
*
* @param string $actionname Action name.
* @param string $actionclass Action class.
* @dataProvider html_editor_placement_action_available_provider
* @return array
*/
public function test_is_html_editor_placement_action_available(
string $actionname,
string $actionclass,
): void {
// Provider is not enabled.
$this->setUser($this->users[1]);
$this->assertFalse(utils::is_html_editor_placement_action_available(
context: $this->context,
actionname: $actionname,
actionclass: $actionclass
));
public static function html_editor_placement_actions_available_provider(): array {
return [
'Two actions' => [
'actionstouse' => [
'generate_text',
'generate_image',
],
'expectedcount' => 2,
],
'Generate text only' => [
'actionstouse' => [
'generate_text',
],
'expectedcount' => 1,
],
'Generate image only' => [
'actionstouse' => [
'generate_image',
],
'expectedcount' => 1,
],
'No actions' => [
'actionstouse' => [],
'expectedcount' => 0,
],
];
}
// Plugin is not enabled.
$this->setUser($this->users[1]);
/**
* Test is_html_editor_placement_action_available method.
*/
public function test_is_html_editor_placement_action_available(): void {
// Everything is disabled to begin with, and user does not have capability.
// Sequentially enable settings until all conditions are met.
$actionname = 'generate_text';
$actionclass = 'core_ai\\aiactions\\' . $actionname;
set_config('enabled', 0, 'aiplacement_editor');
$this->assertFalse(utils::is_html_editor_placement_action_available(
context: $this->context,
actionname: $actionname,
actionclass: $actionclass
));
// Plugin is enabled but user does not have capability.
set_config($actionname, 0, 'aiplacement_editor');
assign_capability("aiplacement/editor:{$actionname}", CAP_PROHIBIT, $this->teacherrole->id, $this->context);
$this->setUser($this->users[2]);
// Enable the placement plugin.
set_config('enabled', 1, 'aiplacement_editor');
$this->assertFalse(utils::is_html_editor_placement_action_available(
context: $this->context,
@@ -89,33 +109,25 @@ final class utils_test extends \advanced_testcase {
actionclass: $actionclass
));
// Plugin is enabled, user has capability and placement action is not available.
$this->setUser($this->users[1]);
set_config($actionname, 0, 'aiplacement_editor');
// Enable the provider.
$this->create_ai_provider([$actionname], \aiprovider_openai\provider::class);
$this->assertFalse(utils::is_html_editor_placement_action_available(
context: $this->context,
actionname: $actionname,
actionclass: $actionclass
));
// Plugin is enabled, user has capability and provider action is not available.
// Switch to a user with the required capability.
$this->setUser($this->users[1]);
$this->assertFalse(utils::is_html_editor_placement_action_available(
context: $this->context,
actionname: $actionname,
actionclass: $actionclass
));
// Enable the action for the placement plugin.
// All requirements should now be met.
set_config($actionname, 1, 'aiplacement_editor');
$this->assertFalse(utils::is_html_editor_placement_action_available(
context: $this->context,
actionname: $actionname,
actionclass: $actionclass
));
// Plugin is enabled, user has capability, placement action is available and provider action is available.
$mockmanager = $this->createMock(\core_ai\manager::class);
$mockmanager->method('is_action_available')->willReturn(true);
$mockmanager->method('is_action_enabled')->willReturn(true);
\core\di::set(\core_ai\manager::class, function() use ($mockmanager) {
return $mockmanager;
});
$this->setUser($this->users[1]);
$this->assertTrue(utils::is_html_editor_placement_action_available(
context: $this->context,
actionname: $actionname,
@@ -124,20 +136,46 @@ final class utils_test extends \advanced_testcase {
}
/**
* Data provider for {@see test_is_html_editor_placement_action_available}
* Test get_actions_available method.
*
* @return array
* @param array $actionstouse The actions to use.
* @param int $expectedcount Expected count of actions.
* @dataProvider html_editor_placement_actions_available_provider
*/
public static function html_editor_placement_action_available_provider(): array {
return [
'Text generation' => [
'generate_text',
generate_text::class,
],
'Image generation' => [
'generate_image',
generate_image::class,
],
];
public function test_get_actions_available(
array $actionstouse,
int $expectedcount,
): void {
$this->setUser($this->users[2]);
// Set up the provider with the required action config.
$this->create_ai_provider($actionstouse, \aiprovider_openai\provider::class);
set_config('enabled', 1, 'aiplacement_editor');
// Enable the actions and check the count.
foreach ($actionstouse as $action) {
set_config($action, 1, 'aiplacement_editor');
}
$actions = utils::get_actions_available($this->context, true);
$this->assertCount($expectedcount, $actions);
// Prohibit the user and check again.
foreach ($actionstouse as $action) {
assign_capability("aiplacement/editor:{$action}", CAP_PROHIBIT, $this->teacherrole->id, $this->context);
}
$actions = utils::get_actions_available($this->context, true);
$this->assertCount(0, $actions);
}
/**
* Test is_html_editor_placement_available method.
*/
public function test_is_html_editor_placement_available(): void {
// Plugin is not enabled.
set_config('enabled', 0, 'aiplacement_editor');
$this->assertFalse(utils::is_html_editor_placement_available());
// Plugin is enabled.
set_config('enabled', 1, 'aiplacement_editor');
$this->assertTrue(utils::is_html_editor_placement_available());
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace core_ai;
/**
* Test trait for AI.
*
* @package core_ai
* @category test
* @copyright 2025 Stevani Andolo <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
trait ai_test_trait {
/**
* Creates a dummy AI provider.
*
* @param array $actions A set of actions to configure the provider with.
* @param string $providerclass
*/
private function create_ai_provider(array $actions, $providerclass): void {
global $DB;
$actionconfig = [];
foreach ($actions as $action) {
$actionclass = 'core_ai\\aiactions\\' . $action;
$actionconfig[$actionclass] = [
'enabled' => true,
'settings' => [
'model' => 'test',
'endpoint' => 'test',
'systeminstruction' => 'test',
],
];
}
$config = ['apikey' => 'test'];
$record = new \stdClass();
$record->name = 'test';
$record->provider = $providerclass;
$record->enabled = 1;
$record->config = json_encode($config);
$record->actionconfig = json_encode($actionconfig);
$DB->insert_record('ai_providers', $record);
}
}
+138
View File
@@ -746,4 +746,142 @@ final class manager_test extends \advanced_testcase {
$result = $manager->is_action_available($action);
$this->assertFalse($result);
}
/**
* Test is_ai_tools_enabled_in_course method.
*
* @return void
*/
public function test_is_ai_tools_enabled_in_course(): void {
global $DB;
$this->resetAfterTest();
$generator = $this->getDataGenerator();
$course = $generator->create_course();
$context = \context_course::instance($course->id);
$manager = \core\di::get(manager::class);
$aitoolsenabled = $manager::is_ai_tools_enabled_in_course($context);
$this->assertTrue($aitoolsenabled);
$course->enableaitools = 0;
$DB->update_record('course', $course);
$aitoolsenabled = $manager::is_ai_tools_enabled_in_course($context);
$this->assertFalse($aitoolsenabled);
}
/**
* Test get_enabled_actions_in_course_module method.
*
* @param string $enabledactions
* @param array $expectedactions
* @dataProvider ai_actions_provider
* @return void
*/
public function test_get_enabled_actions_in_course_module(
string $enabledactions,
array $expectedactions,
): void {
global $PAGE;
$this->resetAfterTest();
$manager = \core\di::get(manager::class);
$generator = $this->getDataGenerator();
$course = $generator->create_course();
// Create forum module and add enabled AI actions.
$module = $generator->create_module('forum', [
'course' => $course->id,
'enabledaiactions' => $enabledactions,
]);
// Set the page context to the module context.
$ctx = \context_module::instance($module->cmid);
$PAGE->set_context($ctx);
// Get all enabled actions in a course module.
$record = $manager::get_ai_fields_from_course_module($module->cmid);
$allactions = $manager::get_enabled_actions_in_course_module($record);
foreach ($expectedactions as $expectedaction) {
$this->assertContains($expectedaction, $allactions);
}
$this->assertCount(count($expectedactions), $allactions);
}
/**
* Test is_action_enabled_in_context method.
*
* @return void
*/
public function test_is_action_enabled_in_context(): void {
global $PAGE;
$this->resetAfterTest();
$manager = \core\di::get(manager::class);
$generator = $this->getDataGenerator();
$course = $generator->create_course();
// Create forum module and enabled only the generate text action.
$module = $generator->create_module('forum', [
'course' => $course->id,
'enabledaiactions' => json_encode(['generate_text' => 1]),
]);
// Set the page context to the module context.
$modulecontext = \context_module::instance($module->cmid);
$PAGE->set_context($modulecontext);
// Only the generate text action should be available.
$result = $manager->is_action_enabled_in_context($modulecontext, generate_text::class);
$this->assertTrue($result);
$result = $manager->is_action_enabled_in_context($modulecontext, explain_text::class);
$this->assertFalse($result);
// Explain text should be available outside the module context.
$systemcontext = \context_system::instance();
$result = $manager->is_action_enabled_in_context($systemcontext, explain_text::class);
$this->assertTrue($result);
}
/**
* Data provider for {@see test_get_enabled_actions_in_course_module}
*
* @return array
*/
public static function ai_actions_provider(): array {
return [
'actioncombo1' => [
json_encode(['generate_text' => 1, 'generate_image' => 1]),
[
generate_text::class,
generate_image::class,
],
],
'actioncombo2' => [
json_encode(['summarise_text' => 1, 'explain_text' => 1]),
[
summarise_text::class,
explain_text::class,
],
],
'actioncombo3' => [
json_encode(['summarise_text' => 1, 'explain_text' => 0, 'generate_text' => 1, 'generate_image' => 1]),
[
summarise_text::class,
generate_text::class,
generate_image::class,
],
],
'actioncombo4' => [
json_encode(['summarise_text' => 0, 'explain_text' => 1, 'generate_text' => 0, 'generate_image' => 0]),
[
explain_text::class,
],
],
];
}
}
+13
View File
@@ -26,6 +26,7 @@ $string['acceptai'] = 'Accept and continue';
$string['action'] = 'Action';
$string['action_explain_text'] = 'Explain text';
$string['action_explain_text_desc'] = 'Explains the text content on a course page.';
$string['action_explain_text_help'] = 'Provides an explanation that expands on key ideas, simplifies complex concepts, and adds context to make the text easier to understand.';
$string['action_explain_text_instruction'] = 'You will receive a text input from the user. Your task is to explain the provided text. Follow these guidelines:
1. Elaborate: Expand on key ideas and concepts, ensuring the explanation adds meaningful depth and avoids restating the text verbatim.
2. Simplify: Break down complex terms or ideas into simpler components, making them easy to understand for a wide audience, including learners.
@@ -40,13 +41,16 @@ Important Instructions:
Ensure the explanation is easy to read and effectively conveys the main points of the original text.';
$string['action_generate_image'] = 'Generate image';
$string['action_generate_image_desc'] = 'Generates an image based on a text prompt.';
$string['action_generate_image_help'] = 'Creates an image based on a prompt.';
$string['action_generate_text'] = 'Generate text';
$string['action_generate_text_desc'] = 'Generates text based on a text prompt.';
$string['action_generate_text_help'] = 'Creates a text based on a prompt.';
$string['action_generate_text_instruction'] = 'You will receive a text input from the user. Your task is to generate text based on their request. Follow these important instructions:
1. Return the summary in plain text only.
2. Do not include any markdown formatting, greetings, or platitudes.';
$string['action_summarise_text'] = 'Summarise text';
$string['action_summarise_text_desc'] = 'Summarises the text content on a course page.';
$string['action_summarise_text_help'] = 'Creates a brief summary of the content in a page.';
$string['action_summarise_text_instruction'] = 'You will receive a text input from the user. Your task is to summarize the provided text. Follow these guidelines:
1. Condense: Shorten long passages into key points.
2. Simplify: Make complex information easier to understand, especially for learners.
@@ -64,12 +68,17 @@ $string['actionsettingprovider_desc'] = 'These settings control how the {$a->pro
$string['actionsettings'] = 'Action settings';
$string['actionsettings_desc'] = 'These settings control the AI actions for this provider instance.';
$string['ai'] = 'AI';
$string['aiactionshdr'] = 'Select AI features for this activity:';
$string['aiactionregister'] = 'AI action register';
$string['aiplacements'] = 'AI placements';
$string['aipolicyacceptance'] = 'AI policy acceptance';
$string['aipolicyregister'] = 'AI policy register';
$string['aiproviders'] = 'AI providers';
$string['aireports'] = 'AI reports';
$string['aitools'] = 'AI tools';
$string['aitoolsincourseactivitydesc'] = 'If set to Yes, you can specify which AI features will be available.';
$string['aitoolsincoursedesc'] = 'If set to Yes, AI tools will be available for activities in this course. AI tools can be configured in each activity\'s setting.';
$string['aitoolsnotenabled'] = 'To specify which AI features you want to be available in this activity. Go to course settings and allow AI Tools.';
$string['aiusage'] = 'AI usage';
$string['aiusagepolicy'] = 'AI usage policy';
$string['availableplacements'] = 'Choose where AI actions are available';
@@ -86,6 +95,8 @@ $string['contentwatermark'] = 'Generated by AI';
$string['createnewprovider'] = 'Create a new provider instance';
$string['dateaccepted'] = 'Date accepted';
$string['declineaipolicy'] = 'Decline';
$string['enableaitoolsincourse'] = 'Allow AI tools for this course';
$string['enableaitoolsincourseactivity'] = 'Allow AI tools in this activity';
$string['enableglobalratelimit'] = 'Set site-wide rate limit';
$string['enableglobalratelimit_help'] = 'Limit the number of requests that the AI provider can receive across the entire site every hour.';
$string['enableuserratelimit'] = 'Set user rate limit';
@@ -113,6 +124,8 @@ $string['globalratelimit_help'] = 'The number of site-wide requests allowed per
$string['manageaiplacements'] = 'Manage AI placements';
$string['manageaiproviders'] = 'Manage AI providers';
$string['noproviders'] = 'This action is unavailable. No <a href="{$a}">AI providers</a> are configured for this action.';
$string['off'] = 'Off';
$string['on'] = 'On';
$string['placement'] = 'Placement';
$string['placementactionsettings'] = 'Actions';
$string['placementactionsettings_desc'] = 'The AI actions available for this placement.';
+1 -1
View File
@@ -1394,7 +1394,7 @@ function get_all_instances_in_courses($modulename, $courses, $userid=NULL, $incl
$params['modulename'] = $modulename;
if (!$rawmods = $DB->get_records_sql("SELECT cm.id AS coursemodule, m.*, cw.section, cm.visible AS visible,
cm.groupmode, cm.groupingid, cm.lang
cm.groupmode, cm.groupingid, cm.lang, cm.enableaitools, cm.enabledaiactions
FROM {course_modules} cm, {course_sections} cw, {modules} md,
{".$modulename."} m
WHERE cm.course $coursessql AND
+3
View File
@@ -106,6 +106,7 @@
<FIELD NAME="showactivitydates" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Whether to display activity dates to user. 0 = do not display, 1 = display activity dates"/>
<FIELD NAME="showcompletionconditions" TYPE="int" LENGTH="1" NOTNULL="false" SEQUENCE="false" COMMENT="Whether to display completion conditions to user. 0 = do not display, 1 = display conditions"/>
<FIELD NAME="pdfexportfont" TYPE="char" LENGTH="50" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="enableaitools" TYPE="int" LENGTH="1" NOTNULL="false" SEQUENCE="false" COMMENT="Whether to allow the use of AI tools in this course. 1 = enabled, 0 = disabled."/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
@@ -345,6 +346,8 @@
<FIELD NAME="deletioninprogress" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="downloadcontent" TYPE="int" LENGTH="1" NOTNULL="false" DEFAULT="1" SEQUENCE="false" COMMENT="Whether the ability to download course module content is enabled for this activity"/>
<FIELD NAME="lang" TYPE="char" LENGTH="30" NOTNULL="false" SEQUENCE="false" COMMENT="Forced language for this activity. Null or '' means 'Do not force'. Otherwise a Moodle lang pack name like 'fr' or 'en_us'."/>
<FIELD NAME="enableaitools" TYPE="int" LENGTH="1" NOTNULL="false" SEQUENCE="false" COMMENT="Whether to allow the use of AI tools in this course module. 1 = enabled, 0 = disabled."/>
<FIELD NAME="enabledaiactions" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="List of enabled AI actions on this course module"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+31
View File
@@ -2094,5 +2094,36 @@ function xmldb_main_upgrade($oldversion) {
upgrade_main_savepoint(true, 2025082900.01);
}
if ($oldversion < 2025090200.01) {
// Define field enableaitools to be added to course.
$table = new xmldb_table('course');
$field = new xmldb_field('enableaitools', XMLDB_TYPE_INTEGER, '1', null, null, null, null, 'pdfexportfont');
// Conditionally launch add field enableaitools.
if (!$dbman->field_exists($table, $field)) {
$dbman->add_field($table, $field);
}
// Define field enableaitools to be added to course_modules.
$table = new xmldb_table('course_modules');
$field = new xmldb_field('enableaitools', XMLDB_TYPE_INTEGER, '1', null, null, null, null, 'lang');
// Conditionally launch add field enableaitools.
if (!$dbman->field_exists($table, $field)) {
$dbman->add_field($table, $field);
}
// Define field enabledaiactions to be added to course_modules.
$field = new xmldb_field('enabledaiactions', XMLDB_TYPE_TEXT, null, null, null, null, null, 'enableaitools');
// Conditionally launch add field enabledaiactions.
if (!$dbman->field_exists($table, $field)) {
$dbman->add_field($table, $field);
}
// Main savepoint reached.
upgrade_main_savepoint(true, 2025090200.01);
}
return true;
}
+1 -1
View File
@@ -29,7 +29,7 @@
defined('MOODLE_INTERNAL') || die();
$version = 2025090200.00; // YYYYMMDD = weekly release date of this DEV branch.
$version = 2025090200.01; // YYYYMMDD = weekly release date of this DEV branch.
// RR = release increments - 00 in DEV branches.
// .XX = incremental changes.
$release = '5.1dev+ (Build: 20250902)'; // Human-friendly version name