Page MenuHomePhorge

D2458.1775222743.diff
No OneTemporary

Authored By
Unknown
Size
13 KB
Referenced Files
None
Subscribers
None

D2458.1775222743.diff

diff --git a/src/app/Http/Controllers/API/V4/Admin/StatsController.php b/src/app/Http/Controllers/API/V4/Admin/StatsController.php
--- a/src/app/Http/Controllers/API/V4/Admin/StatsController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/StatsController.php
@@ -6,6 +6,7 @@
use App\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class StatsController extends \App\Http\Controllers\Controller
@@ -18,6 +19,14 @@
public const COLOR_BLUE_DARK = '#0056b3';
public const COLOR_ORANGE = '#f1a539';
+ /** @var array List of enabled charts */
+ protected $charts = [
+ 'discounts',
+ 'income',
+ 'users',
+ 'users-all',
+ ];
+
/**
* Fetch chart data
*
@@ -33,7 +42,7 @@
$method = 'chart' . implode('', array_map('ucfirst', explode('-', $chart)));
- if (!method_exists($this, $method)) {
+ if (!in_array($chart, $this->charts) || !method_exists($this, $method)) {
return $this->errorResponse(404);
}
@@ -53,9 +62,14 @@
->join('users', 'users.id', '=', 'wallets.user_id')
->where('discount', '>', 0)
->whereNull('users.deleted_at')
- ->groupBy('discounts.discount')
- ->pluck('cnt', 'discount')
- ->all();
+ ->groupBy('discounts.discount');
+
+ $addTenantScope = function ($builder, $tenantId) {
+ return $builder->where('users.tenant_id', $tenantId);
+ };
+
+ $discounts = $this->applyTenantScope($discounts, $addTenantScope)
+ ->pluck('cnt', 'discount')->all();
$labels = array_keys($discounts);
$discounts = array_values($discounts);
@@ -119,7 +133,20 @@
->where('updated_at', '>=', $start->toDateString())
->where('status', PaymentProvider::STATUS_PAID)
->whereIn('type', [PaymentProvider::TYPE_ONEOFF, PaymentProvider::TYPE_RECURRING])
- ->groupByRaw('1')
+ ->groupByRaw('1');
+
+ $addTenantScope = function ($builder, $tenantId) {
+ $where = '`wallet_id` IN ('
+ . 'select `id` from `wallets` '
+ . 'join `users` on (`wallets`.`user_id` = `users`.`id`) '
+ . 'where `payments`.`wallet_id` = `wallets`.`id` '
+ . 'and `users`.`tenant_id` = ' . intval($tenantId)
+ . ')';
+
+ return $builder->whereRaw($where);
+ };
+
+ $payments = $this->applyTenantScope($payments, $addTenantScope)
->pluck('amount', 'period')
->map(function ($amount) {
return $amount / 100;
@@ -185,14 +212,15 @@
$created = DB::table('users')
->selectRaw("date_format(created_at, '%Y-%v') as period, count(*) as cnt")
->where('created_at', '>=', $start->toDateString())
- ->groupByRaw('1')
- ->get();
+ ->groupByRaw('1');
$deleted = DB::table('users')
->selectRaw("date_format(deleted_at, '%Y-%v') as period, count(*) as cnt")
->where('deleted_at', '>=', $start->toDateString())
- ->groupByRaw('1')
- ->get();
+ ->groupByRaw('1');
+
+ $created = $this->applyTenantScope($created)->get();
+ $deleted = $this->applyTenantScope($deleted)->get();
$empty = array_fill_keys($labels, 0);
$created = array_values(array_merge($empty, $created->pluck('cnt', 'period')->all()));
@@ -260,16 +288,16 @@
$created = DB::table('users')
->selectRaw("date_format(created_at, '%Y-%v') as period, count(*) as cnt")
->where('created_at', '>=', $start->toDateString())
- ->groupByRaw('1')
- ->get();
+ ->groupByRaw('1');
$deleted = DB::table('users')
->selectRaw("date_format(deleted_at, '%Y-%v') as period, count(*) as cnt")
->where('deleted_at', '>=', $start->toDateString())
- ->groupByRaw('1')
- ->get();
+ ->groupByRaw('1');
- $count = DB::table('users')->whereNull('deleted_at')->count();
+ $created = $this->applyTenantScope($created)->get();
+ $deleted = $this->applyTenantScope($deleted)->get();
+ $count = $this->applyTenantScope(DB::table('users')->whereNull('deleted_at'))->count();
$empty = array_fill_keys($labels, 0);
$created = array_merge($empty, $created->pluck('cnt', 'period')->all());
@@ -313,4 +341,29 @@
]
];
}
+
+ /**
+ * Add tenant scope to the queries when needed
+ *
+ * @param \Illuminate\Database\Query\Builder $query The query
+ * @param callable $addQuery Additional tenant-scope query-modifier
+ *
+ * @return \Illuminate\Database\Query\Builder
+ */
+ protected function applyTenantScope($query, $addQuery = null)
+ {
+ $user = Auth::guard()->user();
+
+ if ($user->role == 'reseller') {
+ if ($addQuery) {
+ $query = $addQuery($query, \config('app.tenant_id'));
+ } else {
+ $query = $query->withEnvTenant();
+ }
+ }
+
+ // TODO: Tenant selector for admins
+
+ return $query;
+ }
}
diff --git a/src/app/Http/Controllers/API/V4/Reseller/StatsController.php b/src/app/Http/Controllers/API/V4/Reseller/StatsController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Reseller/StatsController.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Reseller;
+
+class StatsController extends \App\Http\Controllers\API\V4\Admin\StatsController
+{
+ /** @var array List of enabled charts */
+ protected $charts = [
+ 'discounts',
+ // 'income',
+ 'users',
+ 'users-all',
+ ];
+}
diff --git a/src/resources/js/reseller/routes.js b/src/resources/js/reseller/routes.js
--- a/src/resources/js/reseller/routes.js
+++ b/src/resources/js/reseller/routes.js
@@ -3,7 +3,7 @@
import LoginComponent from '../../vue/Login'
import LogoutComponent from '../../vue/Logout'
import PageComponent from '../../vue/Page'
-//import StatsComponent from '../../vue/Reseller/Stats'
+import StatsComponent from '../../vue/Reseller/Stats'
import UserComponent from '../../vue/Admin/User'
const routes = [
@@ -33,14 +33,12 @@
name: 'logout',
component: LogoutComponent
},
-/*
{
path: '/stats',
name: 'stats',
component: StatsComponent,
meta: { requiresAuth: true }
},
-*/
{
path: '/user/:user',
name: 'user',
diff --git a/src/resources/vue/Admin/Stats.vue b/src/resources/vue/Admin/Stats.vue
--- a/src/resources/vue/Admin/Stats.vue
+++ b/src/resources/vue/Admin/Stats.vue
@@ -9,11 +9,12 @@
export default {
data() {
return {
- charts: {}
+ charts: {},
+ chartTypes: ['users', 'users-all', 'income', 'discounts']
}
},
mounted() {
- ['users', 'users-all', 'income', 'discounts'].forEach(chart => this.loadChart(chart))
+ this.chartTypes.forEach(chart => this.loadChart(chart))
},
methods: {
drawChart(name, data) {
diff --git a/src/resources/vue/Reseller/Dashboard.vue b/src/resources/vue/Reseller/Dashboard.vue
--- a/src/resources/vue/Reseller/Dashboard.vue
+++ b/src/resources/vue/Reseller/Dashboard.vue
@@ -2,12 +2,19 @@
<div class="container" dusk="dashboard-component">
<user-search></user-search>
<div id="dashboard-nav" class="mt-3">
+ <router-link class="card link-stats" :to="{ name: 'stats' }">
+ <svg-icon icon="chart-line"></svg-icon><span class="name">Stats</span>
+ </router-link>
</div>
</div>
</template>
<script>
import UserSearch from '../Widgets/UserSearch'
+ import { library } from '@fortawesome/fontawesome-svg-core'
+ import { faChartLine } from '@fortawesome/free-solid-svg-icons'
+
+ library.add(faChartLine)
export default {
components: {
diff --git a/src/resources/vue/Reseller/Stats.vue b/src/resources/vue/Reseller/Stats.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Reseller/Stats.vue
@@ -0,0 +1,18 @@
+<template>
+ <div id="stats-container" class="container">
+ </div>
+</template>
+
+<script>
+ import Stats from '../Admin/Stats'
+
+ export default {
+ mixins: [Stats],
+ data() {
+ return {
+// charts: {},
+ chartTypes: ['users', 'users-all', 'discounts']
+ }
+ }
+ }
+</script>
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -180,5 +180,7 @@
Route::post('wallets/{id}/one-off', 'API\V4\Reseller\WalletsController@oneOff');
Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions');
Route::apiResource('discounts', API\V4\Reseller\DiscountsController::class);
+
+ Route::get('stats/chart/{chart}', 'API\V4\Reseller\StatsController@chart');
}
);
diff --git a/src/tests/Browser/Reseller/StatsTest.php b/src/tests/Browser/Reseller/StatsTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Reseller/StatsTest.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Tests\Browser\Reseller;
+
+use Tests\Browser;
+use Tests\Browser\Pages\Admin\Stats;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\TestCaseDusk;
+
+class StatsTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+ }
+
+ /**
+ * Test Stats page (unauthenticated)
+ */
+ public function testStatsUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/stats')->on(new Home());
+ });
+ }
+
+ /**
+ * Test Stats page
+ */
+ public function testStats(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ ->submitLogon('reseller@kolabnow.com', 'reseller', true)
+ ->on(new Dashboard())
+ ->assertSeeIn('@links .link-stats', 'Stats')
+ ->click('@links .link-stats')
+ ->on(new Stats())
+ ->assertElementsCount('@container > div', 3)
+ ->waitFor('@container #chart-users svg')
+ ->assertSeeIn('@container #chart-users svg .title', 'Users - last 8 weeks')
+ ->waitFor('@container #chart-users-all svg')
+ ->assertSeeIn('@container #chart-users-all svg .title', 'All Users - last year')
+ ->waitFor('@container #chart-discounts svg')
+ ->assertSeeIn('@container #chart-discounts svg .title', 'Discounts');
+ });
+ }
+}
diff --git a/src/tests/Feature/Controller/Reseller/StatsTest.php b/src/tests/Feature/Controller/Reseller/StatsTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/Reseller/StatsTest.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Tests\Feature\Controller\Reseller;
+
+use Tests\TestCase;
+
+class StatsTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ /**
+ * Test charts (GET /api/v4/stats/chart/<chart>)
+ */
+ public function testChart(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller = $this->getTestUser('reseller@kolabnow.com');
+
+ // Unauth access
+ $response = $this->get("api/v4/stats/chart/discounts");
+ $response->assertStatus(401);
+
+ // Normal user
+ $response = $this->actingAs($user)->get("api/v4/stats/chart/discounts");
+ $response->assertStatus(403);
+
+ // Admin user
+ $response = $this->actingAs($admin)->get("api/v4/stats/chart/discounts");
+ $response->assertStatus(403);
+
+ // Unknown chart name
+ $response = $this->actingAs($reseller)->get("api/v4/stats/chart/unknown");
+ $response->assertStatus(404);
+
+ // 'income' chart
+ $response = $this->actingAs($reseller)->get("api/v4/stats/chart/income");
+ $response->assertStatus(404);
+
+ // 'discounts' chart
+ $response = $this->actingAs($reseller)->get("api/v4/stats/chart/discounts");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('Discounts', $json['title']);
+ $this->assertSame('donut', $json['type']);
+ $this->assertSame([], $json['data']['labels']);
+ $this->assertSame([['values' => []]], $json['data']['datasets']);
+
+ // 'users' chart
+ $response = $this->actingAs($reseller)->get("api/v4/stats/chart/users");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('Users - last 8 weeks', $json['title']);
+ $this->assertCount(8, $json['data']['labels']);
+ $this->assertSame(date('Y-W'), $json['data']['labels'][7]);
+ $this->assertCount(2, $json['data']['datasets']);
+ $this->assertSame('Created', $json['data']['datasets'][0]['name']);
+ $this->assertSame('Deleted', $json['data']['datasets'][1]['name']);
+
+ // 'users-all' chart
+ $response = $this->actingAs($reseller)->get("api/v4/stats/chart/users-all");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('All Users - last year', $json['title']);
+ $this->assertCount(54, $json['data']['labels']);
+ $this->assertCount(1, $json['data']['datasets']);
+ }
+}

File Metadata

Mime Type
text/plain
Expires
Fri, Apr 3, 1:25 PM (4 d, 3 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18824220
Default Alt Text
D2458.1775222743.diff (13 KB)

Event Timeline