]> _ Git - fluidbook-toolbox.git/commitdiff
WIP #5316 @30
authorStephen Cameron <stephen@cubedesigners.com>
Thu, 4 Aug 2022 18:12:47 +0000 (20:12 +0200)
committerStephen Cameron <stephen@cubedesigners.com>
Thu, 4 Aug 2022 18:12:47 +0000 (20:12 +0200)
app/Http/Controllers/Admin/FluidbookStatsController.php
composer.json
composer.lock
resources/views/fluidbook_stats/API.blade.php [new file with mode: 0644]
resources/views/fluidbook_stats/summary.blade.php [new file with mode: 0644]
routes/backpack/custom.php
routes/web.php

index ab77b39f421e53880cf0c78a1647b1165610cdf6..8dad6405b6fcc30d2dd33edcdb27161617e08994 100644 (file)
@@ -12,22 +12,24 @@ use Cubist\Matomo\Reporting;
 class FluidbookStatsController extends Controller
 {
 
-    private function _getReporting($fluidbook_id)
-    {
-        // Get the appropriate server / API token based on the Fluidbook ID
-        // Stats are split across different servers depending on the ID:
-        // ID < 21210 = stats3.fluidbook.com
-        // ID >= 21210 (even numbers) = stats4.fluidbook.com
-        // ID >= 21211 (odd numbers) = stats5.fluidbook.com
-
+    public static function getMatomoTokens() {
         // Each stats server has a different instance of Matamo, so we need to provide different API tokens for each
         // Normally this information would be stored in the .env but there's no good way to do that with an array, so
         // it is simpler to keep it here. These are also stored in the shared Bitwarden entry for Matomo.
-        $matomo_tokens = [
+        return [
             'stats3.fluidbook.com' => '9df722a0bd30878ddc4d737352427502',
             'stats4.fluidbook.com' => '3ffdbe052ae625f065573df9fa9515df',
             'stats5.fluidbook.com' => '85e9cc307b6e5083249949e9472a80b8',
         ];
+    }
+
+    private function _getReporting($fluidbook_id)
+    {
+        // Get the appropriate server / API token based on the Fluidbook ID
+        // Stats are split across different servers depending on the ID:
+        // ID < 21210 = stats3.fluidbook.com
+        // ID >= 21210 (even numbers) = stats4.fluidbook.com
+        // ID >= 21211 (odd numbers) = stats5.fluidbook.com
 
         $fluidbook_id = intval($fluidbook_id);
 
@@ -41,6 +43,8 @@ class FluidbookStatsController extends Controller
 
         //dump("Server is $server");
 
+        $matomo_tokens = self::getMatomoTokens();
+
         return new Reporting("https://{$server}/", $matomo_tokens[$server]);
     }
 
@@ -155,5 +159,16 @@ class FluidbookStatsController extends Controller
 
     }
 
+    protected function API() {
+
+        if (!can('superadmin')) {
+            // Only allow superadmin access because this is a dev tool and it exposes the API tokens
+            return response(null)->setStatusCode('403');
+        }
+
+        $matomo_tokens = json_encode(static::getMatomoTokens());
+
+        return view('fluidbook_stats.API', compact('matomo_tokens'));
+    }
 
 }
index 10cf0f901284e14c2b529d557bb28ac0778134b8..49df2639fccce1a4199e52ef279bbc145695b9f5 100644 (file)
     "license": "MIT",
     "require": {
         "php": ">=8.0",
-        "ext-json": "*",
-        "ext-simplexml": "*",
+        "ext-calendar": "*",
         "ext-dom": "*",
+        "ext-json": "*",
         "ext-libxml": "*",
+        "ext-simplexml": "*",
         "ext-tidy": "*",
         "ext-zip": "*",
         "ahmadshah/lucy": "dev-master",
+        "chartisan/php": "^1.2",
         "cubedesigners/userdatabase": "dev-master",
         "cubist/azuretts": "dev-master",
         "cubist/cms-back": "dev-master",
+        "cubist/matomo": "dev-master",
         "cubist/pdf": "dev-master",
         "cubist/scorm": "dev-master",
         "fluidbook/tools": "dev-master",
@@ -37,7 +40,6 @@
         "php-ffmpeg/php-ffmpeg": "^0.18.0",
         "phpoffice/phpspreadsheet": "^1.22",
         "rustici-software/scormcloud-api-v2-client-php": "^2.0"
-
     },
     "require-dev": {
         "facade/ignition": "^2.17",
index f65d05dc12c69a1fcd474605641526083e81168a..3668170d6e264119f1a9df199c15c25bd07d20d7 100644 (file)
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "c0e65b576302056a4929e45aaedd1971",
+    "content-hash": "37d4b92043ca0a35270b6f38d7ee16c1",
     "packages": [
         {
             "name": "ahmadshah/lucy",
             ],
             "time": "2020-09-08T20:04:29+00:00"
         },
+        {
+            "name": "chartisan/php",
+            "version": "1.2.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/Chartisan/PHP.git",
+                "reference": "85d2352077800e9bcb411aec1ff7e4d23eef93a1"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/Chartisan/PHP/zipball/85d2352077800e9bcb411aec1ff7e4d23eef93a1",
+                "reference": "85d2352077800e9bcb411aec1ff7e4d23eef93a1",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.4"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Chartisan\\PHP\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Èrik Campobadal Forés",
+                    "email": "soc@erik.cat"
+                }
+            ],
+            "description": "Chartisan's PHP backend",
+            "support": {
+                "issues": "https://github.com/Chartisan/PHP/issues",
+                "source": "https://github.com/Chartisan/PHP/tree/1.2.1"
+            },
+            "time": "2020-11-05T13:13:58+00:00"
+        },
         {
             "name": "chrisjean/php-ico",
             "version": "1.0.4",
             "description": "Cubist Locale",
             "time": "2022-04-12T10:03:49+00:00"
         },
+        {
+            "name": "cubist/matomo",
+            "version": "dev-master",
+            "source": {
+                "type": "git",
+                "url": "git://git.cubedesigners.com/cubist_matomo.git",
+                "reference": "6fc6532042481409ec54713b2967bea13b20f89d"
+            },
+            "dist": {
+                "type": "tar",
+                "url": "https://composer.cubedesigners.com/dist/cubist/matomo/cubist-matomo-dev-master-7b4016.tar",
+                "reference": "6fc6532042481409ec54713b2967bea13b20f89d",
+                "shasum": "3a9d43421797356f9931b05a37f60da138e10ee3"
+            },
+            "require": {
+                "ext-json": "*",
+                "guzzlehttp/guzzle": "^7.4.1",
+                "php": ">=7.2"
+            },
+            "default-branch": true,
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Cubist\\Matomo\\": "src"
+                }
+            },
+            "license": [
+                "proprietary"
+            ],
+            "authors": [
+                {
+                    "name": "Vincent Vanwaelscappel",
+                    "email": "vincent@cubedesigners.com"
+                }
+            ],
+            "description": "Matomo API",
+            "time": "2022-06-15T10:09:17+00:00"
+        },
         {
             "name": "cubist/net",
             "version": "dev-master",
diff --git a/resources/views/fluidbook_stats/API.blade.php b/resources/views/fluidbook_stats/API.blade.php
new file mode 100644 (file)
index 0000000..895b71c
--- /dev/null
@@ -0,0 +1,229 @@
+{{-- Matomo Stats API Helper --}}
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Matomo API Explorer</title>
+    <script src="https://cdn.tailwindcss.com?plugins=forms"></script>
+    <style>
+        html, body {
+            height: 100%;
+        }
+
+        input:not([type=button],[type=submit]), select {
+            padding: 0.5em !important;
+            border-radius: 0.5em !important;
+            line-height: 1 !important;
+            font-size: 0.9em !important;
+        }
+
+        label span {
+            font-weight: 500;
+            color: #fff;
+        }
+
+    </style>
+</head>
+<body class="bg-gray-600 p-4">
+<div x-data="matomo()" class="flex flex-col h-full space-y-2">
+    <!--
+    <select x-model="server">
+        <template x-for="server in Object.keys(servers)" :key="server">
+            <option :value="server" x-text="server"></option>
+        </template>
+    </select>
+    -->
+    <form class="options w-full space-y-2 flex-grow-0" @submit.prevent="loadAPI">
+        <div class="flex items-center space-x-2">
+            <a href="https://developer.matomo.org/api-reference/reporting-api" class="text-yellow-200 hover:text-yellow-300 font-bold" target="_blank">Matomo API</a>
+            <input x-model="generatedURL" class="flex-1 text-xs text-gray-500" @click="$el.select()">
+        </div>
+
+        <div class="flex items-center space-x-2">
+            <label class="space-x-1">
+                <span>Site ID</span>
+                <input x-model="siteID" class="w-20" @focus="$el.select()">
+            </label>
+            <label class="space-x-1">
+                <span>API Method</span>
+                <input class="w-60" type="search" x-model="method" @focus="$el.select()" list="API-methods" autofocus>
+                <datalist id="API-methods">
+                    {{-- List of suggestions for the input... --}}
+                    <option value="VisitsSummary.get">
+                    <option value="VisitsSummary.getVisits">
+                    <option value="VisitsSummary.getUniqueVisitors">
+                    <option value="Actions.get">
+                    <option value="Actions.getPageUrls">
+                    <option value="Actions.getSiteSearchKeywords">
+                    <option value="Events.getCategory">
+                    <option value="UserCountry.getCountry">
+                    <option value="UserCountry.getCity">
+                    <option value="UserLanguage.getLanguage">
+                </datalist>
+            </label>
+            <label class="space-x-1">
+                <span>Date</span>
+                <input class="w-50" type="search" x-model="date" @focus="$el.select()" list="date-examples">
+                <datalist id="date-examples">
+                    {{-- There are lots possible date inputs, so this just to give some ideas --}}
+                    <option value="previous12">
+                    <option value="last30">
+                    <option value="today">
+                    <option value="yesterday">
+                    <option value="{{ date('Y') }}-01-01,today">
+                </datalist>
+            </label>
+            <label class="space-x-1">
+                <span>Period</span>
+                <select x-model="period" class="capitalize rounded-lg py-0 w-24">
+                    <template x-for="p in periods" :key="p">
+                        <option :value="p" x-text="p" :selected="period === p"></option>
+                    </template>
+                </select>
+            </label>
+            <input type="submit" value="GO" class="bg-green-500 text-white py-1 px-4 rounded" :class="method !== '' || 'cursor-not-allowed opacity-60'">
+            <input type="button" value="Clear" class="bg-red-500 text-white py-1 px-4 rounded" @click="$refs.result.innerHTML = ''">
+        </div>
+    </form>
+
+    <div x-ref="result" class="flex-1 bg-white rounded-lg p-4 overflow-y-scroll"></div>
+
+</div>
+
+<script>
+  function matomo() {
+    return {
+      servers: {!! $matomo_tokens; !!},
+      periods: ['day', 'month', 'year', 'range'],
+      siteID: 16976,
+      method: '',
+      period: 'month',
+      date: '{{ date('Y') }}-01-01,today',
+
+      get server() {
+        if (!this.siteID) return false;
+        let ID = parseInt(this.siteID)
+
+        if (ID < 21210) return 'stats3.fluidbook.com';
+        else if (ID >= 21210 && ID % 2 === 0) return 'stats4.fluidbook.com';
+        else return 'stats5.fluidbook.com';
+
+      },
+
+      get generatedURL() {
+        return `https://${this.server}/?idSite=${this.siteID}&method=${this.method}&date=${this.date}&period=${this.period}&module=API&token_auth=${this.servers[this.server]}&format=JSON`
+      },
+
+      loadAPI() {
+        let $this = this;
+
+        if (this.method === '') return false;
+
+        fetch(this.generatedURL)
+          .then(response => response.json())
+          .then((data) => {
+              //$this.result = JSON.stringify(data, null, 2)
+              // let filteredData = Object.entries(data).filter(([key, value]) => !Array.isArray(value));
+              // Filter out empty results from the API...
+              let filteredData = Object.fromEntries(Object.entries(data).filter(([key, value]) => !Array.isArray(value) || (Array.isArray(value) && value.length > 0)));
+              // window.formatter = new JSONFormatter(filteredData, Infinity, { animateClose: false });
+              // $this.$refs.result.insertAdjacentHTML('afterbegin', window.formatter.render().innerHTML + '<br><hr><br>');
+
+              console.log("data:", data);
+              console.log("filtered:", filteredData);
+              // return false;
+
+
+              if ($this.period === 'range') { // When doing a range, data structure returned isn't the same as others
+                  filteredData = { [$this.date]: filteredData }
+              }
+
+              let requestDetails = `<h2 class="mb-4 font-medium text-sm">
+                Results for <span class="text-blue-600">${$this.method}</span>
+                (date: <span class="text-blue-600">${$this.date}</span>,
+                period: <span class="text-blue-600">${$this.period}</span>)
+                <a class="inline-block px-2 py-0.5 bg-blue-500 text-white text-xs ml-2 rounded" href="${$this.generatedURL}" target="_blank">RAW ↗️</a>
+              </h2>`;
+
+              let result = '';
+
+              // Check first item of filteredData object to see if it contains a nested array of data
+              if (Array.isArray(Object.values(filteredData)[0])) {
+                  for (const [date, nestedData] of Object.entries(filteredData)) {
+                      console.log(date, nestedData)
+                      result += `<h3 class="bg-gray-700 font-bold mt-4 mb-2 rounded px-3 py-1 text-white text-sm w-max">${date}</h3>`;
+                      result += $this.tableFromData(nestedData, true);
+                  }
+              } else {
+                  result += $this.tableFromData(filteredData);
+              }
+
+              $this.$refs.result.insertAdjacentHTML('afterbegin', requestDetails + result + '<hr class="my-4">')
+
+          });
+      },
+
+      tableFromData(filteredData, hideDateColumn) {
+          let tableClasses = 'border-collapse border border-slate-400 p-2 text-xs';
+          let table = `<table class="${tableClasses}">`;
+          let firstRow = Object.values(filteredData)[0];
+
+          let headings = (typeof firstRow === 'object') ?
+              Object.keys(firstRow).map(heading => `<th class="${tableClasses}">${heading}</th>`).join("")
+              :
+              `<th class="${tableClasses}">&nbsp;</th>`; // Only a singular value returned by API
+
+          table += `<tr class="bg-gray-400">`;
+
+          if (!hideDateColumn) {
+              table += `<th class="${tableClasses}">Date</th>`
+          }
+
+          table += headings;
+          table += `</tr>`;
+
+          Object.entries(filteredData).forEach(function(datedSet, index) {
+
+              // console.log('datedSet...', datedSet);
+
+              let date = datedSet[0];
+              let dateData = (typeof datedSet[1] === 'object') ? Object.values(datedSet[1]) : datedSet[1];
+
+              table += `<tr class="${ index % 2 === 1 ? 'bg-gray-200' : '' }">`;
+
+              if (!hideDateColumn) {
+                  table += `<td class="${tableClasses}">${date}</td>`;
+              }
+
+              if (typeof dateData === 'object') {
+                  table += dateData.map(value => `<td class="${tableClasses}">${value}</td>`).join("");
+              } else {
+                  table += `<td class="${tableClasses}">${dateData}</td>`;
+              }
+
+              table += `</tr>`;
+
+              // dateData.forEach(function(data) {
+              // console.log('... data', data);
+              // table += `<tr>`;
+              // table += `<td>${date}</td>`;
+              // table += data.map(value => `<td>${value}</td>`).join("");
+              // table += `</tr>`;
+              // })
+          });
+
+          table += '</table>';
+
+          return table;
+      }
+
+    }
+  }
+</script>
+
+<script src="//unpkg.com/alpinejs" defer></script>
+{{--<script src="https://unpkg.com/json-formatter-js"></script>--}}
+</body>
+</html>
diff --git a/resources/views/fluidbook_stats/summary.blade.php b/resources/views/fluidbook_stats/summary.blade.php
new file mode 100644 (file)
index 0000000..307b8d2
--- /dev/null
@@ -0,0 +1,91 @@
+@extends(backpack_view('blank'))
+
+@section('content')
+    <h1>{{ __('Statistiques') }}</h1>
+
+    <div class="chart-wrapper" style="position:relative; height: 30vh;">
+        <div id="chart"></div>
+    </div>
+
+    @dump($fluidbook, $visits, $pageviews, $searches)
+
+@endsection
+
+@push('after_scripts')
+    {{-- Charting library --}}
+    <script src="https://unpkg.com/chart.js@^2.9.3/dist/Chart.min.js"></script>
+    {{-- Chartisan --}}
+    <script src="https://unpkg.com/@chartisan/chartjs@^2.1.0/dist/chartisan_chartjs.umd.js"></script>
+
+    <script>
+        const bar_offset = 2; // How much to offset each overlapping bar (applied as a -/+ depending on which dataset)
+        const chart = new Chartisan({
+            el: '#chart',
+            data: {!! $chart !!},
+            hooks: new ChartisanHooks()
+                .colors(['#9ec400', '#888'])
+                .tooltip({
+                    mode: 'index',
+                    position: 'nearest',
+                    callbacks: {
+                        title: function(context) {
+                            return chart.options.data.chart.extra.tooltip_labels[context[0].label];
+                        }
+                    },
+                })
+                .datasets([{
+                    barPercentage: 0.9,
+                    // categoryPercentage: 0.8,
+                    // maxBarThickness: 50,
+                }])
+                // Complex options (ref: https://github.com/Chartisan/Chartisan/issues/7#issuecomment-774745067)
+                .custom(function({ data, merge, server }) {
+                    // data ->   Contains the current chart configuration
+                    //           data that will be passed to the chart instance.
+                    // merge ->  Contains a function that can be called to merge
+                    //           two javascript objects and returns its merge.
+                    // server -> Contains the server information in case you need
+                    //           to access the raw information provided by the server.
+                    //           This is mostly used to access the `extra` field.
+
+
+                    //console.log('data', data, 'server', server);
+
+                    let merged = merge(data, {
+                        options: {
+                            maintainAspectRatio: false,
+                            // responsive: true,
+                            scales: {
+                                xAxes: [{ stacked: true }],
+                                yAxes: [{ stacked: false }], // Don't stack y-axis: prevents datasets from adding
+                            },
+                        },
+                        plugins: [{
+                            afterUpdate: function(chart) {
+                                // Create an offset between the bars
+                                // Ref: https://stackoverflow.com/questions/62731182/chart-js-creating-a-barchart-with-overlaying-and-offset-bars
+                                chart.config.data.datasets.forEach(function(dataset, index) {
+                                    // First dataset is moved left (-), second is moved right (+)
+                                    let offset = index % 2 === 0 ? 0 - bar_offset : bar_offset;
+
+                                    dataset._meta[0].data.forEach(function(entry) {
+                                        let model = entry._model;
+                                        model.x += offset;
+                                        model.controlPointNextX = offset;
+                                        model.controlPointPreviousX = offset;
+                                    });
+                                });
+                            }
+                        }]
+                    });
+
+                    //console.log("MERGED!", merged)
+
+                    return merged;
+
+                    // The function must always return the new chart configuration.
+                })
+        })
+    </script>
+
+@endpush
index 2f11bb8c550898723972c6db97556bd0a6b1ec28..a6de6bff7918f241e529a862d18dccb641d6d2aa 100644 (file)
@@ -5,18 +5,17 @@ Route::group([
     'namespace' => 'App\Http\Controllers\Admin',
 ], function () { // custom admin routes
     try {
+        Route::crud('company', 'CompanyCrudController');
+        Route::crud('fluidbook-publication', 'FluidbookPublicationCrudController');
+        Route::crud('fluidbook-quote', 'FluidbookQuoteCrudController');
+        Route::crud('fluidbook-theme', 'FluidbookThemeCrudController');
         Route::crud('locale', 'LocaleCrudController');
         Route::crud('settings', 'SettingsCrudController');
-        Route::crud('quiztranslation', 'QuiztranslationCrudController');
-        Route::crud('fluidbook-theme', 'FluidbookThemeCrudController');
-        Route::crud('fluidbook-quote', 'FluidbookQuoteCrudController');
-        Route::crud('quizatttempt', 'QuizatttemptCrudController');
+        Route::crud('signature', 'SignatureCrudController');
         Route::crud('toolbox-translate', 'ToolboxTranslateCrudController');
-        Route::crud('fluidbook-publication', 'FluidbookPublicationCrudController');
         Route::crud('quiz', 'QuizCrudController');
-        Route::crud('signature', 'SignatureCrudController');
-        Route::crud('users', 'UsersCrudController');
-        Route::crud('company', 'CompanyCrudController');
+        Route::crud('quizatttempt', 'QuizatttemptCrudController');
+        Route::crud('quiztranslation', 'QuiztranslationCrudController');
         Route::crud('users', 'UsersCrudController');
     } catch (\Throwable $e) {
 
index 7cd6cfcf34d2a569a06a0c0321ac1267f6b8a8cc..bcb795198972d5763b51edc96ca71bdbed25e23d 100644 (file)
@@ -13,5 +13,6 @@ Route::group([
     Route::get('fluidbookthemepreview/{id}-burger.jpg', 'FluidbookThemePreviewController@previewBurger');
     Route::get('fluidbookthemepreview/{id}-menu.jpg', 'FluidbookThemePreviewController@previewMenu');
     Route::get('fluidbookthemepreview/{id}.jpg', 'FluidbookThemePreviewController@preview');
-
+    Route::get('fluidbook-stats/API', 'FluidbookStatsController@API');
+    Route::get('fluidbook-stats/{fluidbook_id}/{date?}', 'FluidbookStatsController@summary');
 });