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\Plugins\MultiSites;
10
11use Piwik\API\DataTablePostProcessor;
12use Piwik\API\Request;
13use Piwik\API\ResponseBuilder;
14use Piwik\Config;
15use Piwik\Metrics\Formatter;
16use Piwik\NumberFormatter;
17use Piwik\Period;
18use Piwik\DataTable;
19use Piwik\DataTable\Row;
20use Piwik\DataTable\Row\DataTableSummaryRow;
21use Piwik\Site;
22use Piwik\View;
23
24/**
25 * Fetches and formats the response of `MultiSites.getAll` in a way that it can be used by the All Websites AngularJS
26 * widget. Eg sites are moved into groups if one is assigned, stats are calculated for groups, etc.
27 */
28class Dashboard
29{
30    /** @var DataTable */
31    private $sitesByGroup;
32
33    /**
34     * @var int
35     */
36    private $numSites = 0;
37
38    /**
39     * Array of metrics that will be displayed and will be number formatted
40     * @var array
41     */
42    private $displayedMetricColumns = array('nb_visits', 'nb_pageviews', 'nb_actions', 'revenue');
43
44    /**
45     * @param string $period
46     * @param string $date
47     * @param string|false $segment
48     */
49    public function __construct($period, $date, $segment)
50    {
51        $sites = Request::processRequest('MultiSites.getAll', [
52            'period' => $period,
53            'date' => $date,
54            'segment' => $segment,
55            'enhanced' => '1',
56            // NOTE: have to select everything since with queued filters disabled some metrics won't be renamed to
57            // their display name, and so showColumns will end up removing those.
58            'showColumns' => '',
59            'disable_queued_filters' => '1',
60            'filter_limit' => '-1',
61            'filter_offset' => '0',
62            'totals' => 0
63        ], $default = []);
64
65        $sites->deleteRow(DataTable::ID_SUMMARY_ROW);
66
67        /** @var DataTable $pastData */
68        $pastData = $sites->getMetadata('pastData');
69
70        $sites->filter(function (DataTable $table) use ($pastData) {
71            $pastRow = null;
72
73            foreach ($table->getRows() as $row) {
74                $idSite = $row->getColumn('label');
75                $site   = Site::getSite($idSite);
76                // we cannot queue label and group as we might need them for search and sorting!
77                $row->setColumn('label', $site['name']);
78                $row->setMetadata('group', $site['group']);
79
80                if ($pastData) {
81                    // if we do not update the pastData labels, the evolution cannot be calculated correctly.
82                    $pastRow = $pastData->getRowFromLabel($idSite);
83                    if ($pastRow) {
84                        $pastRow->setColumn('label', $site['name']);
85                    }
86                }
87            }
88
89            if ($pastData && $pastRow) {
90                $pastData->setLabelsHaveChanged();
91            }
92
93        });
94
95        $this->setSitesTable($sites);
96    }
97
98    public function setSitesTable(DataTable $sites)
99    {
100        $this->sitesByGroup = $this->moveSitesHavingAGroupIntoSubtables($sites);
101        $this->rememberNumberOfSites();
102    }
103
104    public function getSites($request, $limit)
105    {
106        $request['filter_limit']  = $limit;
107        $request['filter_offset'] = isset($request['filter_offset']) ? $request['filter_offset'] : 0;
108
109        $this->makeSitesFlatAndApplyGenericFilters($this->sitesByGroup, $request);
110        $sites = $this->convertDataTableToArrayAndApplyQueuedFilters($this->sitesByGroup, $request);
111        $sites = $this->enrichValues($sites);
112
113        return $sites;
114    }
115
116    public function getTotals()
117    {
118        $totals = array(
119            'nb_pageviews'       => $this->sitesByGroup->getMetadata('total_nb_pageviews'),
120            'nb_visits'          => $this->sitesByGroup->getMetadata('total_nb_visits'),
121            'nb_actions'         => $this->sitesByGroup->getMetadata('total_nb_actions'),
122            'revenue'            => $this->sitesByGroup->getMetadata('total_revenue'),
123            'nb_visits_lastdate' => $this->sitesByGroup->getMetadata('total_nb_visits_lastdate') ? : 0,
124        );
125        $this->formatMetrics($totals);
126        return $totals;
127    }
128
129    private function formatMetrics(&$metrics)
130    {
131        $formatter = NumberFormatter::getInstance();
132        foreach($metrics as $metricName => &$value) {
133            if(in_array($metricName, $this->displayedMetricColumns)) {
134
135                if( strpos($metricName, 'revenue') !== false) {
136                    $currency = isset($metrics['idsite']) ? Site::getCurrencySymbolFor($metrics['idsite']) : '';
137                    $value  = $formatter->formatCurrency($value, $currency);
138                    continue;
139                }
140                $value = $formatter->format($value);
141            }
142        }
143    }
144
145
146    public function getNumSites()
147    {
148        return $this->numSites;
149    }
150
151    public function search($pattern)
152    {
153        $this->nestedSearch($this->sitesByGroup, $pattern);
154        $this->rememberNumberOfSites();
155    }
156
157    private function rememberNumberOfSites()
158    {
159        $this->numSites = $this->sitesByGroup->getRowsCountRecursive();
160    }
161
162    private function nestedSearch(DataTable $sitesByGroup, $pattern)
163    {
164        foreach ($sitesByGroup->getRows() as $index => $site) {
165
166            $label = strtolower($site->getColumn('label'));
167            $labelMatches = false !== strpos($label, $pattern);
168
169            if ($site->getMetadata('isGroup')) {
170                $subtable = $site->getSubtable();
171                $this->nestedSearch($subtable, $pattern);
172
173                if (!$labelMatches && !$subtable->getRowsCount()) {
174                    // we keep the group if at least one site within the group matches the pattern
175                    $sitesByGroup->deleteRow($index);
176                }
177
178            } elseif (!$labelMatches) {
179                $group = $site->getMetadata('group');
180
181                if (!$group || false === strpos(strtolower($group), $pattern)) {
182                    $sitesByGroup->deleteRow($index);
183                }
184            }
185        }
186    }
187
188    /**
189     * @return string
190     */
191    public function getLastDate()
192    {
193        $lastPeriod = $this->sitesByGroup->getMetadata('last_period_date');
194
195        if (!empty($lastPeriod)) {
196            $lastPeriod = $lastPeriod->toString();
197        } else {
198            $lastPeriod = '';
199        }
200
201        return $lastPeriod;
202    }
203
204    private function convertDataTableToArrayAndApplyQueuedFilters(DataTable $table, $request)
205    {
206        $request['serialize'] = 0;
207        $request['expanded'] = 0;
208        $request['totals'] = 0;
209        $request['format_metrics'] = 1;
210        $request['disable_generic_filters'] = 1;
211
212        $responseBuilder = new ResponseBuilder('json', $request);
213        $rows = json_decode($responseBuilder->getResponse($table, 'MultiSites', 'getAll'), true);
214
215        return $rows;
216    }
217
218    private function moveSitesHavingAGroupIntoSubtables(DataTable $sites)
219    {
220        /** @var DataTableSummaryRow[] $groups */
221        $groups = array();
222
223        $sitesByGroup = $this->makeCloneOfDataTableSites($sites);
224        $sitesByGroup->enableRecursiveFilters(); // we need to make sure filters get applied to subtables (groups)
225
226        foreach ($sites->getRows() as $site) {
227
228            $group = $site->getMetadata('group');
229
230            if (!empty($group) && !array_key_exists($group, $groups)) {
231                $row = new DataTableSummaryRow();
232                $row->setColumn('label', $group);
233                $row->setMetadata('isGroup', 1);
234                $row->setSubtable($this->createGroupSubtable($sites));
235                $sitesByGroup->addRow($row);
236
237                $groups[$group] = $row;
238            }
239
240            if (!empty($group)) {
241                $groups[$group]->getSubtable()->addRow($site);
242            } else {
243                $sitesByGroup->addRow($site);
244            }
245        }
246
247        foreach ($groups as $group) {
248            // we need to recalculate as long as all rows are there, as soon as some rows are removed
249            // we can no longer recalculate the correct value. We might even calculate values for groups
250            // that are not returned. If this becomes a problem we need to keep a copy of this to recalculate
251            // only actual returned groups.
252            $group->recalculate();
253        }
254
255        return $sitesByGroup;
256    }
257
258    private function createGroupSubtable(DataTable $sites)
259    {
260        $table = new DataTable();
261        $processedMetrics = $sites->getMetadata(DataTable::EXTRA_PROCESSED_METRICS_METADATA_NAME);
262        $table->setMetadata(DataTable::EXTRA_PROCESSED_METRICS_METADATA_NAME, $processedMetrics);
263
264        return $table;
265    }
266
267    private function makeCloneOfDataTableSites(DataTable $sites)
268    {
269        $sitesByGroup = $sites->getEmptyClone(true);
270        // we handle them ourselves for faster performance etc. This way we also avoid to apply them twice.
271        $sitesByGroup->disableFilter('ColumnCallbackReplace');
272        $sitesByGroup->disableFilter('MetadataCallbackAddMetadata');
273
274        return $sitesByGroup;
275    }
276
277    /**
278     * Makes sure to not have any subtables anymore.
279     *
280     * So if $table is
281     * array(
282     *    site1
283     *    site2
284     *        subtable => site3
285     *                    site4
286     *                    site5
287     *    site6
288     *    site7
289     * )
290     *
291     * it will return
292     *
293     * array(
294     *    site1
295     *    site2
296     *    site3
297     *    site4
298     *    site5
299     *    site6
300     *    site7
301     * )
302     *
303     * in a sorted order
304     *
305     * @param DataTable $table
306     * @param array $request
307     */
308    private function makeSitesFlatAndApplyGenericFilters(DataTable $table, $request)
309    {
310        // we handle limit here as we have to apply sort filter, then make sites flat, then apply limit filter.
311        $filterOffset = $request['filter_offset'];
312        $filterLimit  = $request['filter_limit'];
313        unset($request['filter_offset']);
314        unset($request['filter_limit']);
315
316        // filter_sort_column does not work correctly is a bug in MultiSites.getAll
317        if (!empty($request['filter_sort_column']) && $request['filter_sort_column'] === 'nb_pageviews') {
318            $request['filter_sort_column'] = 'Actions_nb_pageviews';
319        } elseif (!empty($request['filter_sort_column']) && $request['filter_sort_column'] === 'revenue') {
320            $request['filter_sort_column'] = 'Goal_revenue';
321        }
322
323        // make sure no limit filter is applied, we will do this manually
324        $table->disableFilter('Limit');
325
326        // this will apply the sort filter
327        /** @var DataTable $table */
328        $genericFilter = new DataTablePostProcessor('MultiSites', 'getAll', $request);
329        $table = $genericFilter->applyGenericFilters($table);
330
331        // make sure from now on the sites will be no longer sorted, they were already sorted
332        $table->disableFilter('Sort');
333
334        // make sites flat and limit
335        $table->filter('Piwik\Plugins\MultiSites\DataTable\Filter\NestedSitesLimiter', array($filterOffset, $filterLimit));
336    }
337
338    private function enrichValues($sites)
339    {
340        foreach ($sites as &$site) {
341            if (!isset($site['idsite'])) {
342                continue;
343            }
344
345            $site['main_url'] = Site::getMainUrlFor($site['idsite']);
346
347            $this->formatMetrics($site);
348        }
349
350        return $sites;
351    }
352}
353