From e8b49d1d32226166a01d9ff09ad2490c6040eaac Mon Sep 17 00:00:00 2001 From: Benjamin Walker Date: Mon, 13 Jan 2025 17:24:40 +1000 Subject: [PATCH] Add device experiment #75 --- README.md | 6 +- classes/experiment_manager.php | 72 ++++++++++++++++-- classes/form/edit_experiment.php | 13 +++- classes/form/manage_experiments.php | 6 +- db/install.xml | 5 +- db/upgrade.php | 15 ++++ edit_experiment.php | 6 +- lang/en/tool_abconfig.php | 3 + lib.php | 109 ++++++++++++++++++++++++---- tests/experiment_manager_test.php | 3 +- tests/lib_test.php | 4 +- version.php | 4 +- 12 files changed, 215 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 89231ec..609989c 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Configuration Visit the Site Administration menu and navigate to Plugins->Admin Tools->Manage Experiments. This page allows you to add new experiments, as well as edit existing experiments. To add a new experiment, fill in the fields, and click 'Add Experiment'. To edit the details of an existing experiment, click on the Edit link inside of the experiments table, to go to the edit page. ### Scopes and audiences -The plugin currently has two scopes that experiments can lie under, Request scope and Session scope. +The plugin currently has three scopes that experiments can lie under, Request scope, Session scope and Device scope. #### Request scope @@ -49,6 +49,10 @@ Request scope experiments are run on every http request. Any request scope will Session scope experiments are called when a user logs into the site. At this time, a condition set will be decided on, and users will continue to have that condition set applied for the length of their session. This does not apply to guest users, only logged in users. When a user logs out, and logs back in, a new set of conditions is applied to the account, which may be the same condition set. +#### Device scope + +Device scope experiments are similar to a session experiment but is set before the session exists, and is deterministic for all requests coming from a specific device or user. + ### Conditions Each experiment can have multiple condition sets avaiable, of which 1 is applied to a given user at a given time. The condition set is picked based on the weighting you specify when creating the condition, which corresponds to the % of users that it applies to. diff --git a/classes/experiment_manager.php b/classes/experiment_manager.php index 3d080d0..e7a4e43 100644 --- a/classes/experiment_manager.php +++ b/classes/experiment_manager.php @@ -37,6 +37,9 @@ class tool_abconfig_experiment_manager { // Experiment functions. + /** @var array Experiment js that needs to be rendered. */ + private static $renderjs = []; + /** * Add an experiment * @param string $name @@ -51,8 +54,14 @@ public function add_experiment($name, $shortname, $scope) { if ($this->experiment_exists($shortname)) { $return = false; } else { - $return = $DB->insert_record('tool_abconfig_experiment', - array('name' => $name, 'shortname' => $shortname, 'scope' => $scope, 'enabled' => 0, 'adminenabled' => 0)); + $return = $DB->insert_record('tool_abconfig_experiment', [ + 'name' => $name, + 'shortname' => $shortname, + 'scope' => $scope, + 'enabled' => 0, + 'adminenabled' => 0, + 'numoffset' => rand(0, 99), + ]); } self::invalidate_experiment_cache(); return $return; @@ -82,9 +91,10 @@ public function experiment_exists($shortname) { * @param string $scope * @param int $enabled * @param int $adminenabled + * @param int $numoffset * @return bool */ - public function update_experiment($prevshortname, $name, $shortname, $scope, $enabled, $adminenabled) { + public function update_experiment($prevshortname, $name, $shortname, $scope, $enabled, $adminenabled, $numoffset) { global $DB; // Check whether the experiment exists to be updated. if (!$this->experiment_exists($prevshortname)) { @@ -93,9 +103,15 @@ public function update_experiment($prevshortname, $name, $shortname, $scope, $en // Get id of record. $sqlexperiment = $DB->sql_compare_text($prevshortname, strlen($prevshortname)); $record = $DB->get_record_sql('SELECT * FROM {tool_abconfig_experiment} WHERE shortname = ?', array($sqlexperiment)); - - $return = $DB->update_record('tool_abconfig_experiment', array('id' => $record->id, 'name' => $name, - 'shortname' => $shortname, 'scope' => $scope, 'enabled' => $enabled, 'adminenabled' => $adminenabled)); + $return = $DB->update_record('tool_abconfig_experiment', (object) [ + 'id' => $record->id, + 'name' => $name, + 'shortname' => $shortname, + 'scope' => $scope, + 'enabled' => $enabled, + 'adminenabled' => $adminenabled, + 'numoffset' => $numoffset, + ]); } self::invalidate_experiment_cache(); return $return; @@ -300,6 +316,23 @@ public function get_active_session() { }); } + /** + * Get active devices. + * @return mixed + */ + public function get_active_device() { + $experiments = self::get_experiments(); + + // Filter array for only enabled session experiments. + return array_filter($experiments, function ($experiment) { + if ($experiment['enabled'] == 1 && $experiment['scope'] == 'device') { + return true; + } else { + return false; + } + }); + } + /** * Get active experiments * @return mixed @@ -317,6 +350,33 @@ public function get_active_experiments() { }); } + /** + * Sets experiment JS to be rendered. + * @param string $key + * @param string $value + * @return void + */ + public function set_render_js(string $key, string $value): void { + self::$renderjs[$key] = $value; + } + + /** + * Gets experiment JS to be rendered. + * @return array + */ + public function get_render_js(): array { + return self::$renderjs; + } + + /** + * Unsets experiment JS. + * @param string $key + * @return void + */ + public function remove_render_js(string $key): void { + unset(self::$renderjs[$key]); + } + /** * Log commands * @param string $commands diff --git a/classes/form/edit_experiment.php b/classes/form/edit_experiment.php index ccff693..d1c3cc2 100644 --- a/classes/form/edit_experiment.php +++ b/classes/form/edit_experiment.php @@ -66,9 +66,20 @@ public function definition() { $mform->addRule('experimentshortname', get_string('formexperimentshortnamereq', 'tool_abconfig'), 'required'); // Setup Data array for scopes. - $scopes = ['request' => get_string('request', 'tool_abconfig'), 'session' => get_string('session', 'tool_abconfig')]; + $scopes = [ + 'request' => get_string('request', 'tool_abconfig'), + 'session' => get_string('session', 'tool_abconfig'), + 'device' => get_string('device', 'tool_abconfig'), + ]; $mform->addElement('select', 'scope', get_string('formexperimentscopeselect', 'tool_abconfig'), $scopes); + $mform->addElement('text', 'numoffset', get_string('offset', 'tool_abconfig')); + $mform->setType('numoffset', PARAM_INT); + $mform->hideIf('numoffset', 'scope', 'neq', 'device'); + $mform->addRule('numoffset', get_string('err_numeric', 'form'), 'numeric', null, 'client'); + $mform->addRule('numoffset', get_string('maximumchars', '', 2), 'maxlength', 2, 'client'); + $mform->addHelpButton('numoffset', 'offset', 'tool_abconfig'); + // Enabled checkbox. $mform->addElement('advcheckbox', 'enabled', get_string('formexperimentenabled', 'tool_abconfig')); diff --git a/classes/form/manage_experiments.php b/classes/form/manage_experiments.php index 2e90256..91f2223 100644 --- a/classes/form/manage_experiments.php +++ b/classes/form/manage_experiments.php @@ -46,7 +46,11 @@ public function definition() { $mform = $this->_form; // Setup Data array for scopes. - $scopes = ['request' => get_string('request', 'tool_abconfig'), 'session' => get_string('session', 'tool_abconfig')]; + $scopes = [ + 'request' => get_string('request', 'tool_abconfig'), + 'session' => get_string('session', 'tool_abconfig'), + 'device' => get_string('device', 'tool_abconfig'), + ]; // Add section for adding experiments. $mform->addElement('header', 'addexperiment', get_string('formaddexperiment', 'tool_abconfig')); diff --git a/db/install.xml b/db/install.xml index 0b81e5a..32b1db3 100644 --- a/db/install.xml +++ b/db/install.xml @@ -1,5 +1,5 @@ - @@ -12,6 +12,7 @@ + @@ -33,4 +34,4 @@ - \ No newline at end of file + diff --git a/db/upgrade.php b/db/upgrade.php index 8c41c1f..0f423fa 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -45,5 +45,20 @@ function xmldb_tool_abconfig_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2022070600, 'tool', 'abconfig'); } + if ($oldversion < 2025011300) { + + // Define field numoffset to be added to tool_abconfig_experiment. + $table = new xmldb_table('tool_abconfig_experiment'); + $field = new xmldb_field('numoffset', XMLDB_TYPE_INTEGER, '5', null, null, null, null, 'adminenabled'); + + // Conditionally launch add field numoffset. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Abconfig savepoint reached. + upgrade_plugin_savepoint(true, 2025011300, 'tool', 'abconfig'); + } + return true; } diff --git a/edit_experiment.php b/edit_experiment.php index fe1a9db..ff8e419 100644 --- a/edit_experiment.php +++ b/edit_experiment.php @@ -49,7 +49,8 @@ $experiment = $DB->get_record('tool_abconfig_experiment', array('id' => $eid)); $data = array('experimentname' => $experiment->name, 'experimentshortname' => $experiment->shortname, 'prevshortname' => $experiment->shortname, 'scope' => $experiment->scope, - 'id' => $experiment->id, 'enabled' => $experiment->enabled, 'adminenabled' => $experiment->adminenabled); + 'id' => $experiment->id, 'enabled' => $experiment->enabled, 'adminenabled' => $experiment->adminenabled, + 'numoffset' => $experiment->numoffset ?? rand(0, 99)); $customarray = array('eid' => $experiment->id); @@ -72,6 +73,7 @@ $eid = $fromform->id; $prevshortname = $fromform->prevshortname; $adminenabled = $fromform->adminenabled; + $numoffset = $fromform->numoffset; // If eid is empty, do nothing. if ($eid == 0) { @@ -83,7 +85,7 @@ $manager->delete_experiment($shortname); $manager->delete_all_conditions($eid); } else { - $manager->update_experiment($prevshortname, $name, $shortname, $scope, $enabled, $adminenabled); + $manager->update_experiment($prevshortname, $name, $shortname, $scope, $enabled, $adminenabled, $numoffset); } redirect($prevurl); diff --git a/lang/en/tool_abconfig.php b/lang/en/tool_abconfig.php index 0abf59b..a86c4b8 100644 --- a/lang/en/tool_abconfig.php +++ b/lang/en/tool_abconfig.php @@ -65,9 +65,12 @@ // Short Strings. $string['request'] = 'Request'; $string['session'] = 'Session'; +$string['device'] = 'Device'; $string['name'] = 'Experiment name'; $string['shortname'] = 'Short experiment name'; $string['scope'] = 'Experiment scope'; +$string['offset'] = 'Offset'; +$string['offset_help'] = 'Offset value between 0 and 99 that will be used to rotate device hashes.'; $string['edit'] = 'Edit'; $string['enabled'] = 'Enabled'; $string['yes'] = 'Yes'; diff --git a/lib.php b/lib.php index 3c852b5..3de5402 100644 --- a/lib.php +++ b/lib.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die; /** - * After config + * After config, handles param, request and session experiments. * * This is a legacy callback that is used for compatibility with older Moodle versions. * Moodle 4.4+ will use tool_abconfig\hook_callbacks::after_config instead. @@ -147,6 +147,89 @@ function tool_abconfig_after_config() { } // @codingStandardsIgnoreEnd } +/** + * Before session, handles device experiments. + * + * At some point this will also get converted to hook in core. + * + * @return void|null + */ +function tool_abconfig_before_session_start() { + + global $USER, $CFG; + + try { + // Device experiments require IP and user agent, so can't be used for CLI scripts. + if (!isset($_SERVER['REMOTE_ADDR']) || !isset($_SERVER['HTTP_USER_AGENT'])) { + return null; + } + + // Setup experiment manager. + $manager = new tool_abconfig_experiment_manager(); + + // Check if the param to disable ABconfig is present, if so, exit. + if (!optional_param('abconfig', true, PARAM_BOOL)) { + if (is_siteadmin()) { + return null; + } + } + + // First, Build a list of all commands that need to be executed. + $commandarray = []; + + // Device scope. + $deviceexperiments = $manager->get_active_device(); + if (!empty($deviceexperiments)) { + // Create a hash using IP and useragent, and convert it to a number. + $hash = md5($_SERVER['REMOTE_ADDR'] . $_SERVER['HTTP_USER_AGENT']); + $basenum = hexdec(substr($hash, 0, 8)) % 100; + foreach ($deviceexperiments as $record) { + + // Make admin immune unless enabled for admin. + if (is_siteadmin()) { + if ($record['adminenabled'] == 0) { + continue; + } + } + + $conditionrecords = $record['conditions']; + + // Remove all conditions that contain the user ip in the whitelist. + $crecords = array(); + + foreach ($conditionrecords as $conditionrecord) { + $iplist = $conditionrecord['ipwhitelist']; + if (!remoteip_in_list($iplist)) { + array_push($crecords, $conditionrecord); + } + } + + // Device logic. + $num = ($basenum + $record['numoffset'] ?? 0) % 100; + $prevtotal = 0; + foreach ($crecords as $crecord) { + // If random hash is within this range, set condition and break, else increment total. + if ($num > $prevtotal && $num <= ($prevtotal + $crecord['value'])) { + $commandarray[$record['shortname']] = $crecord['commands']; + // Do not select any more conditions. + break; + } else { + // Not this record, increment lower bound, and move on. + $prevtotal += $crecord['value']; + } + } + } + } + + // Now, execute all commands in the arrays. + foreach ($commandarray as $shortname => $command) { + tool_abconfig_execute_command_array($command, $shortname); + } + } catch (Exception $e) { // @codingStandardsIgnoreStart + // Catch exceptions from stuff not existing during installation process, fail silently + } // @codingStandardsIgnoreEnd +} + /** * After require login * @return void|null @@ -259,9 +342,10 @@ function tool_abconfig_before_http_headers() { * @return void */ function tool_abconfig_execute_command_array($commandsencoded, $shortname, $js = false, string $string = null) { - global $CFG, $SESSION; + global $CFG; // Execute any commands passed in. + $manager = new tool_abconfig_experiment_manager(); $commands = json_decode($commandsencoded); foreach ($commands as $commandstring) { @@ -309,20 +393,20 @@ function tool_abconfig_execute_command_array($commandsencoded, $shortname, $js = if ($command == 'js_header') { // Check for JS header scripts. $commandarray = explode(',', $commandstring, 2); - // Set a unique session variable to be picked up by renderer hooks, to emit JS in the right areas. + // Set a unique manager variable to be picked up by renderer hooks, to emit JS in the right areas. $jsheaderunique = 'abconfig_js_header_'.$shortname; - // Store the unique in the session to be picked up by the header render hook. - $SESSION->$jsheaderunique = $commandarray[1]; + // Store the unique in the manager to be picked up by the header render hook. + $manager->set_render_js($jsheaderunique, $commandarray[1]); } if ($command == 'js_footer') { // Check for JS footer scripts. $commandarray = explode(',', $commandstring, 2); - // Set a unique session variable to be picked up by renderer hooks, to emit JS in the right areas. + // Set a unique manager variable to be picked up by renderer hooks, to emit JS in the right areas. $jsfooterunique = 'abconfig_js_footer_'.$shortname; - // Store the javascript in the session unique to be picked up by the footer render hook. - $SESSION->$jsfooterunique = $commandarray[1]; + // Store the javascript in the manager unique to be picked up by the footer render hook. + $manager->set_render_js($jsfooterunique, $commandarray[1]); } } } @@ -340,11 +424,10 @@ function tool_abconfig_execute_js(string $type) { } } - global $SESSION; - // Get all experiments. $manager = new tool_abconfig_experiment_manager(); $records = $manager->get_experiments(); + $renderjs = $manager->get_render_js(); foreach ($records as $record) { // If called from header. @@ -354,14 +437,14 @@ function tool_abconfig_execute_js(string $type) { $unique = 'abconfig_js_footer_'.$record['shortname']; } - if (property_exists($SESSION, $unique)) { + if (array_key_exists($unique, $renderjs)) { // Found JS to be executed. - echo ""; + echo ""; } // If experiment is request scope, unset var so it doesnt fire again. if ($record['scope'] == 'request' || $record['enabled'] == 0) { - unset($SESSION->$unique); + $manager->remove_render_js($unique); } } } diff --git a/tests/experiment_manager_test.php b/tests/experiment_manager_test.php index 50a0329..ee3f4ae 100644 --- a/tests/experiment_manager_test.php +++ b/tests/experiment_manager_test.php @@ -85,7 +85,7 @@ public function test_update_experiment() { array('name' => 'name', 'shortname' => 'shortname', 'scope' => 'request', 'enabled' => 0)); // Update all the values of the experiment. - $manager->update_experiment('shortname', 'name2', 'shortname2', 'session', 1, 1); + $manager->update_experiment('shortname', 'name2', 'shortname2', 'session', 1, 1, 33); // Get record and verify fields. $sqlexperiment = $DB->sql_compare_text('shortname2', strlen('shortname2')); @@ -96,6 +96,7 @@ public function test_update_experiment() { $this->assertEquals($record->scope, 'session'); $this->assertEquals($record->enabled, 1); $this->assertEquals($record->adminenabled, 1); + $this->assertEquals($record->numoffset, 33); } public function test_delete_experiment() { diff --git a/tests/lib_test.php b/tests/lib_test.php index 67b857e..b518c00 100644 --- a/tests/lib_test.php +++ b/tests/lib_test.php @@ -558,7 +558,7 @@ public function test_condition_users_user_does_match_by_id() { // Set up a valid experiment and a condition for User 1. $manager = new tool_abconfig_experiment_manager(); $experiment = $manager->add_experiment('Experiment', 'experiment', 'session'); - $manager->update_experiment('experiment', 'Experiment', 'experiment', 'session', 1, 1); + $manager->update_experiment('experiment', 'Experiment', 'experiment', 'session', 1, 1, 33); $manager->add_condition($experiment, 'Users', '', 'CFG,passwordpolicy,1', 100, [$user1->id, $user2->id]); // Execute the hook and confirm that the experiment was executed for User 1. @@ -587,7 +587,7 @@ public function test_condition_users_user_does_not_match_by_id() { // Set up a valid experiment and a condition for User 1. $manager = new tool_abconfig_experiment_manager(); $experiment = $manager->add_experiment('Experiment', 'experiment', 'session'); - $manager->update_experiment('experiment', 'Experiment', 'experiment', 'session', 1, 1); + $manager->update_experiment('experiment', 'Experiment', 'experiment', 'session', 1, 1, 33); $manager->add_condition($experiment, 'Users', '', 'CFG,passwordpolicy,1', 100, [$user1->id, $user3->id]); // Execute the hook and confirm that the experiment was not executed for User 2. diff --git a/version.php b/version.php index 4ecfc45..11611ba 100644 --- a/version.php +++ b/version.php @@ -25,8 +25,8 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2024060404; // The current plugin version (Date: YYYYMMDDXX). -$plugin->release = 2024060404; // Same as version. +$plugin->version = 2025011300; // The current plugin version (Date: YYYYMMDDXX). +$plugin->release = 2025011300; // Same as version. $plugin->requires = 2014051217; $plugin->supported = [38, 405]; // Available as of Moodle 3.8.0 or later. $plugin->component = "tool_abconfig";