Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117764252
D2458.1775222743.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
13 KB
Referenced Files
None
Subscribers
None
D2458.1775222743.diff
View Options
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
Details
Attached
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)
Attached To
Mode
D2458: Reseller Stats UI
Attached
Detach File
Event Timeline