Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/moodle-ci.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
36 changes: 36 additions & 0 deletions classes/local/data_grid/filter/forced_condition_interface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?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/>.

/**
* Marker interface for conditions that must never be removed from a data source.
*
* @package block_dash
* @copyright 2026 bdecent gmbh <https://bdecent.de>
* @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 {
}
17 changes: 17 additions & 0 deletions classes/local/data_source/abstract_data_source.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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')) {
Expand Down Expand Up @@ -382,13 +393,19 @@ 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);
}
}
} 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);
}
}
Expand Down
138 changes: 138 additions & 0 deletions tests/forced_condition_test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?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/>.

/**
* Unit tests for forced conditions and the filter collection augment callback.
*
* @package block_dash
* @copyright 2026 bdecent gmbh <https://bdecent.de>
* @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);
}
}
2 changes: 1 addition & 1 deletion version.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading