diff --git a/classes/form/rights_config_form.php b/classes/form/rights_config_form.php index 8ffd8d1..2eb7579 100644 --- a/classes/form/rights_config_form.php +++ b/classes/form/rights_config_form.php @@ -52,6 +52,9 @@ class rights_config_form extends \moodleform { /** @var string Constant for defining the action option "confirm" for the action {@see self::ACTION_CHANGE_CONFIRM_STATUS}. */ const ACTIONOPTION_CHANGE_CONFIRM_STATE_UNCONFIRM = 'unconfirm'; + /** @var string Constant for defining the action "change usage scope". */ + const ACTION_CHANGE_SCOPE = 'changescope'; + /** * Form definition. */ @@ -70,6 +73,7 @@ public function definition() { self::ACTION_ASSIGN_ROLE => get_string('assignrole', 'local_ai_manager'), self::ACTION_CHANGE_LOCK_STATE => get_string('changelockstate', 'local_ai_manager'), self::ACTION_CHANGE_CONFIRM_STATE => get_string('changeconfirmstate', 'local_ai_manager'), + self::ACTION_CHANGE_SCOPE => get_string('changescope', 'local_ai_manager'), ]); $actionselectsgroup[] = $mform->createElement('select', 'role', '', [ @@ -96,6 +100,14 @@ public function definition() { ); $mform->hideif('confirmstate', 'action', 'neq', self::ACTION_CHANGE_CONFIRM_STATE); + $actionselectsgroup[] = $mform->createElement('select', 'scope', '', + [ + userinfo::SCOPE_COURSES_ONLY => get_string('scope_courses', 'local_ai_manager'), + userinfo::SCOPE_EVERYWHERE => get_string('scope_everywhere', 'local_ai_manager'), + ] + ); + $mform->hideif('scope', 'action', 'neq', self::ACTION_CHANGE_SCOPE); + $mform->addGroup($actionselectsgroup, 'actiongroup', get_string('executebulkuseractions', 'local_ai_manager') . ':', [' '], false); diff --git a/classes/local/rights_config_table.php b/classes/local/rights_config_table.php index 4822b04..e176fff 100644 --- a/classes/local/rights_config_table.php +++ b/classes/local/rights_config_table.php @@ -57,7 +57,7 @@ public function __construct( $this->set_attribute('id', $uniqid); $this->define_baseurl($baseurl); // Define the list of columns to show. - $columns = ['checkbox', 'lastname', 'firstname', 'role', 'locked', 'confirmed']; + $columns = ['checkbox', 'lastname', 'firstname', 'role', 'locked', 'confirmed', 'scope']; $checkboxheader = html_writer::div('', 'rights-table-selection_info', ['id' => 'rights-table-selection_info']); $checkboxheader .= html_writer::empty_tag('input', ['type' => 'checkbox', 'id' => 'rights-table-selectall_checkbox']); $headers = [ @@ -67,6 +67,7 @@ public function __construct( get_string('role', 'local_ai_manager'), get_string('locked', 'local_ai_manager'), get_string('confirmed', 'local_ai_manager'), + get_string('scope', 'local_ai_manager'), ]; $tenantfield = get_config('local_ai_manager', 'tenantcolumn'); @@ -100,7 +101,7 @@ public function __construct( } } - $fields = 'u.id as id, lastname, firstname, role, locked, ui.confirmed'; + $fields = 'u.id as id, lastname, firstname, role, locked, ui.confirmed, ui.scope'; $from = '{user} u LEFT JOIN {local_ai_manager_userinfo} ui ON u.id = ui.userid'; $where = 'u.deleted != 1 AND u.suspended != 1 AND ' . $tenantfield . ' = :tenant' . $rolewhere; @@ -117,16 +118,17 @@ public function __construct( $this->no_sorting('role'); $this->no_sorting('locked'); $this->no_sorting('confirmed'); + $this->no_sorting('scope'); $this->collapsible(false); $this->sortable(true, 'lastname'); $this->set_sql($usertableextend->get_fields(), $usertableextend->get_from(), - $usertableextend->get_where() . ' GROUP BY u.id, role, locked, ui.confirmed', + $usertableextend->get_where() . ' GROUP BY u.id, role, locked, ui.confirmed, ui.scope', $usertableextend->get_params()); // We need to use this because we are using "GROUP BY" which is not being expected by the sql table. $this->set_count_sql("SELECT COUNT(*) FROM (SELECT " . $usertableextend->get_fields() . " FROM " . $usertableextend->get_from() . " WHERE " . $usertableextend->get_where() . - " GROUP BY u.id, role, locked, ui.confirmed) AS subquery", + " GROUP BY u.id, role, locked, ui.confirmed, ui.scope) AS subquery", $usertableextend->get_params()); parent::setup(); } @@ -174,6 +176,28 @@ public function col_confirmed($value) { } } + /** + * Get the icon representing the user scope. + * + * @param stdClass $value the object containing the information of the current row + * @return string the resulting string for the confirmed column + */ + public function col_scope($value) { + $userinfo = new userinfo($value->id); + $scope = empty($value->scope) ? $userinfo->get_default_scope() : intval($value->scope); + switch ($scope) { + case userinfo::SCOPE_EVERYWHERE: + return ''; + case userinfo::SCOPE_COURSES_ONLY: + return ''; + default: + // Should not happen. + return 'No scope'; + } + } + #[\Override] public function other_cols($column, $row) { if ($column === 'checkbox') { diff --git a/classes/local/userinfo.php b/classes/local/userinfo.php index 506c045..6965084 100644 --- a/classes/local/userinfo.php +++ b/classes/local/userinfo.php @@ -41,6 +41,12 @@ class userinfo { /** @var int This is not really a role, but is being used to signal that the default role for a user should be assigned. */ public const ROLE_DEFAULT = -1; + /** @var int Constant identifying that the scope for using AI tools of this user is not limited. */ + public const SCOPE_EVERYWHERE = 1; + + /** @var int Constant identifying that the scope for using AI tools of this user restricted to courses. */ + public const SCOPE_COURSES_ONLY = 2; + /** @var false|stdClass The database record or false if there is none (yet) */ private false|stdClass $record; @@ -53,13 +59,16 @@ class userinfo { /** @var bool The confirmed state of the user */ private bool $confirmed; + /** @var int The scope of the user, one of {@see self::SCOPE_EVERYWHERE} or {@see self::SCOPE_COURSES_ONLY}. */ + private int $scope; + /** * Create a userinfo object. * * @param int $userid The userid to create the userinfo object for */ public function __construct( - /** @var int $userid The userid to create the userinfo object for */ + /** @var int $userid The userid to create the userinfo object for */ private readonly int $userid ) { $this->load(); @@ -74,6 +83,7 @@ public function load(): void { $this->role = !empty($this->record->role) ? $this->record->role : $this->get_default_role(); $this->locked = !empty($this->record->locked); $this->confirmed = !empty($this->record->confirmed); + $this->scope = !empty($this->record->scope) ? $this->record->scope : $this->get_default_scope(); } /** @@ -132,6 +142,7 @@ public function store() { $newrecord->role = $this->role; $newrecord->locked = $this->locked ? 1 : 0; $newrecord->confirmed = $this->confirmed ? 1 : 0; + $newrecord->scope = $this->scope; $newrecord->timemodified = time(); if ($this->record) { $newrecord->id = $this->record->id; @@ -207,6 +218,38 @@ public function is_confirmed(): bool { return $this->confirmed; } + /** + * Sets the scope of the user. + * + * @param int $scope The scope the user should have, has to be one of {@see self::SCOPE_EVERYWHERE} or + * {@see self::SCOPE_COURSES_ONLY} + * @throws \coding_exception if a wrong scope has been passed + */ + public function set_scope(int $scope): void { + if (!in_array($scope, [self::SCOPE_EVERYWHERE, self::SCOPE_COURSES_ONLY])) { + throw new \coding_exception('Wrong scope specified, use one of SCOPE_EVERYWHERE or SCOPE_COURSES_ONLY'); + } + $this->scope = $scope; + } + + /** + * Returns the default scope of a user. + * + * @return int the default scope of a user that has not been assigned a scope yet + */ + public function get_default_scope() { + return $this->get_default_role() === self::ROLE_BASIC ? self::SCOPE_COURSES_ONLY : self::SCOPE_EVERYWHERE; + } + + /** + * Getter for the scope. + * + * @return int one of {@see self::SCOPE_EVERYWHERE} or {@see self::SCOPE_COURSES_ONLY} + */ + public function get_scope(): int { + return $this->scope; + } + /** * Helper function to get the tenant for a user. * diff --git a/classes/manager.php b/classes/manager.php index 522eb89..3eda10d 100644 --- a/classes/manager.php +++ b/classes/manager.php @@ -140,6 +140,30 @@ public function perform_request(string $prompttext, array $options = []): prompt return prompt_response::create_from_error(403, get_string('error_http403notconfirmed', 'local_ai_manager'), ''); } + if ($userinfo->get_scope() === userinfo::SCOPE_COURSES_ONLY) { + if (empty($options['contextid'])) { + throw new \coding_exception('The "contextid" is missing in the passed options, ' + . 'but is required if the user\'s scope is set to "courses only"'); + } + $contextid = $options['contextid']; + $context = context::instance_by_id($contextid); + if ($context->contextlevel < CONTEXT_COURSE) { + // In case of system context, course category context, user context we throw a permission denied error. + return prompt_response::create_from_error(403, get_string('error_http403coursesonly', 'local_ai_manager'), ''); + } else if ($context->contextlevel === CONTEXT_BLOCK) { + // Block context is a bit special though... + if ($context->get_parent_context()->contextlevel !== CONTEXT_COURSE) { + return prompt_response::create_from_error(403, get_string('error_http403coursesonly', 'local_ai_manager'), ''); + } else { + $courseid = $context->get_parent_context()->instanceid; + if ($courseid === intval(SITEID)) { + return prompt_response::create_from_error(403, get_string('error_http403coursesonly', 'local_ai_manager'), + ''); + } + } + } + } + if (intval($this->configmanager->get_max_requests($this->purpose, $userinfo->get_role())) === 0) { return prompt_response::create_from_error(403, get_string('error_http403usertype', 'local_ai_manager'), ''); } diff --git a/db/install.xml b/db/install.xml index 20ed5d6..1eedc93 100644 --- a/db/install.xml +++ b/db/install.xml @@ -1,5 +1,5 @@ - @@ -76,6 +76,7 @@ + diff --git a/db/upgrade.php b/db/upgrade.php index 7d7b447..02da8d8 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -23,6 +23,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use local_ai_manager\local\userinfo; + /** * Define upgrade steps to be performed to upgrade the plugin from the old version to the current one. * @@ -178,5 +180,27 @@ function xmldb_local_ai_manager_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2024120200, 'local', 'ai_manager'); } + if ($oldversion < 2024122300) { + + // Define field scope to be added to local_ai_manager_userinfo. + $table = new xmldb_table('local_ai_manager_userinfo'); + $field = new xmldb_field('scope', XMLDB_TYPE_INTEGER, '1', null, null, null, null, 'confirmed'); + + // Conditionally launch add field scope. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + $userids = $DB->get_fieldset('local_ai_manager_userinfo', 'userid'); + foreach ($userids as $userid) { + // This will set the correct default value for the "scope" and update the record afterwards. + $userinfo = new userinfo($userid); + $userinfo->store(); + } + + // AI manager savepoint reached. + upgrade_plugin_savepoint(true, 2024122300, 'local', 'ai_manager'); + } + return true; } diff --git a/lang/de/local_ai_manager.php b/lang/de/local_ai_manager.php index 7f81e1a..c2ed182 100644 --- a/lang/de/local_ai_manager.php +++ b/lang/de/local_ai_manager.php @@ -56,6 +56,7 @@ $string['cachedef_googleauth'] = 'Cache für Google-OAuth2-Access-Token'; $string['changeconfirmstate'] = 'Zustimmung der Nutzungsbedingungen ändern'; $string['changelockstate'] = 'Gesperrt-Zustand verändern'; +$string['changescope'] = 'Nutzungsbereich verändern'; $string['configure_instance'] = 'KI-Tool-Instanzen konfigurieren'; $string['configureaitool'] = 'KI-Tool konfigurieren'; $string['configurepurposes'] = 'Einsatzzwecke konfigurieren'; @@ -70,6 +71,7 @@ $string['endpoint'] = 'API-Endpunkt'; $string['error_http400'] = 'Fehler beim Bereinigen der übergebenen Optionen'; $string['error_http403blocked'] = 'Ihr Tenant-Manager hat den Zugriff auf die KI-Tools für Sie blockiert'; +$string['error_http403coursesonly'] = 'Sie haben keine Berechtigung, KI-Tools außerhalb von Kursen zu nutzen.'; $string['error_http403disabled'] = 'Ihr Tenant-Manager hat die KI-Tools für Ihren Tenant nicht aktiviert'; $string['error_http403nocapability'] = 'Sie haben nicht die Berechtigung, um den AI-Manager zu nutzen ("local/ai_manager:use")'; $string['error_http403notconfirmed'] = 'Sie haben die Nutzungsbedingungen noch nicht akzeptiert'; @@ -173,6 +175,9 @@ $string['role_basic'] = 'Standardrolle'; $string['role_extended'] = 'Erweiterte Rolle'; $string['role_unlimited'] = 'Unbeschränkte Rolle'; +$string['scope'] = 'Nutzungsbereich'; +$string['scope_courses'] = 'Nur in Kursen'; +$string['scope_everywhere'] = 'Überall'; $string['select_tool_for_purpose'] = 'Einsatzzweck "{$a}"'; $string['selecteduserscount'] = '{$a} ausgewählt'; $string['serviceaccountjson'] = 'Inhalt der JSON-Datei des Google-Serviceaccounts'; diff --git a/lang/en/local_ai_manager.php b/lang/en/local_ai_manager.php index cf2215d..5331558 100644 --- a/lang/en/local_ai_manager.php +++ b/lang/en/local_ai_manager.php @@ -56,6 +56,7 @@ $string['cachedef_googleauth'] = 'Cache for Google OAuth2 access token'; $string['changeconfirmstate'] = 'Change confirmation of terms of use'; $string['changelockstate'] = 'Change lock status'; +$string['changescope'] = 'Change usage scope'; $string['configure_instance'] = 'Configure AI Tool Instances'; $string['configureaitool'] = 'Configure AI tool'; $string['configurepurposes'] = 'Configure the purposes'; @@ -69,11 +70,12 @@ $string['enable_ai_integration'] = 'Enable AI integration'; $string['endpoint'] = 'API endpoint'; $string['error_http400'] = 'Error sanitizing passed options'; -$string['error_http403blocked'] = 'Your ByCS admin has blocked access to the AI tools for you'; -$string['error_http403disabled'] = 'Your ByCS admin has not enabled the AI tools feature'; +$string['error_http403blocked'] = 'Your tenant manager has blocked access to the AI tools for you'; +$string['error_http403coursesonly'] = 'You do not have the permission to use AI tool outside courses.'; +$string['error_http403disabled'] = 'Your tenant manager has not enabled the AI tools feature'; $string['error_http403nocapability'] = 'You do not have the capability to use the AI manager ("local/ai_manager:use")'; $string['error_http403notconfirmed'] = 'You have not yet confirmed the terms of use'; -$string['error_http403usertype'] = 'Your ByCS admin has disabled this purpose for your user type'; +$string['error_http403usertype'] = 'Your tenant manager has disabled this purpose for your user type'; $string['error_http409'] = 'The itemid {$a} is already taken'; $string['error_http429'] = 'You have reached the maximum amount of requests. You are only allowed to send {$a->count} requests in a period of {$a->period}'; $string['error_limitreached'] = 'You have reached the maximum amount of requests for this purpose. Please wait until the counter has been reset.'; @@ -150,7 +152,7 @@ $string['portrait'] = 'portrait'; $string['preconfiguredmodel'] = 'Preconfigured model'; $string['privacy:metadata'] = 'The local ai_manager plugin does not store any personal data.'; -$string['privacy_table_description'] = 'In the table below, you can see an overview of the AI tools configured by your school. Your ByCS admin may have provided additional notes on the terms of use and privacy notices of the respective AI tools in the "Info link" column.'; +$string['privacy_table_description'] = 'In the table below, you can see an overview of the AI tools configured by your school. Your tenant manager may have provided additional notes on the terms of use and privacy notices of the respective AI tools in the "Info link" column.'; $string['privacy_terms_description'] = 'Following are the notes about data privacy and terms of use in the exact same form like you confirmed or still have to confirm to use the AI functionalities.'; $string['privacy_terms_heading'] = 'Privacy and Terms of Use'; $string['privacy_terms_missing'] = 'No terms of use have been specified.'; @@ -173,6 +175,9 @@ $string['role_basic'] = 'base role'; $string['role_extended'] = 'extended role'; $string['role_unlimited'] = 'unlimited role'; +$string['scope'] = 'usage scope'; +$string['scope_courses'] = 'Only in courses'; +$string['scope_everywhere'] = 'Everywhere'; $string['select_tool_for_purpose'] = 'Purpose {$a}'; $string['selecteduserscount'] = '{$a} selected'; $string['serviceaccountjson'] = 'Content of the JSON file of the Google service account'; diff --git a/rights_config.php b/rights_config.php index 2eb5cad..5620ce6 100644 --- a/rights_config.php +++ b/rights_config.php @@ -69,6 +69,9 @@ case rights_config_form::ACTION_CHANGE_CONFIRM_STATE: $userinfo->set_confirmed($data->confirmstate === rights_config_form::ACTIONOPTION_CHANGE_CONFIRM_STATE_CONFIRM); break; + case rights_config_form::ACTION_CHANGE_SCOPE: + $userinfo->set_scope(intval($data->scope)); + break; default: throw new \coding_exception('Unknown action: ' . $data->action); } diff --git a/tests/manager_test.php b/tests/manager_test.php index 96b7625..014566d 100644 --- a/tests/manager_test.php +++ b/tests/manager_test.php @@ -74,6 +74,40 @@ public function test_perform_request(array $configuration, int $expectedcode, st $userinfo = new userinfo($user->id); $userinfo->set_locked($configuration['locked']); $userinfo->set_confirmed($configuration['confirmed']); + + $userinfo->set_scope($configuration['scopecourses'] ? userinfo::SCOPE_COURSES_ONLY : userinfo::SCOPE_EVERYWHERE); + + // Setup some objects for checking contexts. + $course = $this->getDataGenerator()->create_course(); + switch ($configuration['context']) { + case 'course': + $contextid = \context_course::instance($course->id)->id; + break; + case 'block_in_course': + $block = $this->getDataGenerator()->create_block('html', + ['parentcontextid' => \context_course::instance($course->id)->id]); + $contextid = \context_block::instance($block->id)->id; + break; + case 'user': + $contextid = \context_user::instance($user->id)->id; + break; + case 'site': + $contextid = SYSCONTEXTID; + break; + case 'block_systemcontext': + $block = $this->getDataGenerator()->create_block('html', + ['parentcontextid' => SYSCONTEXTID]); + $contextid = \context_block::instance($block->id)->id; + break; + case 'block_usercontext': + $block = $this->getDataGenerator()->create_block('html', + ['parentcontextid' => \context_user::instance($user->id)->id]); + $contextid = \context_block::instance($block->id)->id; + break; + default: + $contextid = null; + } + $userinfo->set_role(userinfo::ROLE_BASIC); $userinfo->store(); @@ -111,7 +145,11 @@ public function test_perform_request(array $configuration, int $expectedcode, st // Now we finally finished our setup. Call the perform_request method and check the result. - $result = $manager->perform_request('Random string that is irrelevant'); + $options = []; + if (!is_null($contextid)) { + $options['contextid'] = $contextid; + } + $result = $manager->perform_request('Random string that is irrelevant', $options); $this->assertEquals($expectedcode, $result->get_code()); if ($result->get_code() == 200) { $this->assertTrue(str_contains($result->get_content(), $message)); @@ -135,6 +173,8 @@ public static function perform_request_provider(): array { 'tenantenabled' => true, 'locked' => false, 'confirmed' => true, + 'scopecourses' => false, + 'context' => null, // That means that there are more than 0 requests. 0 requests would mean that this role is locked. 'maxrequests' => 10, 'currentusage' => 5, @@ -153,27 +193,58 @@ public static function perform_request_provider(): array { 'tenantnotallowed' => [ 'configuration' => [...$defaultoptions, 'tenantallowed' => false], 'expectedcode' => 403, - 'message' => 'Your ByCS admin has not enabled the AI tools feature', + 'message' => 'Your tenant manager has not enabled the AI tools feature', ], 'tenantnotenabled' => [ 'configuration' => [...$defaultoptions, 'tenantenabled' => false], 'expectedcode' => 403, - 'message' => 'Your ByCS admin has not enabled the AI tools feature', + 'message' => 'Your tenant manager has not enabled the AI tools feature', ], 'userlocked' => [ 'configuration' => [...$defaultoptions, 'locked' => true], 'expectedcode' => 403, - 'message' => 'Your ByCS admin has blocked access to the AI tools for you', + 'message' => 'Your tenant manager has blocked access to the AI tools for you', ], 'usernotconfirmed' => [ 'configuration' => [...$defaultoptions, 'confirmed' => false], 'expectedcode' => 403, 'message' => 'You have not yet confirmed the terms of use', ], + 'userscopecourses_course' => [ + 'configuration' => [...$defaultoptions, 'scopecourses' => true, 'context' => 'course'], + 'expectedcode' => 200, + 'message' => 'Test result', + ], + 'userscopecourses_block_in_course' => [ + 'configuration' => [...$defaultoptions, 'scopecourses' => true, 'context' => 'block_in_course'], + 'expectedcode' => 200, + 'message' => 'Test result', + ], + 'userscopecourses_user' => [ + 'configuration' => [...$defaultoptions, 'scopecourses' => true, 'context' => 'user'], + 'expectedcode' => 403, + 'message' => 'You do not have the permission to use AI tool outside courses', + ], + 'userscopecourses_site' => [ + 'configuration' => [...$defaultoptions, 'scopecourses' => true, 'context' => 'site'], + 'expectedcode' => 403, + 'message' => 'You do not have the permission to use AI tool outside courses', + ], + 'userscopecourses_block_systemcontext' => [ + 'configuration' => [...$defaultoptions, 'scopecourses' => true, 'context' => 'block_systemcontext'], + 'expectedcode' => 403, + 'message' => 'You do not have the permission to use AI tool outside courses', + ], + 'userscopecourses_block_usercontext' => [ + // This for example are blocks you added to your own dashboard. They have user context. + 'configuration' => [...$defaultoptions, 'scopecourses' => true, 'context' => 'block_usercontext'], + 'expectedcode' => 403, + 'message' => 'You do not have the permission to use AI tool outside courses', + ], 'purposedisabledforrole' => [ 'configuration' => [...$defaultoptions, 'maxrequests' => 0], 'expectedcode' => 403, - 'message' => 'Your ByCS admin has disabled this purpose for your user type', + 'message' => 'Your tenant manager has disabled this purpose for your user type', ], 'usagelimitreached' => [ 'configuration' => [...$defaultoptions, 'currentusage' => $defaultoptions['maxrequests'] + 1], diff --git a/version.php b/version.php index 7cd9193..87c9ece 100644 --- a/version.php +++ b/version.php @@ -24,7 +24,7 @@ */ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2024120400; +$plugin->version = 2024122300; $plugin->requires = 2024042200; $plugin->release = '0.0.3'; $plugin->component = 'local_ai_manager';