Merge branch 'MDL-85738-main' of https://github.com/stevandoMoodle/moodle
This commit is contained in:
@@ -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
|
||||
@@ -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}}">
|
||||
|
||||
@@ -82,3 +82,19 @@ Feature: AI course assist features
|
||||
When I click on "AI features" "button"
|
||||
And I click on "Summarise" "button"
|
||||
Then I should see "Welcome to the new AI feature!" in the ".ai-drawer" "css_element"
|
||||
|
||||
Scenario: AI features are not available when AI tools is disabled at course level
|
||||
Given I am on the "Course 1" "course editing" page logged in as teacher1
|
||||
When I set the following fields to these values:
|
||||
| Allow AI tools for this course | No |
|
||||
And I press "Save and display"
|
||||
And I am on the "PageName1" "page activity" page logged in as teacher1
|
||||
Then "AI features" "button" should not exist
|
||||
|
||||
Scenario: AI features are not available when AI tools is disabled at module level
|
||||
Given I am on the "PageName1" "page activity editing" page logged in as teacher1
|
||||
When I set the following fields to these values:
|
||||
| Allow AI tools in this activity | No |
|
||||
And I press "Save and display"
|
||||
And I am on the "PageName1" "page activity" page logged in as teacher1
|
||||
Then "AI features" "button" should not exist
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,7 +409,9 @@ class backup_module_structure_step extends backup_structure_step {
|
||||
'visibleold', 'groupmode', 'groupingid',
|
||||
'completion', 'completiongradeitemnumber', 'completionpassgrade',
|
||||
'completionview', 'completionexpected',
|
||||
'availability', 'showdescription', 'downloadcontent', 'lang'));
|
||||
'availability', 'showdescription', 'downloadcontent', 'lang',
|
||||
'enableaitools', 'enabledaiactions',
|
||||
));
|
||||
|
||||
$tags = new backup_nested_element('tags');
|
||||
$tag = new backup_nested_element('tag', array('id'), array('name', 'rawname'));
|
||||
@@ -533,7 +535,9 @@ class backup_course_structure_step extends backup_structure_step {
|
||||
'requested',
|
||||
'showactivitydates',
|
||||
'showcompletionconditions', 'pdfexportfont',
|
||||
'enablecompletion', 'completionstartonenrol', 'completionnotify'));
|
||||
'enablecompletion', 'completionstartonenrol', 'completionnotify',
|
||||
'enableaitools', 'enabledaiactions',
|
||||
));
|
||||
|
||||
$category = new backup_nested_element('category', array('id'), array(
|
||||
'name', 'description'));
|
||||
|
||||
@@ -177,6 +177,8 @@ use core\output\html_writer;
|
||||
* @property-read bool $deletioninprogress True if this course module is scheduled for deletion, false otherwise.
|
||||
* @property-read bool $downloadcontent True if content download is enabled for this course module, false otherwise.
|
||||
* @property-read bool $lang the forced language for this activity (language pack name). Null means not forced.
|
||||
* @property-read int|null $enableaitools AI tools for course_modules table
|
||||
* @property-read string|null $enabledaiactions AI actions for course_modules table
|
||||
*/
|
||||
class cm_info implements IteratorAggregate {
|
||||
/**
|
||||
@@ -514,6 +516,16 @@ class cm_info implements IteratorAggregate {
|
||||
*/
|
||||
private $lang;
|
||||
|
||||
/**
|
||||
* @var int|null enable/disable AI tools for this course module
|
||||
*/
|
||||
private $enableaitools;
|
||||
|
||||
/**
|
||||
* @var string|null enabled AI actions for this course module
|
||||
*/
|
||||
private $enabledaiactions;
|
||||
|
||||
/**
|
||||
* List of class read-only properties and their getter methods.
|
||||
* Used by magic functions __get(), __isset().
|
||||
@@ -573,6 +585,8 @@ class cm_info implements IteratorAggregate {
|
||||
'deletioninprogress' => false,
|
||||
'downloadcontent' => false,
|
||||
'lang' => false,
|
||||
'enableaitools' => false,
|
||||
'enabledaiactions' => false,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -1091,7 +1105,9 @@ class cm_info implements IteratorAggregate {
|
||||
static $cmfields = ['id', 'course', 'module', 'instance', 'section', 'idnumber', 'added',
|
||||
'score', 'indent', 'visible', 'visibleoncoursepage', 'visibleold', 'groupmode', 'groupingid',
|
||||
'completion', 'completiongradeitemnumber', 'completionview', 'completionexpected', 'completionpassgrade',
|
||||
'showdescription', 'availability', 'deletioninprogress', 'downloadcontent', 'lang'];
|
||||
'showdescription', 'availability', 'deletioninprogress', 'downloadcontent', 'lang',
|
||||
'enableaitools', 'enabledaiactions',
|
||||
];
|
||||
|
||||
foreach ($cmfields as $key) {
|
||||
$cmrecord->$key = $this->$key;
|
||||
|
||||
@@ -70,6 +70,8 @@ abstract class helper_for_get_mods_by_courses {
|
||||
$moddetails['visible'] = $modinstance->visible;
|
||||
$moddetails['groupmode'] = $modinstance->groupmode;
|
||||
$moddetails['groupingid'] = $modinstance->groupingid;
|
||||
$moddetails['enableaitools'] = $modinstance->enableaitools;
|
||||
$moddetails['enabledaiactions'] = $modinstance->enabledaiactions;
|
||||
}
|
||||
|
||||
return $moddetails;
|
||||
@@ -132,6 +134,8 @@ abstract class helper_for_get_mods_by_courses {
|
||||
'groupmode' => new external_value(PARAM_INT, 'Group mode', VALUE_OPTIONAL),
|
||||
'groupingid' => new external_value(PARAM_INT, 'Group id', VALUE_OPTIONAL),
|
||||
'lang' => new external_value(PARAM_SAFEDIR, 'Forced activity language', VALUE_OPTIONAL),
|
||||
'enableaitools' => new external_value(PARAM_INT, 'AI tools status', VALUE_OPTIONAL),
|
||||
'enabledaiactions' => new external_value(PARAM_TEXT, 'Enabled AI actions', VALUE_OPTIONAL),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1058,6 +1058,8 @@ class modinfo {
|
||||
$mods[$cmid]->deletioninprogress = $rawmods[$cmid]->deletioninprogress;
|
||||
$mods[$cmid]->downloadcontent = $rawmods[$cmid]->downloadcontent;
|
||||
$mods[$cmid]->lang = $rawmods[$cmid]->lang;
|
||||
$mods[$cmid]->enableaitools = $rawmods[$cmid]->enableaitools;
|
||||
$mods[$cmid]->enabledaiactions = $rawmods[$cmid]->enabledaiactions;
|
||||
|
||||
$modname = $mods[$cmid]->mod;
|
||||
$functionname = $modname . "_get_coursemodule_info";
|
||||
|
||||
@@ -418,6 +418,17 @@ class course_edit_form extends moodleform {
|
||||
array('itemtype' => 'course', 'component' => 'core'));
|
||||
}
|
||||
|
||||
// Add AI tools section if AI placement (course or editor) is available and at least one provider is enabled.
|
||||
$courseplacementenabled = aiplacement_courseassist\utils::is_course_assist_available();
|
||||
$editorplacementenabled = aiplacement_editor\utils::is_html_editor_placement_available();
|
||||
$providerenabled = \core\di::get(core_ai\manager::class)->get_provider_instances(['enabled' => 1]);
|
||||
if (($courseplacementenabled || $editorplacementenabled) && $providerenabled) {
|
||||
$mform->addElement('header', 'aitoolshdr', get_string('aitools', 'ai'));
|
||||
$mform->addElement('selectyesno', 'enableaitools', get_string('enableaitoolsincourse', 'ai'));
|
||||
$mform->setDefault('enableaitools', $course->enableaitools ?? 1);
|
||||
$mform->addElement('static', 'aitoolsincoursedesc', '', get_string('aitoolsincoursedesc', 'ai'));
|
||||
}
|
||||
|
||||
// Add custom fields to the form.
|
||||
$handler = core_course\customfield\course_handler::create();
|
||||
$handler->set_parent_context($categorycontext); // For course handler only.
|
||||
|
||||
@@ -123,6 +123,18 @@ function add_moduleinfo($moduleinfo, $course, $mform = null) {
|
||||
$moduleinfo->beforemod = null;
|
||||
}
|
||||
|
||||
// Set enableaitools for the activity.
|
||||
if (isset($moduleinfo->enableaitools)) {
|
||||
$newcm->enableaitools = $moduleinfo->enableaitools;
|
||||
}
|
||||
|
||||
// Set enabledaiactions for the activity.
|
||||
if (isset($moduleinfo->enabledaiactions)) {
|
||||
$newcm->enabledaiactions = $moduleinfo->enabledaiactions;
|
||||
} else {
|
||||
$newcm->enabledaiactions = null;
|
||||
}
|
||||
|
||||
// From this point we make database changes, so start transaction.
|
||||
$transaction = $DB->start_delegated_transaction();
|
||||
|
||||
@@ -448,6 +460,7 @@ function edit_module_post_actions($moduleinfo, $course) {
|
||||
* @return object the completed module info
|
||||
*/
|
||||
function set_moduleinfo_defaults($moduleinfo) {
|
||||
global $PAGE;
|
||||
|
||||
if (empty($moduleinfo->coursemodule)) {
|
||||
// Add.
|
||||
@@ -512,6 +525,39 @@ function set_moduleinfo_defaults($moduleinfo) {
|
||||
$moduleinfo->section = 0;
|
||||
}
|
||||
|
||||
// Enable AI tools by default.
|
||||
if (!isset($moduleinfo->enableaitools)) {
|
||||
$moduleinfo->enableaitools = null;
|
||||
}
|
||||
|
||||
$enabledaiactions = [];
|
||||
// Get and check for enabled AI actions in the course placement.
|
||||
$aiactions = aiplacement_courseassist\utils::get_actions_available($PAGE->context, false);
|
||||
foreach ($aiactions as $action) {
|
||||
$value = 0;
|
||||
$actionname = "action-" . $action['action'];
|
||||
if (!empty($moduleinfo->{$actionname})) {
|
||||
$value = 1;
|
||||
}
|
||||
$enabledaiactions[$action['action']] = $value;
|
||||
}
|
||||
|
||||
// Get and check for enabled AI actions in the editor placement.
|
||||
$aiactions = aiplacement_editor\utils::get_actions_available($PAGE->context, false);
|
||||
foreach ($aiactions as $action) {
|
||||
$value = 0;
|
||||
$actionname = "action-" . $action['action'];
|
||||
if (!empty($moduleinfo->{$actionname})) {
|
||||
$value = 1;
|
||||
}
|
||||
$enabledaiactions[$action['action']] = $value;
|
||||
}
|
||||
|
||||
// Set enabledaiactions for the activity.
|
||||
if (!empty($enabledaiactions)) {
|
||||
$moduleinfo->enabledaiactions = json_encode($enabledaiactions);
|
||||
}
|
||||
|
||||
return $moduleinfo;
|
||||
}
|
||||
|
||||
@@ -669,6 +715,18 @@ function update_moduleinfo($cm, $moduleinfo, $course, $mform = null) {
|
||||
$cm->showdescription = 0;
|
||||
}
|
||||
|
||||
// Set enableaitools for the activity.
|
||||
if (isset($moduleinfo->enableaitools)) {
|
||||
$cm->enableaitools = $moduleinfo->enableaitools;
|
||||
}
|
||||
|
||||
// Set enabledaiactions for the activity.
|
||||
if (isset($moduleinfo->enabledaiactions)) {
|
||||
$cm->enabledaiactions = $moduleinfo->enabledaiactions;
|
||||
} else {
|
||||
$cm->enabledaiactions = null;
|
||||
}
|
||||
|
||||
$DB->update_record('course_modules', $cm);
|
||||
|
||||
// Update embedded links and save files.
|
||||
|
||||
@@ -510,7 +510,7 @@ abstract class moodleform_mod extends moodleform {
|
||||
* Adds all the standard elements to a form to edit the settings for an activity module.
|
||||
*/
|
||||
protected function standard_coursemodule_elements() {
|
||||
global $COURSE, $CFG, $DB, $OUTPUT;
|
||||
global $COURSE, $CFG, $DB, $OUTPUT, $PAGE;
|
||||
$mform =& $this->_form;
|
||||
|
||||
$this->_outcomesused = false;
|
||||
@@ -675,6 +675,68 @@ abstract class moodleform_mod extends moodleform {
|
||||
$this->standard_hidden_coursemodule_elements();
|
||||
|
||||
$this->plugin_extend_coursemodule_standard_elements();
|
||||
|
||||
$availableactions = [];
|
||||
// Get available actions for the AI course placement.
|
||||
if (aiplacement_courseassist\utils::is_course_assist_available()) {
|
||||
$aicourseplacementactions = aiplacement_courseassist\utils::get_actions_available($this->get_context(), false);
|
||||
$availableactions = array_merge($availableactions, $aicourseplacementactions);
|
||||
}
|
||||
|
||||
// Get available actions for the AI editor placement.
|
||||
if (aiplacement_editor\utils::is_html_editor_placement_available()) {
|
||||
$aieditorplacementactions = aiplacement_editor\utils::get_actions_available($this->get_context(), false);
|
||||
$availableactions = array_merge($availableactions, $aieditorplacementactions);
|
||||
}
|
||||
|
||||
// Current set of enabled AI actions.
|
||||
$enabledaiactions = ($this->_cm && $this->_cm->enabledaiactions)
|
||||
? (array) json_decode($this->_cm->enabledaiactions)
|
||||
: true;
|
||||
|
||||
// Show AI tools in activity settings if AI course assist is available.
|
||||
$aimanager = \core\di::get(core_ai\manager::class);
|
||||
if (!empty($availableactions) && $aimanager->get_provider_instances(['enabled' => 1])) {
|
||||
$mform->addElement('header', 'aitoolshdr', get_string('aitools', 'ai'));
|
||||
|
||||
// Check if AI tools is enabled in the course.
|
||||
if (is_null($COURSE->enableaitools) || $COURSE->enableaitools) {
|
||||
$mform->addElement('selectyesno', 'enableaitools', get_string('enableaitoolsincourseactivity', 'ai'));
|
||||
$mform->setDefault('enableaitools', $this->_cm->enableaitools ?? 1);
|
||||
$mform->addElement('static', 'aitoolsincourseactivitydesc', '', get_string('aitoolsincourseactivitydesc', 'ai'));
|
||||
|
||||
$mform->addElement('static', 'aiactionshdr', get_string('aiactionshdr', 'core_ai'), '');
|
||||
$mform->hideIf('aiactionshdr', 'enableaitools', 'eq', 0);
|
||||
|
||||
// Get enabled AI actions.
|
||||
if (is_array($enabledaiactions)) {
|
||||
$filteredenabledaiactions = array_filter($enabledaiactions, function ($value): bool {
|
||||
return $value === 1;
|
||||
});
|
||||
}
|
||||
|
||||
foreach ($availableactions as $action) {
|
||||
$actionname = 'action-' . $action['action'];
|
||||
$actiontext = 'action_' . $action['action'];
|
||||
|
||||
// Set actions option's default value.
|
||||
$defaultvalue = $this->_cm ? 0 : 1; // Default to 'On' for new activities.
|
||||
if ($enabledaiactions === true || array_key_exists($action['action'], $filteredenabledaiactions)) {
|
||||
$defaultvalue = 1;
|
||||
}
|
||||
|
||||
$mform->addElement('select', $actionname, $action['buttontext'], [
|
||||
1 => get_string('on', 'core_ai'),
|
||||
0 => get_string('off', 'core_ai'),
|
||||
]);
|
||||
$mform->addHelpButton($actionname, $actiontext, 'core_ai');
|
||||
$mform->setDefault($actionname, $defaultvalue);
|
||||
$mform->hideIf($actionname, 'enableaitools', 'eq', 0);
|
||||
}
|
||||
} else {
|
||||
$mform->addElement('static', 'aiactionshdr', '', get_string('aitoolsnotenabled', 'ai'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -117,3 +117,23 @@ Feature: Generate image using AI
|
||||
And I should see "Welcome to the new AI feature!" in the "AI usage policy" "dialogue"
|
||||
And I click on "Accept and continue" "button" in the "AI usage policy" "dialogue"
|
||||
And I should see "Describe the image you want AI to create"
|
||||
|
||||
@javascript
|
||||
Scenario: Image generation is not available when AI tools is disabled at course level
|
||||
Given I am on the "Course 1" "course editing" page logged in as teacher1
|
||||
When I set the following fields to these values:
|
||||
| Allow AI tools for this course | No |
|
||||
And I press "Save and display"
|
||||
And I am on the "PageName1" "page activity" page logged in as teacher1
|
||||
And I navigate to "Settings" in current page administration
|
||||
Then "AI generate image" button should not exist in the "Description" TinyMCE editor
|
||||
|
||||
@javascript
|
||||
Scenario: Image generation is not available when AI tools is disabled at module level
|
||||
Given I am on the "PageName1" "page activity editing" page logged in as teacher1
|
||||
When I set the following fields to these values:
|
||||
| Allow AI tools in this activity | No |
|
||||
And I press "Save and display"
|
||||
And I am on the "PageName1" "page activity" page logged in as teacher1
|
||||
And I navigate to "Settings" in current page administration
|
||||
Then "AI generate image" button should not exist in the "Description" TinyMCE editor
|
||||
|
||||
@@ -118,3 +118,23 @@ Feature: Generate text using AI
|
||||
And I should see "Welcome to the new AI feature!" in the "AI usage policy" "dialogue"
|
||||
And I click on "Accept and continue" "button" in the "AI usage policy" "dialogue"
|
||||
And I should see "Describe the text you want AI to create"
|
||||
|
||||
@javascript
|
||||
Scenario: Text generation is not available when AI tools is disabled at course level
|
||||
Given I am on the "Course 1" "course editing" page logged in as teacher1
|
||||
When I set the following fields to these values:
|
||||
| Allow AI tools for this course | No |
|
||||
And I press "Save and display"
|
||||
And I am on the "PageName1" "page activity" page logged in as teacher1
|
||||
And I navigate to "Settings" in current page administration
|
||||
Then "AI generate text" button should not exist in the "Description" TinyMCE editor
|
||||
|
||||
@javascript
|
||||
Scenario: Text generation is not available when AI tools is disabled at module level
|
||||
Given I am on the "PageName1" "page activity editing" page logged in as teacher1
|
||||
When I set the following fields to these values:
|
||||
| Allow AI tools in this activity | No |
|
||||
And I press "Save and display"
|
||||
And I am on the "PageName1" "page activity" page logged in as teacher1
|
||||
And I navigate to "Settings" in current page administration
|
||||
Then "AI generate text" button should not exist in the "Description" TinyMCE editor
|
||||
|
||||
@@ -174,8 +174,8 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
|
||||
$this->assertCount(1, $books['books']);
|
||||
$this->assertEquals('Second Book', $books['books'][0]['name']);
|
||||
// We see 17 fields.
|
||||
$this->assertCount(18, $books['books'][0]);
|
||||
// We see 20 fields.
|
||||
$this->assertCount(20, $books['books'][0]);
|
||||
// As an Admin you can see some book properties like 'section'.
|
||||
$this->assertEquals(0, $books['books'][0]['section']);
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
// Create what we expect to be returned when querying the two courses.
|
||||
$expectedfields = array('id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'introfiles', 'lang', 'revision',
|
||||
'timemodified', 'display', 'showexpanded', 'showdownloadfolder', 'section', 'visible',
|
||||
'forcedownload', 'groupmode', 'groupingid');
|
||||
'forcedownload', 'groupmode', 'groupingid', 'enableaitools', 'enabledaiactions');
|
||||
|
||||
// Add expected coursemodule and data.
|
||||
$folder1->coursemodule = $folder1->cmid;
|
||||
@@ -155,6 +155,8 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
$folder1->groupingid = 0;
|
||||
$folder1->introfiles = [];
|
||||
$folder1->lang = '';
|
||||
$folder1->enableaitools = null;
|
||||
$folder1->enabledaiactions = null;
|
||||
|
||||
$folder2->coursemodule = $folder2->cmid;
|
||||
$folder2->introformat = 1;
|
||||
@@ -164,6 +166,8 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
$folder2->groupingid = 0;
|
||||
$folder2->introfiles = [];
|
||||
$folder2->lang = '';
|
||||
$folder2->enableaitools = null;
|
||||
$folder2->enabledaiactions = null;
|
||||
|
||||
foreach ($expectedfields as $field) {
|
||||
$expected1[$field] = $folder1->{$field};
|
||||
|
||||
@@ -71,7 +71,7 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
|
||||
// Create what we expect to be returned when querying the two courses.
|
||||
$expectedfields = array('id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'introfiles', 'timemodified',
|
||||
'section', 'visible', 'groupmode', 'groupingid', 'lang');
|
||||
'section', 'visible', 'groupmode', 'groupingid', 'lang', 'enableaitools', 'enabledaiactions');
|
||||
|
||||
// Add expected coursemodule and data.
|
||||
$label1->coursemodule = $label1->cmid;
|
||||
@@ -82,6 +82,8 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
$label1->groupingid = 0;
|
||||
$label1->introfiles = [];
|
||||
$label1->lang = '';
|
||||
$label1->enableaitools = null;
|
||||
$label1->enabledaiactions = null;
|
||||
|
||||
$label2->coursemodule = $label2->cmid;
|
||||
$label2->introformat = 1;
|
||||
@@ -91,6 +93,8 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
$label2->groupingid = 0;
|
||||
$label2->introfiles = [];
|
||||
$label2->lang = '';
|
||||
$label2->enableaitools = null;
|
||||
$label2->enabledaiactions = null;
|
||||
|
||||
foreach ($expectedfields as $field) {
|
||||
$expected1[$field] = $label1->{$field};
|
||||
|
||||
@@ -208,6 +208,8 @@ final class externallib_test extends mod_lti_testcase {
|
||||
$lti1->section = 0;
|
||||
$lti1->introfiles = [];
|
||||
$lti1->lang = '';
|
||||
$lti1->enableaitools = null;
|
||||
$lti1->enabledaiactions = null;
|
||||
|
||||
$lti2->coursemodule = $lti2->cmid;
|
||||
$lti2->introformat = 1;
|
||||
@@ -218,6 +220,8 @@ final class externallib_test extends mod_lti_testcase {
|
||||
$lti2->section = 0;
|
||||
$lti2->introfiles = [];
|
||||
$lti2->lang = '';
|
||||
$lti2->enableaitools = null;
|
||||
$lti2->enabledaiactions = null;
|
||||
|
||||
foreach ($expectedfields as $field) {
|
||||
$expected1[$field] = $lti1->{$field};
|
||||
@@ -261,7 +265,9 @@ final class externallib_test extends mod_lti_testcase {
|
||||
$additionalfields = array('timecreated', 'timemodified', 'typeid', 'toolurl', 'securetoolurl',
|
||||
'instructorchoicesendname', 'instructorchoicesendemailaddr', 'instructorchoiceallowroster',
|
||||
'instructorchoiceallowsetting', 'instructorcustomparameters', 'instructorchoiceacceptgrades', 'grade',
|
||||
'resourcekey', 'password', 'debuglaunch', 'servicesalt', 'visible', 'groupmode', 'groupingid', 'section', 'lang');
|
||||
'resourcekey', 'password', 'debuglaunch', 'servicesalt', 'visible', 'groupmode', 'groupingid', 'section', 'lang',
|
||||
'enableaitools', 'enabledaiactions',
|
||||
);
|
||||
|
||||
foreach ($additionalfields as $field) {
|
||||
$expectedltis[0][$field] = $lti1->{$field};
|
||||
|
||||
@@ -142,7 +142,8 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
// Create what we expect to be returned when querying the two courses.
|
||||
$expectedfields = array('id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'introfiles', 'lang',
|
||||
'content', 'contentformat', 'contentfiles', 'legacyfiles', 'legacyfileslast', 'display',
|
||||
'displayoptions', 'revision', 'timemodified', 'section', 'visible', 'groupmode', 'groupingid');
|
||||
'displayoptions', 'revision', 'timemodified', 'section', 'visible', 'groupmode', 'groupingid',
|
||||
'enableaitools', 'enabledaiactions');
|
||||
|
||||
// Add expected coursemodule and data.
|
||||
$page1->coursemodule = $page1->cmid;
|
||||
@@ -155,6 +156,8 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
$page1->introfiles = [];
|
||||
$page1->contentfiles = [];
|
||||
$page1->lang = '';
|
||||
$page1->enableaitools = null;
|
||||
$page1->enabledaiactions = null;
|
||||
|
||||
$page2->coursemodule = $page2->cmid;
|
||||
$page2->introformat = 1;
|
||||
@@ -166,6 +169,8 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
$page2->introfiles = [];
|
||||
$page2->contentfiles = [];
|
||||
$page2->lang = '';
|
||||
$page2->enableaitools = null;
|
||||
$page2->enabledaiactions = null;
|
||||
|
||||
foreach ($expectedfields as $field) {
|
||||
$expected1[$field] = $page1->{$field};
|
||||
|
||||
+5
-1
@@ -251,7 +251,7 @@ final class external_test extends \core_external\tests\externallib_testcase {
|
||||
$allusersfields = ['id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'introfiles', 'lang',
|
||||
'timeopen', 'timeclose', 'grademethod', 'section', 'visible', 'groupmode', 'groupingid',
|
||||
'attempts', 'timelimit', 'grademethod', 'decimalpoints', 'questiondecimalpoints', 'sumgrades',
|
||||
'grade', 'preferredbehaviour', 'hasfeedback'];
|
||||
'grade', 'preferredbehaviour', 'hasfeedback', 'enableaitools', 'enabledaiactions'];
|
||||
$userswithaccessfields = ['attemptonlast', 'reviewattempt', 'reviewcorrectness', 'reviewmaxmarks', 'reviewmarks',
|
||||
'reviewspecificfeedback', 'reviewgeneralfeedback', 'reviewrightanswer',
|
||||
'reviewoverallfeedback', 'questionsperpage', 'navmethod',
|
||||
@@ -274,6 +274,8 @@ final class external_test extends \core_external\tests\externallib_testcase {
|
||||
$quiz1->autosaveperiod = get_config('quiz', 'autosaveperiod');
|
||||
$quiz1->introfiles = [];
|
||||
$quiz1->lang = '';
|
||||
$quiz1->enableaitools = null;
|
||||
$quiz1->enabledaiactions = null;
|
||||
|
||||
$quiz2->coursemodule = $quiz2->cmid;
|
||||
$quiz2->introformat = 1;
|
||||
@@ -287,6 +289,8 @@ final class external_test extends \core_external\tests\externallib_testcase {
|
||||
$quiz2->autosaveperiod = get_config('quiz', 'autosaveperiod');
|
||||
$quiz2->introfiles = [];
|
||||
$quiz2->lang = '';
|
||||
$quiz2->enableaitools = null;
|
||||
$quiz2->enabledaiactions = null;
|
||||
|
||||
foreach (array_merge($allusersfields, $userswithaccessfields) as $field) {
|
||||
$expected1[$field] = $quiz1->{$field};
|
||||
|
||||
@@ -143,7 +143,8 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
// Create what we expect to be returned when querying the two courses.
|
||||
$expectedfields = array('id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'introfiles', 'lang',
|
||||
'contentfiles', 'tobemigrated', 'legacyfiles', 'legacyfileslast', 'display', 'displayoptions',
|
||||
'filterfiles', 'revision', 'timemodified', 'section', 'visible', 'groupmode', 'groupingid');
|
||||
'filterfiles', 'revision', 'timemodified', 'section', 'visible', 'groupmode', 'groupingid',
|
||||
'enableaitools', 'enabledaiactions');
|
||||
|
||||
// Add expected coursemodule and data.
|
||||
$resource1->coursemodule = $resource1->cmid;
|
||||
@@ -156,6 +157,8 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
$resource1->introfiles = [];
|
||||
$resource1->contentfiles = [];
|
||||
$resource1->lang = '';
|
||||
$resource1->enableaitools = null;
|
||||
$resource1->enabledaiactions = null;
|
||||
|
||||
$resource2->coursemodule = $resource2->cmid;
|
||||
$resource2->introformat = 1;
|
||||
@@ -167,6 +170,8 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
$resource2->introfiles = [];
|
||||
$resource2->contentfiles = [];
|
||||
$resource2->lang = '';
|
||||
$resource2->enableaitools = null;
|
||||
$resource2->enabledaiactions = null;
|
||||
|
||||
foreach ($expectedfields as $field) {
|
||||
$expected1[$field] = $resource1->{$field};
|
||||
|
||||
@@ -723,6 +723,8 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
$scorm1->groupmode = 0;
|
||||
$scorm1->groupingid = 0;
|
||||
$scorm1->lang = '';
|
||||
$scorm1->enableaitools = null;
|
||||
$scorm1->enabledaiactions = null;
|
||||
|
||||
$scorm2->coursemodule = $scorm2->cmid;
|
||||
$scorm2->section = 0;
|
||||
@@ -730,6 +732,8 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
$scorm2->groupmode = 0;
|
||||
$scorm2->groupingid = 0;
|
||||
$scorm2->lang = '';
|
||||
$scorm2->enableaitools = null;
|
||||
$scorm2->enabledaiactions = null;
|
||||
|
||||
// SCORM size. The same package is used in both SCORMs.
|
||||
$scormcontext1 = \context_module::instance($scorm1->cmid);
|
||||
@@ -806,7 +810,8 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
|
||||
$additionalfields = array('updatefreq', 'timemodified', 'options',
|
||||
'completionstatusrequired', 'completionscorerequired', 'completionstatusallscos',
|
||||
'autocommit', 'section', 'visible', 'groupmode', 'groupingid');
|
||||
'autocommit', 'section', 'visible', 'groupmode', 'groupingid',
|
||||
'enableaitools', 'enabledaiactions');
|
||||
|
||||
foreach ($additionalfields as $field) {
|
||||
$fieldtype = $returndescription->keys['scorms']->content->keys[$field]->type;
|
||||
|
||||
@@ -142,7 +142,7 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
// Create what we expect to be returned when querying the two courses.
|
||||
$expectedfields = array('id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'introfiles', 'lang',
|
||||
'externalurl', 'display', 'displayoptions', 'parameters', 'timemodified', 'section', 'visible', 'groupmode',
|
||||
'groupingid');
|
||||
'groupingid', 'enableaitools', 'enabledaiactions');
|
||||
|
||||
// Add expected coursemodule and data.
|
||||
$url1->coursemodule = $url1->cmid;
|
||||
@@ -153,6 +153,8 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
$url1->groupingid = 0;
|
||||
$url1->introfiles = [];
|
||||
$url1->lang = '';
|
||||
$url1->enableaitools = null;
|
||||
$url1->enabledaiactions = null;
|
||||
|
||||
$url2->coursemodule = $url2->cmid;
|
||||
$url2->introformat = 1;
|
||||
@@ -162,6 +164,8 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
$url2->groupingid = 0;
|
||||
$url2->introfiles = [];
|
||||
$url2->lang = '';
|
||||
$url2->enableaitools = null;
|
||||
$url2->enabledaiactions = null;
|
||||
|
||||
foreach ($expectedfields as $field) {
|
||||
$expected1[$field] = $url1->{$field};
|
||||
|
||||
@@ -273,6 +273,7 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
// Create what we expect to be returned when querying the two courses.
|
||||
// First for the student user.
|
||||
$expectedfields = array('id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'introfiles', 'lang',
|
||||
'enableaitools', 'enabledaiactions',
|
||||
'firstpagetitle', 'wikimode', 'defaultformat', 'forceformat', 'editbegin', 'editend', 'section', 'visible',
|
||||
'groupmode', 'groupingid');
|
||||
|
||||
@@ -286,6 +287,8 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
$wiki1->groupingid = 0;
|
||||
$wiki1->introfiles = [];
|
||||
$wiki1->lang = '';
|
||||
$wiki1->enableaitools = null;
|
||||
$wiki1->enabledaiactions = null;
|
||||
|
||||
$wiki2->coursemodule = $wiki2->cmid;
|
||||
$wiki2->introformat = 1;
|
||||
@@ -295,6 +298,8 @@ final class externallib_test extends \core_external\tests\externallib_testcase {
|
||||
$wiki2->groupingid = 0;
|
||||
$wiki2->introfiles = [];
|
||||
$wiki2->lang = '';
|
||||
$wiki2->enableaitools = null;
|
||||
$wiki2->enabledaiactions = null;
|
||||
|
||||
foreach ($expectedfields as $field) {
|
||||
$expected1[$field] = $wiki1->{$field};
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user