1<?php
2/**
3 * Matomo - free/libre analytics platform
4 *
5 * @link https://matomo.org
6 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
7 *
8 */
9namespace Piwik\Plugin;
10
11use Piwik\API\Request;
12use Piwik\API\Request as ApiRequest;
13use Piwik\Common;
14use Piwik\DataTable;
15use Piwik\Period;
16use Piwik\Piwik;
17use Piwik\Plugins\API\Filter\DataComparisonFilter;
18use Piwik\View\ViewInterface;
19use Piwik\ViewDataTable\Config as VizConfig;
20use Piwik\ViewDataTable\Manager as ViewDataTableManager;
21use Piwik\ViewDataTable\Request as ViewDataTableRequest;
22use Piwik\ViewDataTable\RequestConfig as VizRequest;
23
24/**
25 * The base class of all report visualizations.
26 *
27 * ViewDataTable instances load analytics data via Piwik's Reporting API and then output some
28 * type of visualization of that data.
29 *
30 * Visualizations can be in any format. HTML-based visualizations should extend
31 * {@link Visualization}. Visualizations that use other formats, such as visualizations
32 * that output an image, should extend ViewDataTable directly.
33 *
34 * ### Creating ViewDataTables
35 *
36 * ViewDataTable instances are not created via the new operator, instead the {@link Piwik\ViewDataTable\Factory}
37 * class is used.
38 *
39 * The specific subclass to create is determined, first, by the **viewDataTable** query parameter.
40 * If this parameter is not set, then the default visualization type for the report being
41 * displayed is used.
42 *
43 * ### Configuring ViewDataTables
44 *
45 * **Display properties**
46 *
47 * ViewDataTable output can be customized by setting one of many available display
48 * properties. Display properties are stored as fields in {@link Piwik\ViewDataTable\Config} objects.
49 * ViewDataTables store a {@link Piwik\ViewDataTable\Config} object in the {@link $config} field.
50 *
51 * Display properties can be set at any time before rendering.
52 *
53 * **Request properties**
54 *
55 * Request properties are similar to display properties in the way they are set. They are,
56 * however, not used to customize ViewDataTable instances, but in the request to Piwik's
57 * API when loading analytics data.
58 *
59 * Request properties are set by setting the fields of a {@link Piwik\ViewDataTable\RequestConfig} object stored in
60 * the {@link $requestConfig} field. They can be set at any time before rendering.
61 * Setting them after data is loaded will have no effect.
62 *
63 * **Customizing how reports are displayed**
64 *
65 * Each individual report should be rendered in its own controller method. There are two
66 * ways to render a report within its controller method. You can either:
67 *
68 * 1. manually create and configure a ViewDataTable instance
69 * 2. invoke {@link Piwik\Plugin\Controller::renderReport} and configure the ViewDataTable instance
70 *    in the {@hook ViewDataTable.configure} event.
71 *
72 * ViewDataTable instances are configured by setting and modifying display properties and request
73 * properties.
74 *
75 * ### Creating new visualizations
76 *
77 * New visualizations can be created by extending the ViewDataTable class or one of its
78 * descendants. To learn more [read our guide on creating new visualizations](/guides/visualizing-report-data#creating-new-visualizations).
79 *
80 * ### Examples
81 *
82 * **Manually configuring a ViewDataTable**
83 *
84 *     // a controller method that displays a single report
85 *     public function myReport()
86 *     {
87 *         $view = \Piwik\ViewDataTable\Factory::build('table', 'MyPlugin.myReport');
88 *         $view->config->show_limit_control = true;
89 *         $view->config->translations['myFancyMetric'] = "My Fancy Metric";
90 *         // ...
91 *         return $view->render();
92 *     }
93 *
94 * **Using {@link Piwik\Plugin\Controller::renderReport}**
95 *
96 * First, a controller method that displays a single report:
97 *
98 *     public function myReport()
99 *     {
100 *         return $this->renderReport(__FUNCTION__);`
101 *     }
102 *
103 * Then the event handler for the {@hook ViewDataTable.configure} event:
104 *
105 *     public function configureViewDataTable(ViewDataTable $view)
106 *     {
107 *         switch ($view->requestConfig->apiMethodToRequestDataTable) {
108 *             case 'MyPlugin.myReport':
109 *                 $view->config->show_limit_control = true;
110 *                 $view->config->translations['myFancyMetric'] = "My Fancy Metric";
111 *                 // ...
112 *                 break;
113 *         }
114 *     }
115 *
116 * **Using custom configuration objects in a new visualization**
117 *
118 *     class MyVisualizationConfig extends Piwik\ViewDataTable\Config
119 *     {
120 *         public $my_new_property = true;
121 *     }
122 *
123 *     class MyVisualizationRequestConfig extends Piwik\ViewDataTable\RequestConfig
124 *     {
125 *         public $my_new_property = false;
126 *     }
127 *
128 *     class MyVisualization extends Piwik\Plugin\ViewDataTable
129 *     {
130 *         public static function getDefaultConfig()
131 *         {
132 *             return new MyVisualizationConfig();
133 *         }
134 *
135 *         public static function getDefaultRequestConfig()
136 *         {
137 *             return new MyVisualizationRequestConfig();
138 *         }
139 *     }
140 *
141 *
142 * @api
143 */
144abstract class ViewDataTable implements ViewInterface
145{
146    const ID = '';
147
148    /**
149     * DataTable loaded from the API for this ViewDataTable.
150     *
151     * @var DataTable
152     */
153    protected $dataTable = null;
154
155    /**
156     * Contains display properties for this visualization.
157     *
158     * @var \Piwik\ViewDataTable\Config
159     */
160    public $config;
161
162    /**
163     * Contains request properties for this visualization.
164     *
165     * @var \Piwik\ViewDataTable\RequestConfig
166     */
167    public $requestConfig;
168
169    /**
170     * @var ViewDataTableRequest
171     */
172    protected $request;
173
174    private $isComparing = null;
175
176    /**
177     * Constructor. Initializes display and request properties to their default values.
178     * Posts the {@hook ViewDataTable.configure} event which plugins can use to configure the
179     * way reports are displayed.
180     */
181    public function __construct($controllerAction, $apiMethodToRequestDataTable, $overrideParams = array())
182    {
183        if (strpos($controllerAction, '.') === false) {
184            $controllerName = '';
185            $controllerAction = '';
186        } else {
187            list($controllerName, $controllerAction) = explode('.', $controllerAction);
188        }
189
190        $this->requestConfig = static::getDefaultRequestConfig();
191        $this->config        = static::getDefaultConfig();
192        $this->config->subtable_controller_action = $controllerAction;
193        $this->config->setController($controllerName, $controllerAction);
194
195        $this->request = new ViewDataTableRequest($this->requestConfig);
196
197        $this->requestConfig->idSubtable = Common::getRequestVar('idSubtable', false, 'int');
198        $this->config->self_url          = Request::getBaseReportUrl($controllerName, $controllerAction);
199
200        $this->requestConfig->apiMethodToRequestDataTable = $apiMethodToRequestDataTable;
201
202        $report = ReportsProvider::factory($this->requestConfig->getApiModuleToRequest(), $this->requestConfig->getApiMethodToRequest());
203
204        if (!empty($report)) {
205            /** @var Report $report */
206            $subtable = $report->getActionToLoadSubTables();
207            if (!empty($subtable)) {
208                $this->config->subtable_controller_action = $subtable;
209            }
210
211            $this->config->show_goals = $report->hasGoalMetrics();
212
213            $relatedReports = $report->getRelatedReports();
214            if (!empty($relatedReports)) {
215                foreach ($relatedReports as $relatedReport) {
216                    if (!$relatedReport) {
217                        continue;
218                    }
219
220                    $relatedReportName = $relatedReport->getName();
221
222                    $this->config->addRelatedReport($relatedReport->getModule() . '.' . $relatedReport->getAction(),
223                                                    $relatedReportName);
224                }
225            }
226
227            $metrics = $report->getMetrics();
228            if (!empty($metrics)) {
229                $this->config->addTranslations($metrics);
230            }
231
232            $processedMetrics = $report->getProcessedMetrics();
233            if (!empty($processedMetrics)) {
234                $this->config->addTranslations($processedMetrics);
235            }
236
237            $this->config->title = $report->getName();
238
239            $report->configureView($this);
240        }
241
242        /**
243         * Triggered during {@link ViewDataTable} construction. Subscribers should customize
244         * the view based on the report that is being displayed.
245         *
246         * This event is triggered before view configuration properties are overwritten by saved settings or request
247         * parameters. Use this to define default values.
248         *
249         * Plugins that define their own reports must subscribe to this event in order to
250         * specify how the Piwik UI should display the report.
251         *
252         * **Example**
253         *
254         *     // event handler
255         *     public function configureViewDataTable(ViewDataTable $view)
256         *     {
257         *         switch ($view->requestConfig->apiMethodToRequestDataTable) {
258         *             case 'VisitTime.getVisitInformationPerServerTime':
259         *                 $view->config->enable_sort = true;
260         *                 $view->requestConfig->filter_limit = 10;
261         *                 break;
262         *         }
263         *     }
264         *
265         * @param ViewDataTable $view The instance to configure.
266         */
267        Piwik::postEvent('ViewDataTable.configure', array($this));
268
269        $this->assignRelatedReportsTitle();
270
271        $this->config->show_footer_icons = (false == $this->requestConfig->idSubtable);
272
273        // the exclude low population threshold value is sometimes obtained by requesting data.
274        // to avoid issuing unnecessary requests when display properties are determined by metadata,
275        // we allow it to be a closure.
276        if (isset($this->requestConfig->filter_excludelowpop_value)
277            && $this->requestConfig->filter_excludelowpop_value instanceof \Closure
278        ) {
279            $function = $this->requestConfig->filter_excludelowpop_value;
280            $this->requestConfig->filter_excludelowpop_value = $function();
281        }
282
283        $this->overrideViewPropertiesWithParams($overrideParams);
284        $this->overrideViewPropertiesWithQueryParams();
285
286        /**
287         * Triggered after {@link ViewDataTable} construction. Subscribers should customize
288         * the view based on the report that is being displayed.
289         *
290         * This event is triggered after all view configuration values have been overwritten by saved settings or
291         * request parameters. Use this if you need to work with the final configuration values.
292         *
293         * Plugins that define their own reports can subscribe to this event in order to
294         * specify how the Piwik UI should display the report.
295         *
296         * **Example**
297         *
298         *     // event handler
299         *     public function configureViewDataTableEnd(ViewDataTable $view)
300         *     {
301         *         if ($view->requestConfig->apiMethodToRequestDataTable == 'VisitTime.getVisitInformationPerServerTime'
302         *             && $view->requestConfig->flat == 1) {
303         *                 $view->config->show_header_message = 'You are viewing this report flattened';
304         *         }
305         *     }
306         *
307         * @param ViewDataTable $view The instance to configure.
308         */
309        Piwik::postEvent('ViewDataTable.configure.end', array($this));
310    }
311
312    private function assignRelatedReportsTitle()
313    {
314        if (!empty($this->config->related_reports_title)) {
315            // title already assigned by a plugin
316            return;
317        }
318        if (count($this->config->related_reports) == 1) {
319            $this->config->related_reports_title = Piwik::translate('General_RelatedReport') . ':';
320        } else {
321            $this->config->related_reports_title = Piwik::translate('General_RelatedReports') . ':';
322        }
323    }
324
325    /**
326     * Returns the default config instance.
327     *
328     * Visualizations that define their own display properties should override this method and
329     * return an instance of their new {@link Piwik\ViewDataTable\Config} descendant.
330     *
331     * See the last example {@link ViewDataTable here} for more information.
332     *
333     * @return \Piwik\ViewDataTable\Config
334     */
335    public static function getDefaultConfig()
336    {
337        return new VizConfig();
338    }
339
340    /**
341     * Returns the default request config instance.
342     *
343     * Visualizations that define their own request properties should override this method and
344     * return an instance of their new {@link Piwik\ViewDataTable\RequestConfig} descendant.
345     *
346     * See the last example {@link ViewDataTable here} for more information.
347     *
348     * @return \Piwik\ViewDataTable\RequestConfig
349     */
350    public static function getDefaultRequestConfig()
351    {
352        return new VizRequest();
353    }
354
355    protected function loadDataTableFromAPI()
356    {
357        if (!is_null($this->dataTable)) {
358            // data table is already there
359            // this happens when setDataTable has been used
360            return $this->dataTable;
361        }
362
363        $extraParams = [];
364        if ($this->isComparing()) {
365            $extraParams['compare'] = '1';
366        }
367
368        $this->dataTable = $this->request->loadDataTableFromAPI($extraParams);
369
370        return $this->dataTable;
371    }
372
373    /**
374     * Returns the viewDataTable ID for this DataTable visualization.
375     *
376     * Derived classes should not override this method. They should instead declare a const ID field
377     * with the viewDataTable ID.
378     *
379     * @throws \Exception
380     * @return string
381     */
382    public static function getViewDataTableId()
383    {
384        $id = static::ID;
385
386        if (empty($id)) {
387            $message = sprintf('ViewDataTable %s does not define an ID. Set the ID constant to fix this issue', get_called_class());
388            throw new \Exception($message);
389        }
390
391        return $id;
392    }
393
394    /**
395     * Returns `true` if this instance's or any of its ancestors' viewDataTable IDs equals the supplied ID,
396     * `false` if otherwise.
397     *
398     * Can be used to test whether a ViewDataTable object is an instance of a certain visualization or not,
399     * without having to know where that visualization is.
400     *
401     * @param  string $viewDataTableId The viewDataTable ID to check for, eg, `'table'`.
402     * @return bool
403     */
404    public function isViewDataTableId($viewDataTableId)
405    {
406        $myIds = ViewDataTableManager::getIdsWithInheritance(get_called_class());
407
408        return in_array($viewDataTableId, $myIds);
409    }
410
411    /**
412     * Returns the DataTable loaded from the API.
413     *
414     * @return DataTable
415     * @throws \Exception if not yet loaded.
416     */
417    public function getDataTable()
418    {
419        if (is_null($this->dataTable)) {
420            throw new \Exception("The DataTable object has not yet been created");
421        }
422
423        return $this->dataTable;
424    }
425
426    /**
427     * To prevent calling an API multiple times, the DataTable can be set directly.
428     * It won't be loaded from the API in this case.
429     *
430     * @param DataTable $dataTable The DataTable to use.
431     * @return void
432     */
433    public function setDataTable($dataTable)
434    {
435        $this->dataTable = $dataTable;
436    }
437
438    /**
439     * Checks that the API returned a normal DataTable (as opposed to DataTable\Map)
440     * @throws \Exception
441     * @return void
442     */
443    protected function checkStandardDataTable()
444    {
445        Piwik::checkObjectTypeIs($this->dataTable, array('\Piwik\DataTable'));
446    }
447
448    /**
449     * Requests all needed data and renders the view.
450     *
451     * @return string The result of rendering.
452     */
453    public function render()
454    {
455        return '';
456    }
457
458    protected function getDefaultDataTableCssClass()
459    {
460        return 'dataTableViz' . Piwik::getUnnamespacedClassName(get_class($this));
461    }
462
463    /**
464     * Returns the list of view properties that can be overridden by query parameters.
465     *
466     * @return array
467     */
468    protected function getOverridableProperties()
469    {
470        return array_merge($this->config->overridableProperties, $this->requestConfig->overridableProperties);
471    }
472
473    private function overrideViewPropertiesWithQueryParams()
474    {
475        $properties = $this->getOverridableProperties();
476
477        foreach ($properties as $name) {
478            if (property_exists($this->requestConfig, $name)) {
479                $this->requestConfig->$name = $this->getPropertyFromQueryParam($name, $this->requestConfig->$name);
480            } elseif (property_exists($this->config, $name)) {
481                $this->config->$name = $this->getPropertyFromQueryParam($name, $this->config->$name);
482            }
483        }
484
485        // handle special 'columns' query parameter
486        $columns = Common::getRequestVar('columns', false);
487
488        if (false !== $columns) {
489            $this->config->columns_to_display = Piwik::getArrayFromApiParameter($columns);
490            array_unshift($this->config->columns_to_display, 'label');
491        }
492    }
493
494    protected function getPropertyFromQueryParam($name, $defaultValue)
495    {
496        $type = is_numeric($defaultValue) ? 'int' : null;
497        $value = Common::getRequestVar($name, $defaultValue, $type);
498        // convert comma separated values to arrays if needed
499        if (is_array($defaultValue)) {
500            $value = Piwik::getArrayFromApiParameter($value);
501        }
502        return $value;
503    }
504
505    /**
506     * Returns `true` if this instance will request a single DataTable, `false` if requesting
507     * more than one.
508     *
509     * @return bool
510     */
511    public function isRequestingSingleDataTable()
512    {
513        $requestArray = $this->request->getRequestArray() + $_GET + $_POST;
514        $date   = Common::getRequestVar('date', null, 'string', $requestArray);
515        $period = Common::getRequestVar('period', null, 'string', $requestArray);
516        $idSite = Common::getRequestVar('idSite', null, 'string', $requestArray);
517
518        if (Period::isMultiplePeriod($date, $period)
519            || strpos($idSite, ',') !== false
520            || $idSite == 'all'
521        ) {
522            return false;
523        }
524
525        return true;
526    }
527
528    /**
529     * Returns `true` if this visualization can display some type of data or not.
530     *
531     * New visualization classes should override this method if they can only visualize certain
532     * types of data. The evolution graph visualization, for example, can only visualize
533     * sets of DataTables. If the API method used results in a single DataTable, the evolution
534     * graph footer icon should not be displayed.
535     *
536     * @param  ViewDataTable $view Contains the API request being checked.
537     * @return bool
538     */
539    public static function canDisplayViewDataTable(ViewDataTable $view)
540    {
541        return $view->config->show_all_views_icons;
542    }
543
544    private function overrideViewPropertiesWithParams($overrideParams)
545    {
546        if (empty($overrideParams)) {
547            return;
548        }
549
550        foreach ($overrideParams as $key => $value) {
551            if (property_exists($this->requestConfig, $key)) {
552                $this->requestConfig->$key = $value;
553            } elseif (property_exists($this->config, $key)) {
554                $this->config->$key = $value;
555            } elseif ($key != 'enable_filter_excludelowpop') {
556                $this->config->custom_parameters[$key] = $value;
557            }
558        }
559    }
560
561    /**
562     * Display a meaningful error message when any invalid parameter is being set.
563     *
564     * @param $overrideParams
565     * @throws
566     */
567    public function throwWhenSettingNonOverridableParameter($overrideParams)
568    {
569        $nonOverridableParams = $this->getNonOverridableParams($overrideParams);
570        if(count($nonOverridableParams) > 0) {
571            throw new \Exception(sprintf(
572                "Setting parameters %s is not allowed. Please report this bug to the Matomo team.",
573                implode(" and ", $nonOverridableParams)
574            ));
575        }
576    }
577
578    /**
579     * @param $overrideParams
580     * @return array
581     */
582    public function getNonOverridableParams($overrideParams)
583    {
584        $paramsCannotBeOverridden = array();
585        foreach ($overrideParams as $paramName => $paramValue) {
586            if (property_exists($this->requestConfig, $paramName)) {
587                $allowedParams = $this->requestConfig->overridableProperties;
588            } elseif (property_exists($this->config, $paramName)) {
589                $allowedParams = $this->config->overridableProperties;
590            } else {
591                // setting Config.custom_parameters is always allowed
592                continue;
593            }
594
595            if (!in_array($paramName, $allowedParams)) {
596                $paramsCannotBeOverridden[] = $paramName;
597            }
598        }
599        return $paramsCannotBeOverridden;
600    }
601
602    /**
603     * Returns true if both this current visualization supports comparison, and if comparison query parameters
604     * are present in the URL.
605     *
606     * @return bool
607     */
608    public function isComparing()
609    {
610        if (!$this->supportsComparison()
611            || $this->config->disable_comparison
612        ) {
613            return false;
614        }
615
616        $request = $this->request->getRequestArray();
617        $request = ApiRequest::getRequestArrayFromString($request);
618
619        $result = DataComparisonFilter::isCompareParamsPresent($request);
620        return $result;
621    }
622
623    /**
624     * Implementations should override this method if they support a special comparison view. By
625     * default, it is assumed visualizations do not support comparison.
626     *
627     * @return bool
628     */
629    public function supportsComparison()
630    {
631        return false;
632    }
633
634    public function getRequestArray()
635    {
636        $requestArray = $this->request->getRequestArray();
637        return ApiRequest::getRequestArrayFromString($requestArray);
638    }
639}
640