diff --git a/.github/workflows/moodle-ci.yml b/.github/workflows/moodle-ci.yml index 8a5bb99..1d2cb21 100644 --- a/.github/workflows/moodle-ci.yml +++ b/.github/workflows/moodle-ci.yml @@ -1,6 +1,11 @@ name: Moodle Plugin CI -on: [push, pull_request] +on: + # Pull requests are built on every branch; direct pushes only on the long-lived + # branches, so a PR branch does not build twice for the same commit. + push: + branches: [main, dev] + pull_request: jobs: test: diff --git a/classes/local/data_grid/filter/forced_condition_interface.php b/classes/local/data_grid/filter/forced_condition_interface.php new file mode 100644 index 0000000..4183a2c --- /dev/null +++ b/classes/local/data_grid/filter/forced_condition_interface.php @@ -0,0 +1,36 @@ +. + +/** + * Marker interface for conditions that must never be removed from a data source. + * + * @package block_dash + * @copyright 2026 bdecent gmbh + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace block_dash\local\data_grid\filter; + +/** + * Conditions implementing this interface always apply, regardless of block preferences. + * + * Regular filters and conditions are stripped in before_data() unless they are enabled + * in the block instance preferences. A forced condition (for example a tenant restriction + * injected via the dash_augment_filter_collection callback) must survive that stripping, + * otherwise it would silently fail open. + */ +interface forced_condition_interface { +} diff --git a/classes/local/data_source/abstract_data_source.php b/classes/local/data_source/abstract_data_source.php index 4e7c0d1..12dc9c2 100755 --- a/classes/local/data_source/abstract_data_source.php +++ b/classes/local/data_source/abstract_data_source.php @@ -31,6 +31,7 @@ use block_dash\local\data_grid\field\attribute\identifier_attribute; use block_dash\local\data_grid\data\data_collection_interface; use block_dash\local\data_grid\filter\filter_collection_interface; +use block_dash\local\data_grid\filter\forced_condition_interface; use block_dash\local\paginator; use block_dash\local\data_source\form\preferences_form; use block_dash\local\layout\grid_layout; @@ -336,6 +337,16 @@ final public function get_filter_collection() { if (is_null($this->filtercollection)) { $this->filtercollection = $this->build_filter_collection(); + // Allow other plugins to augment the filter collection of any data source + // (e.g. inject additional filters or forced conditions). + if ($pluginsfunction = get_plugins_with_function('dash_augment_filter_collection')) { + foreach ($pluginsfunction as $plugins) { + foreach ($plugins as $pluginfunction) { + $pluginfunction($this, $this->filtercollection); + } + } + } + // Apply saved filter preferences before init() so that init() can skip // filters that are not enabled (avoids loading options that are never used). if ($this->get_preferences('filters')) { @@ -382,6 +393,9 @@ public function before_data() { } // No preferences set yet, remove all filters. foreach ($this->get_filter_collection()->get_filters() as $filter) { + if ($filter instanceof forced_condition_interface) { + continue; + } if (!in_array($filter->get_name(), $enabledfilters)) { $this->get_filter_collection()->remove_filter($filter); } @@ -389,6 +403,9 @@ public function before_data() { } else { // No preferences set yet, remove all filters. foreach ($this->get_filter_collection()->get_filters() as $filter) { + if ($filter instanceof forced_condition_interface) { + continue; + } $this->get_filter_collection()->remove_filter($filter); } } diff --git a/tests/forced_condition_test.php b/tests/forced_condition_test.php new file mode 100644 index 0000000..2c29a0d --- /dev/null +++ b/tests/forced_condition_test.php @@ -0,0 +1,138 @@ +. + +/** + * Unit tests for forced conditions and the filter collection augment callback. + * + * @package block_dash + * @copyright 2026 bdecent gmbh + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace block_dash; + +use block_dash\local\data_grid\filter\condition; +use block_dash\local\data_grid\filter\forced_condition_interface; +use block_dash\local\data_source\users_data_source; + +/** + * Unit tests for forced conditions surviving preference based filter removal. + * + * @group block_dash + * @group bdecent + * @group forced_condition_test + * @covers \block_dash\local\data_source\abstract_data_source + */ +final class forced_condition_test extends \advanced_testcase { + /** + * This method is called before each test. + */ + protected function setUp(): void { + global $CFG; + + parent::setUp(); + $this->resetAfterTest(); + $this->setAdminUser(); + + require_once($CFG->dirroot . '/blocks/dash/lib.php'); + } + + /** + * Build a forced condition stub. + * + * @return condition + */ + private function new_forced_condition(): condition { + return new class ('testforced', 'u.id') extends condition implements forced_condition_interface { + /** + * Static WHERE fragment for testing. + * + * @return array + */ + public function get_sql_and_params() { + return ['u.id > :testforcedparam', ['testforcedparam' => 0]]; + } + }; + } + + /** + * A forced condition survives before_data() when no filter preferences exist. + */ + public function test_forced_condition_survives_without_preferences(): void { + $source = new users_data_source(\context_system::instance()); + + $forced = $this->new_forced_condition(); + $forced->init(); + $source->get_filter_collection()->add_filter($forced); + + $countbefore = count($source->get_filter_collection()->get_filters()); + $this->assertGreaterThan(1, $countbefore); + + $source->before_data(); + + // Everything except the forced condition was removed. + $this->assertTrue($source->get_filter_collection()->has_filter('testforced')); + $this->assertCount(1, $source->get_filter_collection()->get_filters()); + } + + /** + * A forced condition survives before_data() when preferences enable other filters. + */ + public function test_forced_condition_survives_with_preferences(): void { + $source = new users_data_source(\context_system::instance()); + $source->set_preferences(['filters' => ['unrelated' => ['enabled' => 1]]]); + + $forced = $this->new_forced_condition(); + $forced->init(); + $source->get_filter_collection()->add_filter($forced); + + $source->before_data(); + + $this->assertTrue($source->get_filter_collection()->has_filter('testforced')); + } + + /** + * A forced condition contributes its WHERE fragment to the final query. + */ + public function test_forced_condition_contributes_sql(): void { + $source = new users_data_source(\context_system::instance()); + + $forced = $this->new_forced_condition(); + $forced->init(); + $source->get_filter_collection()->add_filter($forced); + + $source->before_data(); + + [$sql, $params] = $source->get_query()->get_sql_and_params(); + + $this->assertStringContainsString('u.id > :testforcedparam', $sql); + $this->assertArrayHasKey('testforcedparam', $params); + } + + /** + * The augment callback lookup runs without errors while building collections. + */ + public function test_augment_callback_is_invoked_safely(): void { + $source = new users_data_source(\context_system::instance()); + + // Building the collection triggers get_plugins_with_function('dash_augment_filter_collection'). + $collection = $source->get_filter_collection(); + + $this->assertNotNull($collection); + [$sql, $params] = $source->get_query()->get_sql_and_params(); + $this->assertStringContainsString('{user}', $sql); + } +} diff --git a/version.php b/version.php index d5a1e90..76e6921 100644 --- a/version.php +++ b/version.php @@ -23,7 +23,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2026070202; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2026071300; // The current plugin version (Date: YYYYMMDDXX). $plugin->requires = 2024100700; // Requires this Moodle version. $plugin->component = 'block_dash'; // Full name of the plugin (used for diagnostics). $plugin->maturity = MATURITY_STABLE;