From e55abd713eec21f4f1b3929b5125b340d828c6df Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Tue, 28 Sep 2021 08:35:04 +0100 Subject: [PATCH] MDL-72662 reportbuilder: add relative date options to date filter. Allow user to filter dates relative to the current date, e.g dates within the previous year, the current week or next month. --- admin/classes/local/entities/task_log.php | 8 +- lang/en/reportbuilder.php | 7 + .../classes/local/entities/config_change.php | 4 +- reportbuilder/classes/local/filters/date.php | 167 +++++++++++++++++- .../tests/local/filters/date_test.php | 62 ++++++- 5 files changed, 242 insertions(+), 6 deletions(-) diff --git a/admin/classes/local/entities/task_log.php b/admin/classes/local/entities/task_log.php index d54f260b386..d64a8561c71 100644 --- a/admin/classes/local/entities/task_log.php +++ b/admin/classes/local/entities/task_log.php @@ -281,7 +281,13 @@ class task_log extends base { $this->get_entity_name(), "{$tablealias}.timestart" )) - ->add_joins($this->get_joins()); + ->add_joins($this->get_joins()) + ->set_limited_operators([ + date::DATE_ANY, + date::DATE_RANGE, + date::DATE_PREVIOUS, + date::DATE_CURRENT, + ]); // Duration filter. $filters[] = (new filter( diff --git a/lang/en/reportbuilder.php b/lang/en/reportbuilder.php index 50641d1bd8f..9ebdc7e45b6 100644 --- a/lang/en/reportbuilder.php +++ b/lang/en/reportbuilder.php @@ -34,8 +34,15 @@ $string['errorreportaccess'] = 'You can not view this report'; $string['errorsourceinvalid'] = 'Could not find valid report source'; $string['errorsourceunavailable'] = 'Report source is not available'; $string['filtercontains'] = 'Contains'; +$string['filterdatecurrent'] = 'Current'; +$string['filterdatedays'] = 'Day(s)'; $string['filterdatefrom'] = 'Date from'; +$string['filterdatemonths'] = 'Month(s)'; +$string['filterdatenext'] = 'Next'; +$string['filterdateprevious'] = 'Previous'; $string['filterdateto'] = 'Date to'; +$string['filterdateweeks'] = 'Week(s)'; +$string['filterdateyears'] = 'Year(s)'; $string['filterdoesnotcontain'] = 'Does not contain'; $string['filterdurationunit'] = '{$a} unit'; $string['filterendswith'] = 'Ends with'; diff --git a/report/configlog/classes/local/entities/config_change.php b/report/configlog/classes/local/entities/config_change.php index 62ebeb62774..7ba29cb83c2 100644 --- a/report/configlog/classes/local/entities/config_change.php +++ b/report/configlog/classes/local/entities/config_change.php @@ -154,8 +154,6 @@ class config_change extends base { * @return filter[] */ protected function get_all_filters(): array { - global $DB; - $tablealias = $this->get_table_alias('config_log'); // Time modified filter. @@ -170,6 +168,8 @@ class config_change extends base { ->set_limited_operators([ date::DATE_ANY, date::DATE_RANGE, + date::DATE_PREVIOUS, + date::DATE_CURRENT, ]); // Setting filter. diff --git a/reportbuilder/classes/local/filters/date.php b/reportbuilder/classes/local/filters/date.php index 5d390e56ec8..827f08c39ea 100644 --- a/reportbuilder/classes/local/filters/date.php +++ b/reportbuilder/classes/local/filters/date.php @@ -18,6 +18,7 @@ declare(strict_types=1); namespace core_reportbuilder\local\filters; +use DateTimeImmutable; use lang_string; use MoodleQuickForm; use core_reportbuilder\local\helpers\database; @@ -45,6 +46,27 @@ class date extends base { /** @var int Date within defined range */ public const DATE_RANGE = 3; + /** @var int Date in the previous [X relative date unit(s)] */ + public const DATE_PREVIOUS = 4; + + /** @var int Date in current [relative date unit] */ + public const DATE_CURRENT = 5; + + /** @var int Date in the next [X relative date unit(s)] */ + public const DATE_NEXT = 6; + + /** @var int Relative date unit for a day */ + public const DATE_UNIT_DAY = 1; + + /** @var int Relative date unit for a week */ + public const DATE_UNIT_WEEK = 2; + + /** @var int Relative date unit for a month */ + public const DATE_UNIT_MONTH = 3; + + /** @var int Relative date unit for a month */ + public const DATE_UNIT_YEAR = 4; + /** * Return an array of operators available for this filter * @@ -56,6 +78,9 @@ class date extends base { self::DATE_NOT_EMPTY => new lang_string('filterisnotempty', 'core_reportbuilder'), self::DATE_EMPTY => new lang_string('filterisempty', 'core_reportbuilder'), self::DATE_RANGE => new lang_string('filterrange', 'core_reportbuilder'), + self::DATE_PREVIOUS => new lang_string('filterdateprevious', 'core_reportbuilder'), + self::DATE_CURRENT => new lang_string('filterdatecurrent', 'core_reportbuilder'), + self::DATE_NEXT => new lang_string('filterdatenext', 'core_reportbuilder'), ]; return $this->filter->restrict_limited_operators($operators); @@ -67,11 +92,46 @@ class date extends base { * @param MoodleQuickForm $mform */ public function setup_form(MoodleQuickForm $mform): void { + // Operator selector. $operatorlabel = get_string('filterfieldoperator', 'core_reportbuilder', $this->get_header()); - $mform->addElement('select', "{$this->name}_operator", $operatorlabel, $this->get_operators())->setHiddenLabel(true); + + $elements[] = $mform->createElement('select', "{$this->name}_operator", $operatorlabel, $this->get_operators()); $mform->setType("{$this->name}_operator", PARAM_INT); $mform->setDefault("{$this->name}_operator", self::DATE_ANY); + // Value selector for previous and next operators. + $valuelabel = get_string('filterfieldvalue', 'core_reportbuilder', $this->get_header()); + + $elements[] = $mform->createElement('text', "{$this->name}_value", $valuelabel, ['size' => 3]); + $mform->setType("{$this->name}_value", PARAM_INT); + $mform->setDefault("{$this->name}_value", 1); + $mform->hideIf("{$this->name}_value", "{$this->name}_operator", 'eq', self::DATE_ANY); + $mform->hideIf("{$this->name}_value", "{$this->name}_operator", 'eq', self::DATE_NOT_EMPTY); + $mform->hideIf("{$this->name}_value", "{$this->name}_operator", 'eq', self::DATE_EMPTY); + $mform->hideIf("{$this->name}_value", "{$this->name}_operator", 'eq', self::DATE_RANGE); + $mform->disabledIf("{$this->name}_value", "{$this->name}_operator", 'eq', self::DATE_CURRENT); + + // Unit selector for previous and next operators. + $unitlabel = get_string('filterdurationunit', 'core_reportbuilder', $this->get_header()); + $units = [ + self::DATE_UNIT_DAY => get_string('filterdatedays', 'core_reportbuilder'), + self::DATE_UNIT_WEEK => get_string('filterdateweeks', 'core_reportbuilder'), + self::DATE_UNIT_MONTH => get_string('filterdatemonths', 'core_reportbuilder'), + self::DATE_UNIT_YEAR => get_string('filterdateyears', 'core_reportbuilder'), + ]; + + $elements[] = $mform->createElement('select', "{$this->name}_unit", $unitlabel, $units); + $mform->setType("{$this->name}_unit", PARAM_INT); + $mform->setDefault("{$this->name}_unit", self::DATE_UNIT_DAY); + $mform->hideIf("{$this->name}_unit", "{$this->name}_operator", 'eq', self::DATE_ANY); + $mform->hideIf("{$this->name}_unit", "{$this->name}_operator", 'eq', self::DATE_NOT_EMPTY); + $mform->hideIf("{$this->name}_unit", "{$this->name}_operator", 'eq', self::DATE_EMPTY); + $mform->hideIf("{$this->name}_unit", "{$this->name}_operator", 'eq', self::DATE_RANGE); + + // Add operator/value/unit group. + $mform->addGroup($elements, "{$this->name}_group", '', null, false); + + // Date selectors for range operator. $mform->addElement('date_selector', "{$this->name}_from", get_string('filterdatefrom', 'core_reportbuilder'), ['optional' => true]); $mform->setType("{$this->name}_from", PARAM_INT); @@ -95,7 +155,10 @@ class date extends base { $fieldsql = $this->filter->get_field_sql(); $params = $this->filter->get_field_params(); - $operator = $values["{$this->name}_operator"] ?? self::DATE_ANY; + $operator = (int) ($values["{$this->name}_operator"] ?? self::DATE_ANY); + $dateunitvalue = (int) ($values["{$this->name}_value"] ?? 1); + $dateunit = (int) ($values["{$this->name}_unit"] ?? self::DATE_UNIT_DAY); + switch ($operator) { case self::DATE_NOT_EMPTY: $sql = "{$fieldsql} IS NOT NULL AND {$fieldsql} <> 0"; @@ -122,6 +185,26 @@ class date extends base { $sql = implode(' AND ', $clauses); + break; + // Relative helper method can handle these three cases. + case self::DATE_PREVIOUS: + case self::DATE_CURRENT: + case self::DATE_NEXT: + + // Previous and next operators require a unit value greater than zero. + if ($operator !== self::DATE_CURRENT && $dateunitvalue === 0) { + return ['', []]; + } + + $paramdatefrom = database::generate_param_name(); + $paramdateto = database::generate_param_name(); + + $sql = "{$fieldsql} >= :{$paramdatefrom} AND {$fieldsql} <= :{$paramdateto}"; + [ + $params[$paramdatefrom], + $params[$paramdateto], + ] = self::get_relative_timeframe($operator, $dateunitvalue, $dateunit); + break; default: // Invalid or inactive filter. @@ -130,4 +213,84 @@ class date extends base { return [$sql, $params]; } + + /** + * Return start and end time of given relative date period + * + * @param int $operator + * @param int $dateunitvalue + * @param int $dateunit + * @return int[] + */ + private static function get_relative_timeframe(int $operator, int $dateunitvalue, int $dateunit): array { + $datenow = new DateTimeImmutable(); + + switch ($dateunit) { + case self::DATE_UNIT_DAY: + // Current day. + $datestart = $dateend = $datenow; + + if ($operator === self::DATE_PREVIOUS) { + $datestart = $datestart->modify("-{$dateunitvalue} day"); + $dateend = $dateend->modify('-1 day'); + } else if ($operator === self::DATE_NEXT) { + $datestart = $datestart->modify('+1 day'); + $dateend = $dateend->modify("+{$dateunitvalue} day"); + } + + break; + case self::DATE_UNIT_WEEK: + // Current week. + $datestart = $datenow->modify('monday this week'); + $dateend = $datenow->modify('sunday this week'); + + if ($operator === self::DATE_PREVIOUS) { + $datestart = $datestart->modify("-{$dateunitvalue} week"); + $dateend = $dateend->modify('-1 week'); + } else if ($operator === self::DATE_NEXT) { + $datestart = $datestart->modify('+1 week'); + $dateend = $dateend->modify("+{$dateunitvalue} week"); + } + + break; + case self::DATE_UNIT_MONTH: + // Current month. + $datestart = $datenow->modify('first day of this month'); + $dateend = $datenow->modify('last day of this month'); + + [$dateyear, $datemonth] = explode('/', $datenow->format('Y/m')); + if ($operator === self::DATE_PREVIOUS) { + $datestart = $datestart->setDate((int) $dateyear, $datemonth - $dateunitvalue, 1); + $dateend = $dateend->modify('last day of last month'); + } else if ($operator === self::DATE_NEXT) { + $datestart = $datestart->modify('first day of next month'); + $dateend = $dateend->setDate((int) $dateyear, $datemonth + $dateunitvalue, 1) + ->modify('last day of this month'); + } + + break; + case self::DATE_UNIT_YEAR: + // Current year. + $datestart = $datenow->modify('first day of january this year'); + $dateend = $datenow->modify('last day of december this year'); + + $dateyear = (int) $datenow->format('Y'); + if ($operator === self::DATE_PREVIOUS) { + $datestart = $datestart->setDate($dateyear - $dateunitvalue, 1, 1); + $dateend = $dateend->modify('last day of december last year'); + } else if ($operator === self::DATE_NEXT) { + $datestart = $datestart->modify('first day of january next year'); + $dateend = $dateend->setDate($dateyear + $dateunitvalue, 12, 31); + } + + break; + default: + return [0, 0]; + } + + return [ + $datestart->setTime(0, 0)->getTimestamp(), + $dateend->setTime(23, 59, 59)->getTimestamp(), + ]; + } } diff --git a/reportbuilder/tests/local/filters/date_test.php b/reportbuilder/tests/local/filters/date_test.php index e9073a4f72d..40daae1f9ba 100644 --- a/reportbuilder/tests/local/filters/date_test.php +++ b/reportbuilder/tests/local/filters/date_test.php @@ -31,7 +31,7 @@ use core_reportbuilder\local\report\filter; * @copyright 2021 Paul Holden * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class date_testcase extends advanced_testcase { +class date_test extends advanced_testcase { /** * Data provider for {@see test_get_sql_filter_simple} @@ -114,4 +114,64 @@ class date_testcase extends advanced_testcase { $usernames = $DB->get_fieldset_select('user', 'username', $select, $params); $this->assertEquals([$usertwo->username], $usernames); } + + /** + * Data provider for {@see test_get_sql_filter_relative} + * + * @return array + */ + public function get_sql_filter_relative_provider(): array { + return [ + 'Previous day' => [date::DATE_PREVIOUS, 1, date::DATE_UNIT_DAY, '-1 day'], + 'Previous week' => [date::DATE_PREVIOUS, 1, date::DATE_UNIT_WEEK, '-1 week'], + 'Previous month' => [date::DATE_PREVIOUS, 1, date::DATE_UNIT_MONTH, 'last day of last month'], + 'Previous year' => [date::DATE_PREVIOUS, 1, date::DATE_UNIT_YEAR, 'last day of december last year'], + + 'Current day' => [date::DATE_CURRENT, null, date::DATE_UNIT_DAY], + 'Current week' => [date::DATE_CURRENT, null, date::DATE_UNIT_WEEK], + 'Current month' => [date::DATE_CURRENT, null, date::DATE_UNIT_MONTH], + 'Current year' => [date::DATE_CURRENT, null, date::DATE_UNIT_YEAR], + + 'Next day' => [date::DATE_NEXT, 1, date::DATE_UNIT_DAY, '+1 day'], + 'Next week' => [date::DATE_NEXT, 1, date::DATE_UNIT_WEEK, '+1 week'], + 'Next month' => [date::DATE_NEXT, 1, date::DATE_UNIT_MONTH, 'first day of next month'], + 'Next year' => [date::DATE_NEXT, 1, date::DATE_UNIT_YEAR, 'first day of january next year'], + ]; + } + + /** + * Unit tests for filtering relative dates + * + * @param int $operator + * @param int|null $unitvalue + * @param int $unit + * @param string|null $timecreated Relative time suitable for passing to {@see strtotime} (or null for current time) + * + * @dataProvider get_sql_filter_relative_provider + */ + public function test_get_sql_filter_relative(int $operator, ?int $unitvalue, int $unit, ?string $timecreated = null): void { + global $DB; + + $this->resetAfterTest(); + + $usertimecreated = ($timecreated !== null ? strtotime($timecreated) : time()); + $user = $this->getDataGenerator()->create_user(['timecreated' => $usertimecreated]); + + $filter = new filter( + date::class, + 'test', + new lang_string('yes'), + 'testentity', + 'timecreated' + ); + + [$select, $params] = date::create($filter)->get_sql_filter([ + $filter->get_unique_identifier() . '_operator' => $operator, + $filter->get_unique_identifier() . '_value' => $unitvalue, + $filter->get_unique_identifier() . '_unit' => $unit, + ]); + + $matchingusers = $DB->get_fieldset_select('user', 'username', $select, $params); + $this->assertContains($user->username, $matchingusers); + } }