595 lines
22 KiB
PHP
595 lines
22 KiB
PHP
<?php
|
|
/**
|
|
* Piwik - Open source web analytics
|
|
*
|
|
* @link http://piwik.org
|
|
* @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
|
|
*
|
|
*/
|
|
|
|
namespace Piwik\Plugin;
|
|
|
|
use Piwik\Common;
|
|
use Piwik\DataTable;
|
|
use Piwik\Date;
|
|
use Piwik\Log;
|
|
use Piwik\MetricsFormatter;
|
|
use Piwik\NoAccessException;
|
|
use Piwik\Option;
|
|
use Piwik\Period;
|
|
use Piwik\Piwik;
|
|
use Piwik\Plugins\PrivacyManager\PrivacyManager;
|
|
use Piwik\View;
|
|
use Piwik\ViewDataTable\Manager as ViewDataTableManager;
|
|
|
|
/**
|
|
* The base class for report visualizations that output HTML and use JavaScript.
|
|
*
|
|
* Report visualizations that extend from this class will be displayed like all others in
|
|
* the Piwik UI. The following extra UI controls will be displayed around the visualization
|
|
* itself:
|
|
*
|
|
* - report documentation,
|
|
* - a footer message (if {@link Piwik\ViewDataTable\Config::$show_footer_message} is set),
|
|
* - a list of links to related reports (if {@link Piwik\ViewDataTable\Config::$related_reports} is set),
|
|
* - a button that allows users to switch visualizations,
|
|
* - a control that allows users to export report data in different formats,
|
|
* - a limit control that allows users to change the amount of rows displayed (if
|
|
* {@link Piwik\ViewDataTable\Config::$show_limit_control} is true),
|
|
* - and more depending on the visualization.
|
|
*
|
|
* ### Rendering Process
|
|
*
|
|
* The following process is used to render reports:
|
|
*
|
|
* - The report is loaded through Piwik's Reporting API.
|
|
* - The display and request properties that require report data in order to determine a default
|
|
* value are defaulted. These properties are:
|
|
*
|
|
* - {@link Piwik\ViewDataTable\Config::$columns_to_display}
|
|
* - {@link Piwik\ViewDataTable\RequestConfig::$filter_sort_column}
|
|
* - {@link Piwik\ViewDataTable\RequestConfig::$filter_sort_order}
|
|
*
|
|
* - Priority filters are applied to the report (see {@link Piwik\ViewDataTable\Config::$filters}).
|
|
* - The filters that are applied to every report in the Reporting API (called **generic filters**)
|
|
* are applied. (see {@link Piwik\API\Request})
|
|
* - The report's queued filters are applied.
|
|
* - A {@link Piwik\View} instance is created and rendered.
|
|
*
|
|
* ### Rendering Hooks
|
|
*
|
|
* The Visualization class defines several overridable methods that are called at specific
|
|
* points during the rendering process. Derived classes can override these methods change
|
|
* the data that is displayed or set custom properties.
|
|
*
|
|
* The overridable methods (called **rendering hooks**) are as follows:
|
|
*
|
|
* - **beforeLoadDataTable**: Called at the start of the rendering process before any data
|
|
* is loaded.
|
|
* - **beforeGenericFiltersAreAppliedToLoadedDataTable**: Called after data is loaded and after priority
|
|
* filters are called, but before other filters. This
|
|
* method should be used if you need the report's
|
|
* entire dataset.
|
|
* - **afterGenericFiltersAreAppliedToLoadedDataTable**: Called after generic filters are applied, but before
|
|
* queued filters are applied.
|
|
* - **afterAllFiltersAreApplied**: Called after data is loaded and all filters are applied.
|
|
* - **beforeRender**: Called immediately before a {@link Piwik\View} is created and rendered.
|
|
* - **isThereDataToDisplay**: Called after a {@link Piwik\View} is created to determine if the report has
|
|
* data or not. If not, a message is displayed to the user.
|
|
*
|
|
* ### The DataTable JavaScript class
|
|
*
|
|
* In the UI, visualization behavior is provided by logic in the **DataTable** JavaScript class.
|
|
* When creating new visualizations, the **DataTable** JavaScript class (or one of its existing
|
|
* descendants) should be extended.
|
|
*
|
|
* To learn more read the [Visualizing Report Data](/guides/visualizing-report-data#creating-new-visualizations)
|
|
* guide.
|
|
*
|
|
* ### Examples
|
|
*
|
|
* **Changing the data that is loaded**
|
|
*
|
|
* class MyVisualization extends Visualization
|
|
* {
|
|
* // load the previous period's data as well as the requested data. this will change
|
|
* // $this->dataTable from a DataTable instance to a DataTable\Map instance.
|
|
* public function beforeLoadDataTable()
|
|
* {
|
|
* $date = Common::getRequestVar('date');
|
|
* list($previousDate, $ignore) = Range::getLastDate($date, $period);
|
|
*
|
|
* $this->requestConfig->request_parameters_to_modify['date'] = $previousDate . ',' . $date;
|
|
* }
|
|
*
|
|
* // since we load the previous period's data too, we need to override the logic to
|
|
* // check if there is data or not.
|
|
* public function isThereDataToDisplay()
|
|
* {
|
|
* $tables = $this->dataTable->getDataTables()
|
|
* $requestedDataTable = end($tables);
|
|
*
|
|
* return $requestedDataTable->getRowsCount() != 0;
|
|
* }
|
|
* }
|
|
*
|
|
* **Force properties to be set to certain values**
|
|
*
|
|
* class MyVisualization extends Visualization
|
|
* {
|
|
* // ensure that some properties are set to certain values before rendering.
|
|
* // this will overwrite any changes made by plugins that use this visualization.
|
|
* public function beforeRender()
|
|
* {
|
|
* $this->config->max_graph_elements = false;
|
|
* $this->config->datatable_js_type = 'MyVisualization';
|
|
* $this->config->show_flatten_table = false;
|
|
* $this->config->show_pagination_control = false;
|
|
* $this->config->show_offset_information = false;
|
|
* }
|
|
* }
|
|
*/
|
|
class Visualization extends ViewDataTable
|
|
{
|
|
/**
|
|
* The Twig template file to use when rendering, eg, `"@MyPlugin/_myVisualization.twig"`.
|
|
*
|
|
* Must be defined by classes that extend Visualization.
|
|
*
|
|
* @api
|
|
*/
|
|
const TEMPLATE_FILE = '';
|
|
|
|
private $templateVars = array();
|
|
private $reportLastUpdatedMessage = null;
|
|
private $metadata = null;
|
|
|
|
final public function __construct($controllerAction, $apiMethodToRequestDataTable)
|
|
{
|
|
$templateFile = static::TEMPLATE_FILE;
|
|
|
|
if (empty($templateFile)) {
|
|
throw new \Exception('You have not defined a constant named TEMPLATE_FILE in your visualization class.');
|
|
}
|
|
|
|
parent::__construct($controllerAction, $apiMethodToRequestDataTable);
|
|
}
|
|
|
|
protected function buildView()
|
|
{
|
|
$this->overrideSomeConfigPropertiesIfNeeded();
|
|
|
|
try {
|
|
|
|
$this->beforeLoadDataTable();
|
|
|
|
$this->loadDataTableFromAPI(array('disable_generic_filters' => 1));
|
|
$this->postDataTableLoadedFromAPI();
|
|
|
|
$requestPropertiesAfterLoadDataTable = $this->requestConfig->getProperties();
|
|
|
|
$this->applyFilters();
|
|
$this->afterAllFiltersAreApplied();
|
|
$this->beforeRender();
|
|
|
|
$this->logMessageIfRequestPropertiesHaveChanged($requestPropertiesAfterLoadDataTable);
|
|
|
|
} catch (NoAccessException $e) {
|
|
throw $e;
|
|
} catch (\Exception $e) {
|
|
Log::warning("Failed to get data from API: " . $e->getMessage() . "\n" . $e->getTraceAsString());
|
|
|
|
$loadingError = array('message' => $e->getMessage());
|
|
}
|
|
|
|
$view = new View("@CoreHome/_dataTable");
|
|
|
|
if (!empty($loadingError)) {
|
|
$view->error = $loadingError;
|
|
}
|
|
|
|
$view->assign($this->templateVars);
|
|
$view->visualization = $this;
|
|
$view->visualizationTemplate = static::TEMPLATE_FILE;
|
|
$view->visualizationCssClass = $this->getDefaultDataTableCssClass();
|
|
|
|
if (null === $this->dataTable) {
|
|
$view->dataTable = null;
|
|
} else {
|
|
$view->dataTableHasNoData = !$this->isThereDataToDisplay();
|
|
$view->dataTable = $this->dataTable;
|
|
|
|
// if it's likely that the report data for this data table has been purged,
|
|
// set whether we should display a message to that effect.
|
|
$view->showReportDataWasPurgedMessage = $this->hasReportBeenPurged();
|
|
$view->deleteReportsOlderThan = Option::get('delete_reports_older_than');
|
|
}
|
|
|
|
$view->idSubtable = $this->requestConfig->idSubtable;
|
|
$view->clientSideParameters = $this->getClientSideParametersToSet();
|
|
$view->clientSideProperties = $this->getClientSidePropertiesToSet();
|
|
$view->properties = array_merge($this->requestConfig->getProperties(), $this->config->getProperties());
|
|
$view->reportLastUpdatedMessage = $this->reportLastUpdatedMessage;
|
|
$view->footerIcons = $this->config->footer_icons;
|
|
$view->isWidget = Common::getRequestVar('widget', 0, 'int');
|
|
|
|
return $view;
|
|
}
|
|
|
|
private function overrideSomeConfigPropertiesIfNeeded()
|
|
{
|
|
if (empty($this->config->footer_icons)) {
|
|
$this->config->footer_icons = ViewDataTableManager::configureFooterIcons($this);
|
|
}
|
|
|
|
if (!\Piwik\Plugin\Manager::getInstance()->isPluginActivated('Goals')) {
|
|
$this->config->show_goals = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Assigns a template variable making it available in the Twig template specified by
|
|
* {@link TEMPLATE_FILE}.
|
|
*
|
|
* @param array|string $vars One or more variable names to set.
|
|
* @param mixed $value The value to set each variable to.
|
|
* @api
|
|
*/
|
|
public function assignTemplateVar($vars, $value = null)
|
|
{
|
|
if (is_string($vars)) {
|
|
$this->templateVars[$vars] = $value;
|
|
} elseif (is_array($vars)) {
|
|
foreach ($vars as $key => $value) {
|
|
$this->templateVars[$key] = $value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns `true` if there is data to display, `false` if otherwise.
|
|
*
|
|
* Derived classes should override this method if they change the amount of data that is loaded.
|
|
*
|
|
* @api
|
|
*/
|
|
protected function isThereDataToDisplay()
|
|
{
|
|
return !empty($this->dataTable) && 0 < $this->dataTable->getRowsCount();
|
|
}
|
|
|
|
/**
|
|
* Hook called after the dataTable has been loaded from the API
|
|
* Can be used to add, delete or modify the data freshly loaded
|
|
*
|
|
* @return bool
|
|
*/
|
|
private function postDataTableLoadedFromAPI()
|
|
{
|
|
$columns = $this->dataTable->getColumns();
|
|
$hasNbVisits = in_array('nb_visits', $columns);
|
|
$hasNbUniqVisitors = in_array('nb_uniq_visitors', $columns);
|
|
|
|
// default columns_to_display to label, nb_uniq_visitors/nb_visits if those columns exist in the
|
|
// dataset. otherwise, default to all columns in dataset.
|
|
if (empty($this->config->columns_to_display)) {
|
|
$this->config->setDefaultColumnsToDisplay($columns, $hasNbVisits, $hasNbUniqVisitors);
|
|
}
|
|
|
|
if (!empty($this->dataTable)) {
|
|
$this->removeEmptyColumnsFromDisplay();
|
|
}
|
|
|
|
if (empty($this->requestConfig->filter_sort_column)) {
|
|
$this->requestConfig->setDefaultSort($this->config->columns_to_display, $hasNbUniqVisitors);
|
|
}
|
|
|
|
// deal w/ table metadata
|
|
if ($this->dataTable instanceof DataTable) {
|
|
$this->metadata = $this->dataTable->getAllTableMetadata();
|
|
|
|
if (isset($this->metadata[DataTable::ARCHIVED_DATE_METADATA_NAME])) {
|
|
$this->config->report_last_updated_message = $this->makePrettyArchivedOnText();
|
|
}
|
|
}
|
|
}
|
|
|
|
private function applyFilters()
|
|
{
|
|
list($priorityFilters, $otherFilters) = $this->config->getFiltersToRun();
|
|
|
|
// First, filters that delete rows
|
|
foreach ($priorityFilters as $filter) {
|
|
$this->dataTable->filter($filter[0], $filter[1]);
|
|
}
|
|
|
|
$this->beforeGenericFiltersAreAppliedToLoadedDataTable();
|
|
|
|
if (!$this->requestConfig->areGenericFiltersDisabled()) {
|
|
$this->applyGenericFilters();
|
|
}
|
|
|
|
$this->afterGenericFiltersAreAppliedToLoadedDataTable();
|
|
|
|
// queue other filters so they can be applied later if queued filters are disabled
|
|
foreach ($otherFilters as $filter) {
|
|
$this->dataTable->queueFilter($filter[0], $filter[1]);
|
|
}
|
|
|
|
// Finally, apply datatable filters that were queued (should be 'presentation' filters that
|
|
// do not affect the number of rows)
|
|
if (!$this->requestConfig->areQueuedFiltersDisabled()) {
|
|
$this->dataTable->applyQueuedFilters();
|
|
}
|
|
}
|
|
|
|
private function removeEmptyColumnsFromDisplay()
|
|
{
|
|
if ($this->dataTable instanceof DataTable\Map) {
|
|
$emptyColumns = $this->dataTable->getMetadataIntersectArray(DataTable::EMPTY_COLUMNS_METADATA_NAME);
|
|
} else {
|
|
$emptyColumns = $this->dataTable->getMetadata(DataTable::EMPTY_COLUMNS_METADATA_NAME);
|
|
}
|
|
|
|
if (is_array($emptyColumns)) {
|
|
foreach ($emptyColumns as $emptyColumn) {
|
|
$key = array_search($emptyColumn, $this->config->columns_to_display);
|
|
if ($key !== false) {
|
|
unset($this->config->columns_to_display[$key]);
|
|
}
|
|
}
|
|
|
|
$this->config->columns_to_display = array_values($this->config->columns_to_display);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns prettified and translated text that describes when a report was last updated.
|
|
*
|
|
* @return string
|
|
*/
|
|
private function makePrettyArchivedOnText()
|
|
{
|
|
$dateText = $this->metadata[DataTable::ARCHIVED_DATE_METADATA_NAME];
|
|
$date = Date::factory($dateText);
|
|
$today = mktime(0, 0, 0);
|
|
|
|
if ($date->getTimestamp() > $today) {
|
|
|
|
$elapsedSeconds = time() - $date->getTimestamp();
|
|
$timeAgo = MetricsFormatter::getPrettyTimeFromSeconds($elapsedSeconds);
|
|
|
|
return Piwik::translate('CoreHome_ReportGeneratedXAgo', $timeAgo);
|
|
}
|
|
|
|
$prettyDate = $date->getLocalized("%longYear%, %longMonth% %day%") . $date->toString('S');
|
|
|
|
return Piwik::translate('CoreHome_ReportGeneratedOn', $prettyDate);
|
|
}
|
|
|
|
/**
|
|
* Returns true if it is likely that the data for this report has been purged and if the
|
|
* user should be told about that.
|
|
*
|
|
* In order for this function to return true, the following must also be true:
|
|
* - The data table for this report must either be empty or not have been fetched.
|
|
* - The period of this report is not a multiple period.
|
|
* - The date of this report must be older than the delete_reports_older_than config option.
|
|
* @return bool
|
|
*/
|
|
private function hasReportBeenPurged()
|
|
{
|
|
if (!\Piwik\Plugin\Manager::getInstance()->isPluginActivated('PrivacyManager')) {
|
|
return false;
|
|
}
|
|
|
|
return PrivacyManager::hasReportBeenPurged($this->dataTable);
|
|
}
|
|
|
|
/**
|
|
* Returns array of properties that should be visible to client side JavaScript. The data
|
|
* will be available in the data-props HTML attribute of the .dataTable div.
|
|
*
|
|
* @return array Maps property names w/ property values.
|
|
*/
|
|
private function getClientSidePropertiesToSet()
|
|
{
|
|
$result = array();
|
|
|
|
foreach ($this->config->clientSideProperties as $name) {
|
|
if (property_exists($this->requestConfig, $name)) {
|
|
$result[$name] = $this->getIntIfValueIsBool($this->requestConfig->$name);
|
|
} else if (property_exists($this->config, $name)) {
|
|
$result[$name] = $this->getIntIfValueIsBool($this->config->$name);
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
private function getIntIfValueIsBool($value)
|
|
{
|
|
return is_bool($value) ? (int)$value : $value;
|
|
}
|
|
|
|
/**
|
|
* This functions reads the customization values for the DataTable and returns an array (name,value) to be printed in Javascript.
|
|
* This array defines things such as:
|
|
* - name of the module & action to call to request data for this table
|
|
* - optional filters information, eg. filter_limit and filter_offset
|
|
* - etc.
|
|
*
|
|
* The values are loaded:
|
|
* - from the generic filters that are applied by default @see Piwik\API\DataTableGenericFilter::getGenericFiltersInformation()
|
|
* - from the values already available in the GET array
|
|
* - from the values set using methods from this class (eg. setSearchPattern(), setLimit(), etc.)
|
|
*
|
|
* @return array eg. array('show_offset_information' => 0, 'show_...
|
|
*/
|
|
protected function getClientSideParametersToSet()
|
|
{
|
|
// build javascript variables to set
|
|
$javascriptVariablesToSet = array();
|
|
|
|
foreach ($this->config->custom_parameters as $name => $value) {
|
|
$javascriptVariablesToSet[$name] = $value;
|
|
}
|
|
|
|
foreach ($_GET as $name => $value) {
|
|
try {
|
|
$requestValue = Common::getRequestVar($name);
|
|
} catch (\Exception $e) {
|
|
$requestValue = '';
|
|
}
|
|
$javascriptVariablesToSet[$name] = $requestValue;
|
|
}
|
|
|
|
foreach ($this->requestConfig->clientSideParameters as $name) {
|
|
if (isset($javascriptVariablesToSet[$name])) {
|
|
continue;
|
|
}
|
|
|
|
$valueToConvert = false;
|
|
|
|
if (property_exists($this->requestConfig, $name)) {
|
|
$valueToConvert = $this->requestConfig->$name;
|
|
} else if (property_exists($this->config, $name)) {
|
|
$valueToConvert = $this->config->$name;
|
|
}
|
|
|
|
if (false !== $valueToConvert) {
|
|
$javascriptVariablesToSet[$name] = $this->getIntIfValueIsBool($valueToConvert);
|
|
}
|
|
}
|
|
|
|
$javascriptVariablesToSet['module'] = $this->config->controllerName;
|
|
$javascriptVariablesToSet['action'] = $this->config->controllerAction;
|
|
if (!isset($javascriptVariablesToSet['viewDataTable'])) {
|
|
$javascriptVariablesToSet['viewDataTable'] = static::getViewDataTableId();
|
|
}
|
|
|
|
if ($this->dataTable &&
|
|
// Set doesn't have the method
|
|
!($this->dataTable instanceof DataTable\Map)
|
|
&& empty($javascriptVariablesToSet['totalRows'])
|
|
) {
|
|
$javascriptVariablesToSet['totalRows'] =
|
|
$this->dataTable->getMetadata(DataTable::TOTAL_ROWS_BEFORE_LIMIT_METADATA_NAME) ?: $this->dataTable->getRowsCount();
|
|
}
|
|
|
|
$deleteFromJavascriptVariables = array(
|
|
'filter_excludelowpop',
|
|
'filter_excludelowpop_value',
|
|
);
|
|
foreach ($deleteFromJavascriptVariables as $name) {
|
|
if (isset($javascriptVariablesToSet[$name])) {
|
|
unset($javascriptVariablesToSet[$name]);
|
|
}
|
|
}
|
|
|
|
$rawSegment = \Piwik\API\Request::getRawSegmentFromRequest();
|
|
if (!empty($rawSegment)) {
|
|
$javascriptVariablesToSet['segment'] = $rawSegment;
|
|
}
|
|
|
|
return $javascriptVariablesToSet;
|
|
}
|
|
|
|
/**
|
|
* Hook that is called before loading report data from the API.
|
|
*
|
|
* Use this method to change the request parameters that is sent to the API when requesting
|
|
* data.
|
|
*/
|
|
public function beforeLoadDataTable()
|
|
{
|
|
}
|
|
|
|
/**
|
|
* Hook that is executed before generic filters are applied.
|
|
*
|
|
* Use this method if you need access to the entire dataset (since generic filters will
|
|
* limit and truncate reports).
|
|
*/
|
|
public function beforeGenericFiltersAreAppliedToLoadedDataTable()
|
|
{
|
|
}
|
|
|
|
/**
|
|
* Hook that is executed after generic filters are applied.
|
|
*/
|
|
public function afterGenericFiltersAreAppliedToLoadedDataTable()
|
|
{
|
|
}
|
|
|
|
/**
|
|
* Hook that is executed after the report data is loaded and after all filters have been applied.
|
|
* Use this method to format the report data before the view is rendered.
|
|
*/
|
|
public function afterAllFiltersAreApplied()
|
|
{
|
|
}
|
|
|
|
/**
|
|
* Hook that is executed directly before rendering. Use this hook to force display properties to
|
|
* be a certain value, despite changes from plugins and query parameters.
|
|
*/
|
|
public function beforeRender()
|
|
{
|
|
// eg $this->config->showFooterColumns = true;
|
|
}
|
|
|
|
/**
|
|
* Second, generic filters (Sort, Limit, Replace Column Names, etc.)
|
|
*/
|
|
private function applyGenericFilters()
|
|
{
|
|
$requestArray = $this->request->getRequestArray();
|
|
$request = \Piwik\API\Request::getRequestArrayFromString($requestArray);
|
|
|
|
if (false === $this->config->enable_sort) {
|
|
$request['filter_sort_column'] = '';
|
|
$request['filter_sort_order'] = '';
|
|
}
|
|
|
|
$genericFilter = new \Piwik\API\DataTableGenericFilter($request);
|
|
$genericFilter->filter($this->dataTable);
|
|
}
|
|
|
|
private function logMessageIfRequestPropertiesHaveChanged(array $requestPropertiesBefore)
|
|
{
|
|
$requestProperties = $this->requestConfig->getProperties();
|
|
|
|
$diff = array_diff_assoc($this->makeSureArrayContainsOnlyStrings($requestProperties),
|
|
$this->makeSureArrayContainsOnlyStrings($requestPropertiesBefore));
|
|
|
|
if (empty($diff)) {
|
|
return;
|
|
}
|
|
|
|
$details = array(
|
|
'changedProperties' => $diff,
|
|
'apiMethod' => $this->requestConfig->apiMethodToRequestDataTable,
|
|
'controller' => $this->config->controllerName . '.' . $this->config->controllerAction,
|
|
'viewDataTable' => static::getViewDataTableId()
|
|
);
|
|
|
|
$message = 'Some ViewDataTable::requestConfig properties have changed after requesting the data table. '
|
|
. 'That means the changed values had probably no effect. For instance in beforeRender() hook. '
|
|
. 'Probably a bug? Details:'
|
|
. print_r($details, 1);
|
|
|
|
Log::warning($message);
|
|
}
|
|
|
|
private function makeSureArrayContainsOnlyStrings($array)
|
|
{
|
|
$result = array();
|
|
|
|
foreach ($array as $key => $value) {
|
|
$result[$key] = json_encode($value);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
}
|