PageRenderTime 48ms CodeModel.GetById 16ms RepoModel.GetById 0ms app.codeStats 0ms

/core/Plugin/Visualization.php

https://github.com/CodeYellowBV/piwik
PHP | 624 lines | 310 code | 87 blank | 227 comment | 40 complexity | 2e6c9220c7a1b71e418559d41ffa36cd MD5 | raw file
Possible License(s): LGPL-3.0, JSON, MIT, GPL-3.0, LGPL-2.1, GPL-2.0, AGPL-1.0, BSD-2-Clause, BSD-3-Clause
  1. <?php
  2. /**
  3. * Piwik - free/libre analytics platform
  4. *
  5. * @link http://piwik.org
  6. * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
  7. *
  8. */
  9. namespace Piwik\Plugin;
  10. use Piwik\Common;
  11. use Piwik\DataTable;
  12. use Piwik\Date;
  13. use Piwik\Log;
  14. use Piwik\MetricsFormatter;
  15. use Piwik\NoAccessException;
  16. use Piwik\Option;
  17. use Piwik\Period;
  18. use Piwik\Piwik;
  19. use Piwik\Plugins\PrivacyManager\PrivacyManager;
  20. use Piwik\View;
  21. use Piwik\ViewDataTable\Manager as ViewDataTableManager;
  22. /**
  23. * The base class for report visualizations that output HTML and use JavaScript.
  24. *
  25. * Report visualizations that extend from this class will be displayed like all others in
  26. * the Piwik UI. The following extra UI controls will be displayed around the visualization
  27. * itself:
  28. *
  29. * - report documentation,
  30. * - a footer message (if {@link Piwik\ViewDataTable\Config::$show_footer_message} is set),
  31. * - a list of links to related reports (if {@link Piwik\ViewDataTable\Config::$related_reports} is set),
  32. * - a button that allows users to switch visualizations,
  33. * - a control that allows users to export report data in different formats,
  34. * - a limit control that allows users to change the amount of rows displayed (if
  35. * {@link Piwik\ViewDataTable\Config::$show_limit_control} is true),
  36. * - and more depending on the visualization.
  37. *
  38. * ### Rendering Process
  39. *
  40. * The following process is used to render reports:
  41. *
  42. * - The report is loaded through Piwik's Reporting API.
  43. * - The display and request properties that require report data in order to determine a default
  44. * value are defaulted. These properties are:
  45. *
  46. * - {@link Piwik\ViewDataTable\Config::$columns_to_display}
  47. * - {@link Piwik\ViewDataTable\RequestConfig::$filter_sort_column}
  48. * - {@link Piwik\ViewDataTable\RequestConfig::$filter_sort_order}
  49. *
  50. * - Priority filters are applied to the report (see {@link Piwik\ViewDataTable\Config::$filters}).
  51. * - The filters that are applied to every report in the Reporting API (called **generic filters**)
  52. * are applied. (see {@link Piwik\API\Request})
  53. * - The report's queued filters are applied.
  54. * - A {@link Piwik\View} instance is created and rendered.
  55. *
  56. * ### Rendering Hooks
  57. *
  58. * The Visualization class defines several overridable methods that are called at specific
  59. * points during the rendering process. Derived classes can override these methods change
  60. * the data that is displayed or set custom properties.
  61. *
  62. * The overridable methods (called **rendering hooks**) are as follows:
  63. *
  64. * - **beforeLoadDataTable**: Called at the start of the rendering process before any data
  65. * is loaded.
  66. * - **beforeGenericFiltersAreAppliedToLoadedDataTable**: Called after data is loaded and after priority
  67. * filters are called, but before other filters. This
  68. * method should be used if you need the report's
  69. * entire dataset.
  70. * - **afterGenericFiltersAreAppliedToLoadedDataTable**: Called after generic filters are applied, but before
  71. * queued filters are applied.
  72. * - **afterAllFiltersAreApplied**: Called after data is loaded and all filters are applied.
  73. * - **beforeRender**: Called immediately before a {@link Piwik\View} is created and rendered.
  74. * - **isThereDataToDisplay**: Called after a {@link Piwik\View} is created to determine if the report has
  75. * data or not. If not, a message is displayed to the user.
  76. *
  77. * ### The DataTable JavaScript class
  78. *
  79. * In the UI, visualization behavior is provided by logic in the **DataTable** JavaScript class.
  80. * When creating new visualizations, the **DataTable** JavaScript class (or one of its existing
  81. * descendants) should be extended.
  82. *
  83. * To learn more read the [Visualizing Report Data](/guides/visualizing-report-data#creating-new-visualizations)
  84. * guide.
  85. *
  86. * ### Examples
  87. *
  88. * **Changing the data that is loaded**
  89. *
  90. * class MyVisualization extends Visualization
  91. * {
  92. * // load the previous period's data as well as the requested data. this will change
  93. * // $this->dataTable from a DataTable instance to a DataTable\Map instance.
  94. * public function beforeLoadDataTable()
  95. * {
  96. * $date = Common::getRequestVar('date');
  97. * list($previousDate, $ignore) = Range::getLastDate($date, $period);
  98. *
  99. * $this->requestConfig->request_parameters_to_modify['date'] = $previousDate . ',' . $date;
  100. * }
  101. *
  102. * // since we load the previous period's data too, we need to override the logic to
  103. * // check if there is data or not.
  104. * public function isThereDataToDisplay()
  105. * {
  106. * $tables = $this->dataTable->getDataTables()
  107. * $requestedDataTable = end($tables);
  108. *
  109. * return $requestedDataTable->getRowsCount() != 0;
  110. * }
  111. * }
  112. *
  113. * **Force properties to be set to certain values**
  114. *
  115. * class MyVisualization extends Visualization
  116. * {
  117. * // ensure that some properties are set to certain values before rendering.
  118. * // this will overwrite any changes made by plugins that use this visualization.
  119. * public function beforeRender()
  120. * {
  121. * $this->config->max_graph_elements = false;
  122. * $this->config->datatable_js_type = 'MyVisualization';
  123. * $this->config->show_flatten_table = false;
  124. * $this->config->show_pagination_control = false;
  125. * $this->config->show_offset_information = false;
  126. * }
  127. * }
  128. */
  129. class Visualization extends ViewDataTable
  130. {
  131. /**
  132. * The Twig template file to use when rendering, eg, `"@MyPlugin/_myVisualization.twig"`.
  133. *
  134. * Must be defined by classes that extend Visualization.
  135. *
  136. * @api
  137. */
  138. const TEMPLATE_FILE = '';
  139. private $templateVars = array();
  140. private $reportLastUpdatedMessage = null;
  141. private $metadata = null;
  142. final public function __construct($controllerAction, $apiMethodToRequestDataTable, $params = array())
  143. {
  144. $templateFile = static::TEMPLATE_FILE;
  145. if (empty($templateFile)) {
  146. throw new \Exception('You have not defined a constant named TEMPLATE_FILE in your visualization class.');
  147. }
  148. parent::__construct($controllerAction, $apiMethodToRequestDataTable, $params);
  149. }
  150. protected function buildView()
  151. {
  152. $this->overrideSomeConfigPropertiesIfNeeded();
  153. try {
  154. $this->beforeLoadDataTable();
  155. $this->loadDataTableFromAPI(array('disable_generic_filters' => 1));
  156. $this->postDataTableLoadedFromAPI();
  157. $requestPropertiesAfterLoadDataTable = $this->requestConfig->getProperties();
  158. $this->applyFilters();
  159. $this->afterAllFiltersAreApplied();
  160. $this->beforeRender();
  161. $this->logMessageIfRequestPropertiesHaveChanged($requestPropertiesAfterLoadDataTable);
  162. } catch (NoAccessException $e) {
  163. throw $e;
  164. } catch (\Exception $e) {
  165. Log::warning("Failed to get data from API: " . $e->getMessage() . "\n" . $e->getTraceAsString());
  166. $message = $e->getMessage();
  167. if (\Piwik_ShouldPrintBackTraceWithMessage()) {
  168. $message .= "\n" . $e->getTraceAsString();
  169. }
  170. $loadingError = array('message' => $message);
  171. }
  172. $view = new View("@CoreHome/_dataTable");
  173. if (!empty($loadingError)) {
  174. $view->error = $loadingError;
  175. }
  176. $view->assign($this->templateVars);
  177. $view->visualization = $this;
  178. $view->visualizationTemplate = static::TEMPLATE_FILE;
  179. $view->visualizationCssClass = $this->getDefaultDataTableCssClass();
  180. if (null === $this->dataTable) {
  181. $view->dataTable = null;
  182. } else {
  183. $view->dataTableHasNoData = !$this->isThereDataToDisplay();
  184. $view->dataTable = $this->dataTable;
  185. // if it's likely that the report data for this data table has been purged,
  186. // set whether we should display a message to that effect.
  187. $view->showReportDataWasPurgedMessage = $this->hasReportBeenPurged();
  188. $view->deleteReportsOlderThan = Option::get('delete_reports_older_than');
  189. }
  190. $view->idSubtable = $this->requestConfig->idSubtable;
  191. $view->clientSideParameters = $this->getClientSideParametersToSet();
  192. $view->clientSideProperties = $this->getClientSidePropertiesToSet();
  193. $view->properties = array_merge($this->requestConfig->getProperties(), $this->config->getProperties());
  194. $view->reportLastUpdatedMessage = $this->reportLastUpdatedMessage;
  195. $view->footerIcons = $this->config->footer_icons;
  196. $view->isWidget = Common::getRequestVar('widget', 0, 'int');
  197. return $view;
  198. }
  199. private function overrideSomeConfigPropertiesIfNeeded()
  200. {
  201. if (empty($this->config->footer_icons)) {
  202. $this->config->footer_icons = ViewDataTableManager::configureFooterIcons($this);
  203. }
  204. if (!\Piwik\Plugin\Manager::getInstance()->isPluginActivated('Goals')) {
  205. $this->config->show_goals = false;
  206. }
  207. }
  208. /**
  209. * Assigns a template variable making it available in the Twig template specified by
  210. * {@link TEMPLATE_FILE}.
  211. *
  212. * @param array|string $vars One or more variable names to set.
  213. * @param mixed $value The value to set each variable to.
  214. * @api
  215. */
  216. public function assignTemplateVar($vars, $value = null)
  217. {
  218. if (is_string($vars)) {
  219. $this->templateVars[$vars] = $value;
  220. } elseif (is_array($vars)) {
  221. foreach ($vars as $key => $value) {
  222. $this->templateVars[$key] = $value;
  223. }
  224. }
  225. }
  226. /**
  227. * Returns `true` if there is data to display, `false` if otherwise.
  228. *
  229. * Derived classes should override this method if they change the amount of data that is loaded.
  230. *
  231. * @api
  232. */
  233. protected function isThereDataToDisplay()
  234. {
  235. return !empty($this->dataTable) && 0 < $this->dataTable->getRowsCount();
  236. }
  237. /**
  238. * Hook called after the dataTable has been loaded from the API
  239. * Can be used to add, delete or modify the data freshly loaded
  240. *
  241. * @return bool
  242. */
  243. private function postDataTableLoadedFromAPI()
  244. {
  245. $columns = $this->dataTable->getColumns();
  246. $hasNbVisits = in_array('nb_visits', $columns);
  247. $hasNbUniqVisitors = in_array('nb_uniq_visitors', $columns);
  248. // default columns_to_display to label, nb_uniq_visitors/nb_visits if those columns exist in the
  249. // dataset. otherwise, default to all columns in dataset.
  250. if (empty($this->config->columns_to_display)) {
  251. $this->config->setDefaultColumnsToDisplay($columns, $hasNbVisits, $hasNbUniqVisitors);
  252. }
  253. if (!empty($this->dataTable)) {
  254. $this->removeEmptyColumnsFromDisplay();
  255. }
  256. if (empty($this->requestConfig->filter_sort_column)) {
  257. $this->requestConfig->setDefaultSort($this->config->columns_to_display, $hasNbUniqVisitors);
  258. }
  259. // deal w/ table metadata
  260. if ($this->dataTable instanceof DataTable) {
  261. $this->metadata = $this->dataTable->getAllTableMetadata();
  262. if (isset($this->metadata[DataTable::ARCHIVED_DATE_METADATA_NAME])) {
  263. $this->config->report_last_updated_message = $this->makePrettyArchivedOnText();
  264. }
  265. }
  266. }
  267. private function applyFilters()
  268. {
  269. list($priorityFilters, $otherFilters) = $this->config->getFiltersToRun();
  270. // First, filters that delete rows
  271. foreach ($priorityFilters as $filter) {
  272. $this->dataTable->filter($filter[0], $filter[1]);
  273. }
  274. $this->beforeGenericFiltersAreAppliedToLoadedDataTable();
  275. if (!in_array($this->requestConfig->filter_sort_column, $this->config->columns_to_display)) {
  276. $hasNbUniqVisitors = in_array('nb_uniq_visitors', $this->config->columns_to_display);
  277. $this->requestConfig->setDefaultSort($this->config->columns_to_display, $hasNbUniqVisitors);
  278. }
  279. if (!$this->requestConfig->areGenericFiltersDisabled()) {
  280. $this->applyGenericFilters();
  281. }
  282. $this->afterGenericFiltersAreAppliedToLoadedDataTable();
  283. // queue other filters so they can be applied later if queued filters are disabled
  284. foreach ($otherFilters as $filter) {
  285. $this->dataTable->queueFilter($filter[0], $filter[1]);
  286. }
  287. // Finally, apply datatable filters that were queued (should be 'presentation' filters that
  288. // do not affect the number of rows)
  289. if (!$this->requestConfig->areQueuedFiltersDisabled()) {
  290. $this->dataTable->applyQueuedFilters();
  291. }
  292. }
  293. private function removeEmptyColumnsFromDisplay()
  294. {
  295. if ($this->dataTable instanceof DataTable\Map) {
  296. $emptyColumns = $this->dataTable->getMetadataIntersectArray(DataTable::EMPTY_COLUMNS_METADATA_NAME);
  297. } else {
  298. $emptyColumns = $this->dataTable->getMetadata(DataTable::EMPTY_COLUMNS_METADATA_NAME);
  299. }
  300. if (is_array($emptyColumns)) {
  301. foreach ($emptyColumns as $emptyColumn) {
  302. $key = array_search($emptyColumn, $this->config->columns_to_display);
  303. if ($key !== false) {
  304. unset($this->config->columns_to_display[$key]);
  305. }
  306. }
  307. $this->config->columns_to_display = array_values($this->config->columns_to_display);
  308. }
  309. }
  310. /**
  311. * Returns prettified and translated text that describes when a report was last updated.
  312. *
  313. * @return string
  314. */
  315. private function makePrettyArchivedOnText()
  316. {
  317. $dateText = $this->metadata[DataTable::ARCHIVED_DATE_METADATA_NAME];
  318. $date = Date::factory($dateText);
  319. $today = mktime(0, 0, 0);
  320. if ($date->getTimestamp() > $today) {
  321. $elapsedSeconds = time() - $date->getTimestamp();
  322. $timeAgo = MetricsFormatter::getPrettyTimeFromSeconds($elapsedSeconds);
  323. return Piwik::translate('CoreHome_ReportGeneratedXAgo', $timeAgo);
  324. }
  325. $prettyDate = $date->getLocalized("%longYear%, %longMonth% %day%") . $date->toString('S');
  326. return Piwik::translate('CoreHome_ReportGeneratedOn', $prettyDate);
  327. }
  328. /**
  329. * Returns true if it is likely that the data for this report has been purged and if the
  330. * user should be told about that.
  331. *
  332. * In order for this function to return true, the following must also be true:
  333. * - The data table for this report must either be empty or not have been fetched.
  334. * - The period of this report is not a multiple period.
  335. * - The date of this report must be older than the delete_reports_older_than config option.
  336. * @return bool
  337. */
  338. private function hasReportBeenPurged()
  339. {
  340. if (!\Piwik\Plugin\Manager::getInstance()->isPluginActivated('PrivacyManager')) {
  341. return false;
  342. }
  343. return PrivacyManager::hasReportBeenPurged($this->dataTable);
  344. }
  345. /**
  346. * Returns array of properties that should be visible to client side JavaScript. The data
  347. * will be available in the data-props HTML attribute of the .dataTable div.
  348. *
  349. * @return array Maps property names w/ property values.
  350. */
  351. private function getClientSidePropertiesToSet()
  352. {
  353. $result = array();
  354. foreach ($this->config->clientSideProperties as $name) {
  355. if (property_exists($this->requestConfig, $name)) {
  356. $result[$name] = $this->getIntIfValueIsBool($this->requestConfig->$name);
  357. } else if (property_exists($this->config, $name)) {
  358. $result[$name] = $this->getIntIfValueIsBool($this->config->$name);
  359. }
  360. }
  361. return $result;
  362. }
  363. private function getIntIfValueIsBool($value)
  364. {
  365. return is_bool($value) ? (int)$value : $value;
  366. }
  367. /**
  368. * This functions reads the customization values for the DataTable and returns an array (name,value) to be printed in Javascript.
  369. * This array defines things such as:
  370. * - name of the module & action to call to request data for this table
  371. * - optional filters information, eg. filter_limit and filter_offset
  372. * - etc.
  373. *
  374. * The values are loaded:
  375. * - from the generic filters that are applied by default @see Piwik\API\DataTableGenericFilter::getGenericFiltersInformation()
  376. * - from the values already available in the GET array
  377. * - from the values set using methods from this class (eg. setSearchPattern(), setLimit(), etc.)
  378. *
  379. * @return array eg. array('show_offset_information' => 0, 'show_...
  380. */
  381. protected function getClientSideParametersToSet()
  382. {
  383. // build javascript variables to set
  384. $javascriptVariablesToSet = array();
  385. foreach ($this->config->custom_parameters as $name => $value) {
  386. $javascriptVariablesToSet[$name] = $value;
  387. }
  388. foreach ($_GET as $name => $value) {
  389. try {
  390. $requestValue = Common::getRequestVar($name);
  391. } catch (\Exception $e) {
  392. $requestValue = '';
  393. }
  394. $javascriptVariablesToSet[$name] = $requestValue;
  395. }
  396. foreach ($this->requestConfig->clientSideParameters as $name) {
  397. if (isset($javascriptVariablesToSet[$name])) {
  398. continue;
  399. }
  400. $valueToConvert = false;
  401. if (property_exists($this->requestConfig, $name)) {
  402. $valueToConvert = $this->requestConfig->$name;
  403. } else if (property_exists($this->config, $name)) {
  404. $valueToConvert = $this->config->$name;
  405. }
  406. if (false !== $valueToConvert) {
  407. $javascriptVariablesToSet[$name] = $this->getIntIfValueIsBool($valueToConvert);
  408. }
  409. }
  410. $javascriptVariablesToSet['module'] = $this->config->controllerName;
  411. $javascriptVariablesToSet['action'] = $this->config->controllerAction;
  412. if (!isset($javascriptVariablesToSet['viewDataTable'])) {
  413. $javascriptVariablesToSet['viewDataTable'] = static::getViewDataTableId();
  414. }
  415. if ($this->dataTable &&
  416. // Set doesn't have the method
  417. !($this->dataTable instanceof DataTable\Map)
  418. && empty($javascriptVariablesToSet['totalRows'])
  419. ) {
  420. $javascriptVariablesToSet['totalRows'] =
  421. $this->dataTable->getMetadata(DataTable::TOTAL_ROWS_BEFORE_LIMIT_METADATA_NAME) ?: $this->dataTable->getRowsCount();
  422. }
  423. $deleteFromJavascriptVariables = array(
  424. 'filter_excludelowpop',
  425. 'filter_excludelowpop_value',
  426. );
  427. foreach ($deleteFromJavascriptVariables as $name) {
  428. if (isset($javascriptVariablesToSet[$name])) {
  429. unset($javascriptVariablesToSet[$name]);
  430. }
  431. }
  432. $rawSegment = \Piwik\API\Request::getRawSegmentFromRequest();
  433. if (!empty($rawSegment)) {
  434. $javascriptVariablesToSet['segment'] = $rawSegment;
  435. }
  436. return $javascriptVariablesToSet;
  437. }
  438. /**
  439. * Hook that is called before loading report data from the API.
  440. *
  441. * Use this method to change the request parameters that is sent to the API when requesting
  442. * data.
  443. *
  444. * @api
  445. */
  446. public function beforeLoadDataTable()
  447. {
  448. }
  449. /**
  450. * Hook that is executed before generic filters are applied.
  451. *
  452. * Use this method if you need access to the entire dataset (since generic filters will
  453. * limit and truncate reports).
  454. *
  455. * @api
  456. */
  457. public function beforeGenericFiltersAreAppliedToLoadedDataTable()
  458. {
  459. }
  460. /**
  461. * Hook that is executed after generic filters are applied.
  462. *
  463. * @api
  464. */
  465. public function afterGenericFiltersAreAppliedToLoadedDataTable()
  466. {
  467. }
  468. /**
  469. * Hook that is executed after the report data is loaded and after all filters have been applied.
  470. * Use this method to format the report data before the view is rendered.
  471. *
  472. * @api
  473. */
  474. public function afterAllFiltersAreApplied()
  475. {
  476. }
  477. /**
  478. * Hook that is executed directly before rendering. Use this hook to force display properties to
  479. * be a certain value, despite changes from plugins and query parameters.
  480. *
  481. * @api
  482. */
  483. public function beforeRender()
  484. {
  485. // eg $this->config->showFooterColumns = true;
  486. }
  487. /**
  488. * Second, generic filters (Sort, Limit, Replace Column Names, etc.)
  489. */
  490. private function applyGenericFilters()
  491. {
  492. $requestArray = $this->request->getRequestArray();
  493. $request = \Piwik\API\Request::getRequestArrayFromString($requestArray);
  494. if (false === $this->config->enable_sort) {
  495. $request['filter_sort_column'] = '';
  496. $request['filter_sort_order'] = '';
  497. }
  498. $genericFilter = new \Piwik\API\DataTableGenericFilter($request);
  499. $genericFilter->filter($this->dataTable);
  500. }
  501. private function logMessageIfRequestPropertiesHaveChanged(array $requestPropertiesBefore)
  502. {
  503. $requestProperties = $this->requestConfig->getProperties();
  504. $diff = array_diff_assoc($this->makeSureArrayContainsOnlyStrings($requestProperties),
  505. $this->makeSureArrayContainsOnlyStrings($requestPropertiesBefore));
  506. if (!empty($diff['filter_sort_column'])) {
  507. // this here might be ok as it can be changed after data loaded but before filters applied
  508. unset($diff['filter_sort_column']);
  509. }
  510. if (!empty($diff['filter_sort_order'])) {
  511. // this here might be ok as it can be changed after data loaded but before filters applied
  512. unset($diff['filter_sort_order']);
  513. }
  514. if (empty($diff)) {
  515. return;
  516. }
  517. $details = array(
  518. 'changedProperties' => $diff,
  519. 'apiMethod' => $this->requestConfig->apiMethodToRequestDataTable,
  520. 'controller' => $this->config->controllerName . '.' . $this->config->controllerAction,
  521. 'viewDataTable' => static::getViewDataTableId()
  522. );
  523. $message = 'Some ViewDataTable::requestConfig properties have changed after requesting the data table. '
  524. . 'That means the changed values had probably no effect. For instance in beforeRender() hook. '
  525. . 'Probably a bug? Details:'
  526. . print_r($details, 1);
  527. Log::warning($message);
  528. }
  529. private function makeSureArrayContainsOnlyStrings($array)
  530. {
  531. $result = array();
  532. foreach ($array as $key => $value) {
  533. $result[$key] = json_encode($value);
  534. }
  535. return $result;
  536. }
  537. }