| 1 | efrain | 1 | <?php
 | 
        
           |  |  | 2 | // This file is part of Moodle - http://moodle.org/
 | 
        
           |  |  | 3 | //
 | 
        
           |  |  | 4 | // Moodle is free software: you can redistribute it and/or modify
 | 
        
           |  |  | 5 | // it under the terms of the GNU General Public License as published by
 | 
        
           |  |  | 6 | // the Free Software Foundation, either version 3 of the License, or
 | 
        
           |  |  | 7 | // (at your option) any later version.
 | 
        
           |  |  | 8 | //
 | 
        
           |  |  | 9 | // Moodle is distributed in the hope that it will be useful,
 | 
        
           |  |  | 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
        
           |  |  | 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | 
        
           |  |  | 12 | // GNU General Public License for more details.
 | 
        
           |  |  | 13 | //
 | 
        
           |  |  | 14 | // You should have received a copy of the GNU General Public License
 | 
        
           |  |  | 15 | // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 | 
        
           |  |  | 16 |   | 
        
           |  |  | 17 | declare(strict_types=1);
 | 
        
           |  |  | 18 |   | 
        
           |  |  | 19 | namespace core_reportbuilder\table;
 | 
        
           |  |  | 20 |   | 
        
           |  |  | 21 | use context;
 | 
        
           |  |  | 22 | use moodle_url;
 | 
        
           |  |  | 23 | use renderable;
 | 
        
           |  |  | 24 | use table_sql;
 | 
        
           |  |  | 25 | use html_writer;
 | 
        
           |  |  | 26 | use core_table\dynamic;
 | 
        
           |  |  | 27 | use core_reportbuilder\local\helpers\database;
 | 
        
           |  |  | 28 | use core_reportbuilder\local\filters\base;
 | 
        
           |  |  | 29 | use core_reportbuilder\local\models\report;
 | 
        
           |  |  | 30 | use core_reportbuilder\local\report\base as base_report;
 | 
        
           |  |  | 31 | use core_reportbuilder\local\report\filter;
 | 
        
           |  |  | 32 | use core\output\notification;
 | 
        
           |  |  | 33 |   | 
        
           |  |  | 34 | defined('MOODLE_INTERNAL') || die;
 | 
        
           |  |  | 35 |   | 
        
           |  |  | 36 | require_once("{$CFG->libdir}/tablelib.php");
 | 
        
           |  |  | 37 |   | 
        
           |  |  | 38 | /**
 | 
        
           |  |  | 39 |  * Base report dynamic table class
 | 
        
           |  |  | 40 |  *
 | 
        
           |  |  | 41 |  * @package     core_reportbuilder
 | 
        
           |  |  | 42 |  * @copyright   2021 David Matamoros <davidmc@moodle.com>
 | 
        
           |  |  | 43 |  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 | 
        
           |  |  | 44 |  */
 | 
        
           |  |  | 45 | abstract class base_report_table extends table_sql implements dynamic, renderable {
 | 
        
           |  |  | 46 |   | 
        
           |  |  | 47 |     /** @var report $persistent */
 | 
        
           |  |  | 48 |     protected $persistent;
 | 
        
           |  |  | 49 |   | 
        
           |  |  | 50 |     /** @var base_report $report */
 | 
        
           |  |  | 51 |     protected $report;
 | 
        
           |  |  | 52 |   | 
        
           |  |  | 53 |     /** @var string $groupbysql */
 | 
        
           |  |  | 54 |     protected $groupbysql = '';
 | 
        
           |  |  | 55 |   | 
        
           |  |  | 56 |     /** @var bool $editing */
 | 
        
           |  |  | 57 |     protected $editing = false;
 | 
        
           |  |  | 58 |   | 
        
           |  |  | 59 |     /**
 | 
        
           |  |  | 60 |      * Initialises table SQL properties
 | 
        
           |  |  | 61 |      *
 | 
        
           |  |  | 62 |      * @param string $fields
 | 
        
           |  |  | 63 |      * @param string $from
 | 
        
           |  |  | 64 |      * @param array $joins
 | 
        
           |  |  | 65 |      * @param string $where
 | 
        
           |  |  | 66 |      * @param array $params
 | 
        
           |  |  | 67 |      * @param array $groupby
 | 
        
           |  |  | 68 |      */
 | 
        
           |  |  | 69 |     protected function init_sql(string $fields, string $from, array $joins, string $where, array $params,
 | 
        
           |  |  | 70 |             array $groupby = []): void {
 | 
        
           |  |  | 71 |   | 
        
           |  |  | 72 |         $wheres = [];
 | 
        
           |  |  | 73 |         if ($where !== '') {
 | 
        
           |  |  | 74 |             $wheres[] = $where;
 | 
        
           |  |  | 75 |         }
 | 
        
           |  |  | 76 |   | 
        
           |  |  | 77 |         // Track the index of conditions/filters as we iterate over them.
 | 
        
           |  |  | 78 |         $conditionindex = $filterindex = 0;
 | 
        
           |  |  | 79 |   | 
        
           |  |  | 80 |         // For each condition, we need to ensure their values are always accounted for in the report.
 | 
        
           |  |  | 81 |         $conditionvalues = $this->report->get_condition_values();
 | 
        
           | 1441 | ariadna | 82 |         foreach ($this->report->get_active_conditions(false) as $condition) {
 | 
        
           | 1 | efrain | 83 |             [$conditionsql, $conditionparams] = $this->get_filter_sql($condition, $conditionvalues, 'c' . $conditionindex++);
 | 
        
           |  |  | 84 |             if ($conditionsql !== '') {
 | 
        
           |  |  | 85 |                 $joins = array_merge($joins, $condition->get_joins());
 | 
        
           |  |  | 86 |                 $wheres[] = "({$conditionsql})";
 | 
        
           |  |  | 87 |                 $params = array_merge($params, $conditionparams);
 | 
        
           |  |  | 88 |             }
 | 
        
           |  |  | 89 |         }
 | 
        
           |  |  | 90 |   | 
        
           |  |  | 91 |         // For each filter, we also need to apply their values (will differ according to user viewing the report).
 | 
        
           |  |  | 92 |         if (!$this->editing) {
 | 
        
           |  |  | 93 |             $filtervalues = $this->report->get_filter_values();
 | 
        
           |  |  | 94 |             foreach ($this->report->get_active_filters() as $filter) {
 | 
        
           |  |  | 95 |                 [$filtersql, $filterparams] = $this->get_filter_sql($filter, $filtervalues, 'f' . $filterindex++);
 | 
        
           |  |  | 96 |                 if ($filtersql !== '') {
 | 
        
           |  |  | 97 |                     $joins = array_merge($joins, $filter->get_joins());
 | 
        
           |  |  | 98 |                     $wheres[] = "({$filtersql})";
 | 
        
           |  |  | 99 |                     $params = array_merge($params, $filterparams);
 | 
        
           |  |  | 100 |                 }
 | 
        
           |  |  | 101 |             }
 | 
        
           |  |  | 102 |         }
 | 
        
           |  |  | 103 |   | 
        
           |  |  | 104 |         // Join all the filters into a SQL WHERE clause, falling back to all records.
 | 
        
           |  |  | 105 |         if (!empty($wheres)) {
 | 
        
           |  |  | 106 |             $wheresql = implode(' AND ', $wheres);
 | 
        
           |  |  | 107 |         } else {
 | 
        
           |  |  | 108 |             $wheresql = '1=1';
 | 
        
           |  |  | 109 |         }
 | 
        
           |  |  | 110 |   | 
        
           |  |  | 111 |         if (!empty($groupby)) {
 | 
        
           |  |  | 112 |             $this->groupbysql = 'GROUP BY ' . implode(', ', $groupby);
 | 
        
           |  |  | 113 |         }
 | 
        
           |  |  | 114 |   | 
        
           |  |  | 115 |         // Add unique table joins.
 | 
        
           |  |  | 116 |         $from .= ' ' . implode(' ', array_unique($joins));
 | 
        
           |  |  | 117 |   | 
        
           |  |  | 118 |         $this->set_sql($fields, $from, $wheresql, $params);
 | 
        
           |  |  | 119 |     }
 | 
        
           |  |  | 120 |   | 
        
           |  |  | 121 |     /**
 | 
        
           |  |  | 122 |      * Whether the current report table is being edited, in which case certain actions are not applied to it, e.g. user filtering
 | 
        
           |  |  | 123 |      * and sorting. Default class value is false
 | 
        
           |  |  | 124 |      *
 | 
        
           |  |  | 125 |      * @param bool $editing
 | 
        
           |  |  | 126 |      */
 | 
        
           |  |  | 127 |     public function set_report_editing(bool $editing): void {
 | 
        
           |  |  | 128 |         $this->editing = $editing;
 | 
        
           |  |  | 129 |     }
 | 
        
           |  |  | 130 |   | 
        
           |  |  | 131 |     /**
 | 
        
           |  |  | 132 |      * Return SQL fragments from given filter instance suitable for inclusion in table SQL
 | 
        
           |  |  | 133 |      *
 | 
        
           |  |  | 134 |      * @param filter $filter
 | 
        
           |  |  | 135 |      * @param array $filtervalues
 | 
        
           |  |  | 136 |      * @param string $paramprefix
 | 
        
           |  |  | 137 |      * @return array [$sql, $params]
 | 
        
           |  |  | 138 |      */
 | 
        
           |  |  | 139 |     private function get_filter_sql(filter $filter, array $filtervalues, string $paramprefix): array {
 | 
        
           |  |  | 140 |         /** @var base $filterclass */
 | 
        
           |  |  | 141 |         $filterclass = $filter->get_filter_class();
 | 
        
           |  |  | 142 |   | 
        
           |  |  | 143 |         // Retrieve SQL fragments from the filter instance, process parameters if required.
 | 
        
           |  |  | 144 |         [$sql, $params] = $filterclass::create($filter)->get_sql_filter($filtervalues);
 | 
        
           |  |  | 145 |         if ($paramprefix !== '' && count($params) > 0) {
 | 
        
           |  |  | 146 |             return database::sql_replace_parameters(
 | 
        
           |  |  | 147 |                 $sql,
 | 
        
           |  |  | 148 |                 $params,
 | 
        
           |  |  | 149 |                 fn(string $param) => "{$paramprefix}_{$param}",
 | 
        
           |  |  | 150 |             );
 | 
        
           |  |  | 151 |         }
 | 
        
           |  |  | 152 |   | 
        
           |  |  | 153 |         return [$sql, $params];
 | 
        
           |  |  | 154 |     }
 | 
        
           |  |  | 155 |   | 
        
           |  |  | 156 |     /**
 | 
        
           |  |  | 157 |      * Generate suitable SQL for the table
 | 
        
           |  |  | 158 |      *
 | 
        
           | 1441 | ariadna | 159 |      * @param bool $includesort
 | 
        
           | 1 | efrain | 160 |      * @return string
 | 
        
           |  |  | 161 |      */
 | 
        
           | 1441 | ariadna | 162 |     protected function get_table_sql(bool $includesort = true): string {
 | 
        
           | 1 | efrain | 163 |         $sql = "SELECT {$this->sql->fields} FROM {$this->sql->from} WHERE {$this->sql->where} {$this->groupbysql}";
 | 
        
           |  |  | 164 |   | 
        
           | 1441 | ariadna | 165 |         if ($includesort && ($sort = $this->get_sql_sort())) {
 | 
        
           | 1 | efrain | 166 |             $sql .= " ORDER BY {$sort}";
 | 
        
           |  |  | 167 |         }
 | 
        
           |  |  | 168 |   | 
        
           |  |  | 169 |         return $sql;
 | 
        
           |  |  | 170 |     }
 | 
        
           |  |  | 171 |   | 
        
           |  |  | 172 |     /**
 | 
        
           |  |  | 173 |      * Override parent method of the same, to make use of a recordset and avoid issues with duplicate values in the first column
 | 
        
           |  |  | 174 |      *
 | 
        
           |  |  | 175 |      * @param int $pagesize
 | 
        
           |  |  | 176 |      * @param bool $useinitialsbar
 | 
        
           |  |  | 177 |      */
 | 
        
           |  |  | 178 |     public function query_db($pagesize, $useinitialsbar = true): void {
 | 
        
           |  |  | 179 |         global $DB;
 | 
        
           |  |  | 180 |   | 
        
           |  |  | 181 |         if (!$this->is_downloading()) {
 | 
        
           |  |  | 182 |   | 
        
           | 1441 | ariadna | 183 |             // Initially set the page size, so the following SQL read has correct values.
 | 
        
           |  |  | 184 |             $this->pagesize($pagesize, 0);
 | 
        
           |  |  | 185 |   | 
        
           |  |  | 186 |             $countedcolumn = database::generate_alias();
 | 
        
           |  |  | 187 |             $countedrecordset = $DB->get_counted_recordset_sql(
 | 
        
           |  |  | 188 |                 $this->get_table_sql(false),
 | 
        
           |  |  | 189 |                 $countedcolumn,
 | 
        
           |  |  | 190 |                 $this->get_sql_sort(),
 | 
        
           |  |  | 191 |                 $this->sql->params,
 | 
        
           |  |  | 192 |                 (int) $this->get_page_start(),
 | 
        
           |  |  | 193 |                 (int) $this->get_page_size(),
 | 
        
           |  |  | 194 |             );
 | 
        
           |  |  | 195 |   | 
        
           |  |  | 196 |             // Now set the total page size.
 | 
        
           |  |  | 197 |             $countedsize = (int) ($countedrecordset->current()->{$countedcolumn} ?? 0);
 | 
        
           |  |  | 198 |             $this->pagesize($pagesize, $countedsize);
 | 
        
           |  |  | 199 |   | 
        
           |  |  | 200 |             $this->rawdata = $countedrecordset;
 | 
        
           | 1 | efrain | 201 |         } else {
 | 
        
           |  |  | 202 |             $this->rawdata = $DB->get_recordset_sql($this->get_table_sql(), $this->sql->params);
 | 
        
           |  |  | 203 |         }
 | 
        
           |  |  | 204 |     }
 | 
        
           |  |  | 205 |   | 
        
           |  |  | 206 |     /**
 | 
        
           | 1441 | ariadna | 207 |      * Return total row count for report table. Note we'd typically use {@see query_db} and then read the {@see totalrows}
 | 
        
           |  |  | 208 |      * property to reduce DB calls, however we can use this method when we specifically don't also need to obtain all data
 | 
        
           |  |  | 209 |      *
 | 
        
           |  |  | 210 |      * @return int
 | 
        
           |  |  | 211 |      */
 | 
        
           |  |  | 212 |     public function get_total_row_count(): int {
 | 
        
           |  |  | 213 |         global $DB;
 | 
        
           |  |  | 214 |   | 
        
           |  |  | 215 |         $counttablesql = $this->get_table_sql(false);
 | 
        
           |  |  | 216 |         $counttablealias = database::generate_alias();
 | 
        
           |  |  | 217 |   | 
        
           |  |  | 218 |         return $DB->count_records_sql(
 | 
        
           |  |  | 219 |             "SELECT COUNT(1) FROM ({$counttablesql}) {$counttablealias}",
 | 
        
           |  |  | 220 |             $this->sql->params,
 | 
        
           |  |  | 221 |         );
 | 
        
           |  |  | 222 |     }
 | 
        
           |  |  | 223 |   | 
        
           |  |  | 224 |     /**
 | 
        
           | 1 | efrain | 225 |      * Override parent method of the same, to ensure that any columns with custom sort fields are accounted for
 | 
        
           |  |  | 226 |      *
 | 
        
           |  |  | 227 |      * Because the base table_sql has "special" handling of fullname columns {@see table_sql::contains_fullname_columns}, we need
 | 
        
           |  |  | 228 |      * to handle that here to ensure that any that are being sorted take priority over reportbuilders own aliases of the same
 | 
        
           |  |  | 229 |      * columns. This prevents them appearing multiple times in a query, which SQL Server really doesn't like
 | 
        
           |  |  | 230 |      *
 | 
        
           |  |  | 231 |      * @return string
 | 
        
           |  |  | 232 |      */
 | 
        
           |  |  | 233 |     public function get_sql_sort() {
 | 
        
           |  |  | 234 |         $columnsbyalias = $this->report->get_active_columns_by_alias();
 | 
        
           |  |  | 235 |         $columnsortby = [];
 | 
        
           |  |  | 236 |   | 
        
           |  |  | 237 |         // First pass over sorted columns, to extract all the fullname fields from table_sql.
 | 
        
           |  |  | 238 |         $sortedcolumns = $this->get_sort_columns();
 | 
        
           |  |  | 239 |         $sortedcolumnsfullname = array_filter($sortedcolumns, static function(string $alias): bool {
 | 
        
           |  |  | 240 |             return !preg_match('/^c[\d]+_/', $alias);
 | 
        
           |  |  | 241 |         }, ARRAY_FILTER_USE_KEY);
 | 
        
           |  |  | 242 |   | 
        
           |  |  | 243 |         // Iterate over all sorted report columns, replace with columns own fields if applicable.
 | 
        
           |  |  | 244 |         foreach ($sortedcolumns as $alias => $order) {
 | 
        
           |  |  | 245 |             $column = $columnsbyalias[$alias] ?? null;
 | 
        
           |  |  | 246 |   | 
        
           |  |  | 247 |             // If the column is not being aggregated and defines custom sort fields, then use them.
 | 
        
           | 1441 | ariadna | 248 |             if ($column && !$column->get_aggregation() && ($sortfields = $column->get_sort_fields())) {
 | 
        
           | 1 | efrain | 249 |                 foreach ($sortfields as $sortfield) {
 | 
        
           | 1441 | ariadna | 250 |                     if (!array_key_exists($sortfield, $columnsortby)) {
 | 
        
           |  |  | 251 |                         $columnsortby[$sortfield] = $order;
 | 
        
           |  |  | 252 |                     }
 | 
        
           | 1 | efrain | 253 |                 }
 | 
        
           | 1441 | ariadna | 254 |             } else if (array_key_exists($alias, $sortedcolumnsfullname)) {
 | 
        
           |  |  | 255 |                 // Ensure that magic fullname sorted columns refer to correct alias.
 | 
        
           |  |  | 256 |                 foreach ($columnsbyalias as $column) {
 | 
        
           |  |  | 257 |                     $sortfieldalias = array_filter(
 | 
        
           |  |  | 258 |                         $column->get_sort_fields(),
 | 
        
           |  |  | 259 |                         fn(string $key) => preg_match("/^c[\d]+_{$alias}$/", $key),
 | 
        
           |  |  | 260 |                     );
 | 
        
           |  |  | 261 |                     if (count($sortfieldalias) > 0) {
 | 
        
           |  |  | 262 |                         $columnsortby[reset($sortfieldalias)] = $order;
 | 
        
           |  |  | 263 |                         break;
 | 
        
           |  |  | 264 |                     }
 | 
        
           |  |  | 265 |                 }
 | 
        
           | 1 | efrain | 266 |             } else {
 | 
        
           |  |  | 267 |                 $columnsortby[$alias] = $order;
 | 
        
           |  |  | 268 |             }
 | 
        
           |  |  | 269 |         }
 | 
        
           |  |  | 270 |   | 
        
           |  |  | 271 |         return static::construct_order_by($columnsortby);
 | 
        
           |  |  | 272 |     }
 | 
        
           |  |  | 273 |   | 
        
           |  |  | 274 |     /**
 | 
        
           |  |  | 275 |      * Get the context for the table (that of the report persistent)
 | 
        
           |  |  | 276 |      *
 | 
        
           |  |  | 277 |      * @return context
 | 
        
           |  |  | 278 |      */
 | 
        
           |  |  | 279 |     public function get_context(): context {
 | 
        
           |  |  | 280 |         return $this->persistent->get_context();
 | 
        
           |  |  | 281 |     }
 | 
        
           |  |  | 282 |   | 
        
           |  |  | 283 |     /**
 | 
        
           |  |  | 284 |      * Set the base URL of the table to the current page URL
 | 
        
           |  |  | 285 |      */
 | 
        
           |  |  | 286 |     public function guess_base_url(): void {
 | 
        
           |  |  | 287 |         $this->baseurl = new moodle_url('/');
 | 
        
           |  |  | 288 |     }
 | 
        
           |  |  | 289 |   | 
        
           |  |  | 290 |     /**
 | 
        
           |  |  | 291 |      * Override print_nothing_to_display to modity the output styles.
 | 
        
           |  |  | 292 |      */
 | 
        
           |  |  | 293 |     public function print_nothing_to_display() {
 | 
        
           |  |  | 294 |         global $OUTPUT;
 | 
        
           |  |  | 295 |   | 
        
           |  |  | 296 |         echo $this->get_dynamic_table_html_start();
 | 
        
           |  |  | 297 |         echo $this->render_reset_button();
 | 
        
           |  |  | 298 |   | 
        
           |  |  | 299 |         if ($notice = $this->report->get_default_no_results_notice()) {
 | 
        
           |  |  | 300 |             echo $OUTPUT->render(new notification($notice->out(), notification::NOTIFY_INFO, false));
 | 
        
           |  |  | 301 |         }
 | 
        
           |  |  | 302 |   | 
        
           |  |  | 303 |         echo $this->get_dynamic_table_html_end();
 | 
        
           |  |  | 304 |     }
 | 
        
           |  |  | 305 |   | 
        
           |  |  | 306 |     /**
 | 
        
           |  |  | 307 |      * Override start of HTML to remove top pagination.
 | 
        
           |  |  | 308 |      */
 | 
        
           |  |  | 309 |     public function start_html() {
 | 
        
           |  |  | 310 |         // Render the dynamic table header.
 | 
        
           |  |  | 311 |         echo $this->get_dynamic_table_html_start();
 | 
        
           |  |  | 312 |   | 
        
           |  |  | 313 |         // Render button to allow user to reset table preferences.
 | 
        
           |  |  | 314 |         echo $this->render_reset_button();
 | 
        
           |  |  | 315 |   | 
        
           |  |  | 316 |         $this->wrap_html_start();
 | 
        
           |  |  | 317 |   | 
        
           | 1441 | ariadna | 318 |         $this->set_caption($this->report::get_name(), ['class' => 'visually-hidden']);
 | 
        
           | 1 | efrain | 319 |   | 
        
           | 1441 | ariadna | 320 |         echo html_writer::start_tag('div', ['class' => 'table-responsive']);
 | 
        
           | 1 | efrain | 321 |         echo html_writer::start_tag('table', $this->attributes) . $this->render_caption();
 | 
        
           |  |  | 322 |     }
 | 
        
           |  |  | 323 | }
 |