Add basic Statistics support for Messages and Related devices

This commit is contained in:
Timothée Jaussoin 2023-07-20 14:56:42 +00:00
parent 16a26d1576
commit 0729718ccf
22 changed files with 2139 additions and 1840 deletions

View file

@ -128,8 +128,8 @@ function resolveDomain(Request $request): string
&& $request->user()
&& $request->user()->admin
&& config('app.admins_manage_multi_domains')
? $request->get('domain')
: config('app.sip_domain');
? $request->get('domain')
: config('app.sip_domain');
}
function captchaConfigured(): bool
@ -156,3 +156,25 @@ function resolveUserContacts(Request $request)
);
})->select($selected);
}
/**
* Validate date string to ISO8601
* From: https://github.com/penance316/laravel-iso8601-validator/blob/master/src/IsoDateValidator.php
*
* @param $attribute
* @param $value
* @param $parameters
* @param $validator
*
* @return bool
*/
function validateIsoDate($attribute, $value, $parameters, $validator): bool
{
$regex = (is_array($parameters) && in_array('utc', $parameters))
? '/^(\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)))?$/'
// 2012-04-23T18:25:43.511Z
// Regex from https://www.myintervals.com/blog/2009/05/20/iso-8601-date-validation-that-doesnt-suck/
: '/^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/';
return (bool)preg_match($regex, $value);
}

View file

@ -53,10 +53,10 @@ class AccountController extends Controller
public function store(CreateAccountRequest $request)
{
$request->validate(['g-recaptcha-response' => captchaConfigured() ? 'required|captcha': '']);
$account = (new AccountService(api: false))->store($request);
$request->validate(['g-recaptcha-response' => captchaConfigured() ? 'required|captcha': '']);
Auth::login($account);
if ($request->has('phone')) {

View file

@ -77,7 +77,7 @@ class AccountController extends Controller
$account->created_at = Carbon::now();
$account->user_agent = config('app.name');
$account->dtmf_protocol = $request->get('dtmf_protocol');
$account->activated = $request->has('activated');
$account->activated = $request->get('activated') == 'true';
$account->save();
$account->phone = $request->get('phone');
@ -112,7 +112,7 @@ class AccountController extends Controller
$account->email = $request->get('email');
$account->display_name = $request->get('display_name');
$account->dtmf_protocol = $request->get('dtmf_protocol');
$account->activated = $request->has('activated');
$account->activated = $request->get('activated') == 'true';
$account->save();
$account->phone = $request->get('phone');
@ -180,7 +180,7 @@ class AccountController extends Controller
$account->contactsLists()->detach([$request->get('contacts_list_id')]);
$account->contactsLists()->attach([$request->get('contacts_list_id')]);
return redirect()->route('admin.account.edit', $id);
return redirect()->route('admin.account.edit', $id)->withFragment('#contacts_lists');
}
public function contactsListRemove(Request $request, int $id)
@ -188,6 +188,6 @@ class AccountController extends Controller
$account = Account::findOrFail($id);
$account->contactsLists()->detach([$request->get('contacts_list_id')]);
return redirect()->route('admin.account.edit', $id);
return redirect()->route('admin.account.edit', $id)->withFragment('#contacts_lists');
}
}

View file

@ -36,7 +36,7 @@ class ContactsListContactController extends Controller
}
return view('admin.contacts_list.contacts.add', [
'contacts_list' => ContactsList::firstOrFail($contactsListId),
'contacts_list' => ContactsList::findOrFail($contactsListId),
'params' => [
'search' => $request->get('search'),
'contacts_list_id' => $contactsListId,
@ -61,7 +61,7 @@ class ContactsListContactController extends Controller
'contacts_ids' => 'required|exists:accounts,id'
]);
$contactsList = ContactsList::firstOrFail($contactsListId);
$contactsList = ContactsList::findOrFail($contactsListId);
$contactsList->contacts()->detach($request->get('contacts_ids')); // Just in case
$contactsList->contacts()->attach($request->get('contacts_ids'));

View file

@ -34,8 +34,7 @@ class AccountTypeController extends Controller
public function get(int $accountTypeId)
{
return AccountType::where('id', $accountTypeId)
->firstOrFail();
return AccountType::findOrFail($accountTypeId);
}
public function store(Request $request)
@ -57,8 +56,7 @@ class AccountTypeController extends Controller
'key' => ['alpha_dash', new NoUppercase],
]);
$accountType = AccountType::where('id', $accountTypeId)
->firstOrFail();
$accountType = AccountType::findOrFail($accountTypeId);
$accountType->key = $request->get('key');
$accountType->save();

View file

@ -16,8 +16,7 @@ class ContactsListController extends Controller
public function get(int $contactsListId)
{
return ContactsList::where('id', $contactsListId)
->firstOrFail();
return ContactsList::findOrFail($contactsListId);
}
public function store(Request $request)
@ -42,8 +41,7 @@ class ContactsListController extends Controller
'description' => ['required']
]);
$contactsList = ContactsList::where('id', $contactsListId)
->firstOrFail();
$contactsList = ContactsList::findOrFail($contactsListId);
$contactsList->title = $request->get('title');
$contactsList->description = $request->get('description');
$contactsList->save();

View file

@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\StatisticsMessage;
use App\StatisticsMessageDevice;
use Illuminate\Http\Request;
class StatisticsMessageController extends Controller
{
public function store(Request $request)
{
$request->validate([
'id' => 'required|string|max:64',
'from' => 'required|string|max:256',
'sent_at' => 'required|iso_date',
'encrypted' => 'required|boolean',
'conference_id' => 'string|nullable',
]);
$statisticsMessage = new StatisticsMessage;
$statisticsMessage->id = $request->get('id');
$statisticsMessage->from = $request->get('from');
$statisticsMessage->sent_at = $request->get('sent_at');
$statisticsMessage->encrypted = $request->get('encrypted');
//$statisticsMessage->conference_id = $request->get('conference_id');
try {
return $statisticsMessage->saveOrFail();
} catch (\Throwable $th) {
abort(422);
}
}
public function storeDevice(Request $request, string $messageId, string $to, string $deviceId)
{
$request->validate([
// We don't validate the message_id to avoid a specific DB request, the foreign key constraint is taking care of it
'last_status' => 'required|integer',
'received_at' => 'required|iso_date'
]);
return StatisticsMessageDevice::updateOrCreate(
['message_id' => $messageId, 'to' => $to, 'device_id' => $deviceId],
['last_status' => $request->get('last_status'), 'received_at' => $request->get('received_at')]
);
}
}

View file

@ -2,6 +2,7 @@
namespace App\Providers;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@ -13,6 +14,8 @@ class AppServiceProvider extends ServiceProvider
public function boot()
{
Validator::extend('iso_date', 'validateIsoDate');
if (!empty(config('app.url'))) {
// Add following lines to force laravel to use APP_URL as root url for the app.
$strBaseURL = $this->app['url'];

View file

@ -0,0 +1,31 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2020 Belledonne Communications SARL, All rights reserved.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace App;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class StatisticsMessage extends Model
{
use HasFactory;
public $incrementing = false;
protected $keyType = 'string';
}

View file

@ -0,0 +1,35 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2020 Belledonne Communications SARL, All rights reserved.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace App;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class StatisticsMessageDevice extends Model
{
use HasFactory;
protected $fillable = ['message_id', 'to', 'device_id', 'last_status', 'received_at'];
public function message()
{
return $this->hasOne(StatisticsMessage::class, 'id', 'message_id');
}
}

View file

@ -18,6 +18,7 @@
"namoshek/laravel-redis-sentinel": "^0.1.2",
"ovh/ovh": "^3.0",
"parsedown/laravel": "^1.2",
"phpunit/phpunit": "^9.6",
"react/socket": "^1.10",
"respect/validation": "^2.2"
},
@ -26,7 +27,6 @@
"mockery/mockery": "^1.5",
"nunomaduro/collision": "^6.1",
"phpmd/phpmd": "^2.13",
"phpunit/phpunit": "^9.0",
"squizlabs/php_codesniffer": "^3.7"
},
"config": {

3537
flexiapi/composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('statistics_messages', function (Blueprint $table) {
$table->string('id', 64)->unique();
$table->string('from', 256)->index();
$table->dateTime('sent_at');
$table->boolean('encrypted')->default(false);
$table->string('conference_id')->nullable();
$table->timestamps();
});
Schema::create('statistics_message_devices', function (Blueprint $table) {
$table->id();
$table->string('message_id', 64);
$table->string('to', 256)->index();
$table->string('device_id', 64);
$table->integer('last_status');
$table->dateTime('received_at');
$table->timestamps();
$table->foreign('message_id')->references('id')->on('statistics_messages');
$table->unique(['message_id', 'to', 'device_id']);
});
}
public function down()
{
Schema::dropIfExists('statistics_message_devices');
Schema::dropIfExists('statistics_messages');
}
};

View file

@ -629,9 +629,6 @@ table tr.empty td:before {
.chip {
display: inline-block;
background-color: var(--grey-1);
border: 1px solid var(--grey-2);
border-radius: 3rem;
line-height: 2.5rem;
padding: 0 1rem;
}
@ -701,4 +698,9 @@ select.list_toggle {
padding: 1rem;
margin-bottom: 1rem;
overflow: hidden;
}
.disabled {
opacity: 0.5;
pointer-events: none;
}

View file

@ -124,9 +124,9 @@ form label {
font-size: 1.5rem;
}
form input[type="radio"]~label:after,
form input[required]+label:after {
content: '*';
margin-left: 0.5rem;
}
form input:not([type=checkbox]) ~ label,
@ -247,13 +247,13 @@ form div textarea:active+label {
color: var(--main-5);
}
form div input:invalid,
form div textarea:invalid {
form div input:invalid:not(:placeholder-shown),
form div textarea:invalid:not(:placeholder-shown) {
border-color: var(--danger-6);
color: var(--danger-5);
}
form div input:invalid+label,
form div textarea:invalid+label {
form div input:invalid:not(:placeholder-shown)+label,
form div textarea:invalid:not(:placeholder-shown)+label {
color: var(--danger-5);
}

View file

@ -33,6 +33,7 @@
<div>
{!! Form::email('email_confirmation', old('email_confirm'), ['placeholder' => 'bob@example.net', 'required']) !!}
{!! Form::label('email_confirmation', 'Confirm email') !!}
@include('parts.errors', ['name' => 'email_confirmation'])
</div>
<div>
@ -43,6 +44,7 @@
<div>
{!! Form::password('password_confirmation', ['required']) !!}
{!! Form::label('password_confirmation', 'Confirm password') !!}
@include('parts.errors', ['name' => 'password_confirmation'])
</div>
@if (!empty(config('app.newsletter_registration_address')))

View file

@ -23,6 +23,13 @@
{!! Form::text('username', $domain, ['disabled']) !!}
</div>
<div>
{!! Form::text('phone', old('phone'), ['placeholder' => '+123456789', 'required']) !!}
{!! Form::label('phone', 'Phone number') !!}
@include('parts.errors', ['name' => 'phone'])
</div>
<div></div>
<div>
{!! Form::password('password', ['required']) !!}
{!! Form::label('password', 'Password') !!}
@ -33,13 +40,6 @@
{!! Form::label('password_confirmation', 'Confirm password') !!}
</div>
<div clas="large">
{!! Form::text('phone', old('phone'), ['placeholder' => '+123456789', 'required']) !!}
{!! Form::label('phone', 'Phone number') !!}
@include('parts.errors', ['name' => 'phone'])
</div>
@include('parts.terms')
<div class="large">

View file

@ -41,13 +41,13 @@
<div></div>
<div>
<input placeholder="Password" name="password" type="password" value="" autocomplete="off">
<input placeholder="Password" name="password" type="password" value="" autocomplete="new-password">
<label for="password">{{ $account->id ? 'Password (fill to change)' : 'Password' }}</label>
@include('parts.errors', ['name' => 'password'])
</div>
<div>
<input placeholder="Password" name="password_confirmation" type="password" value="">
<input placeholder="Password" name="password_confirmation" type="password" value="" autocomplete="off">
<label for="password_confirmation">Confirm password</label>
@include('parts.errors', ['name' => 'password_confirmation'])
</div>
@ -68,8 +68,11 @@
<h2>Other information</h2>
<div>
<input name="activated" type="checkbox" @if ($account->activated) checked @endif>
<label>Activated</label>
<input name="activated" value="true" type="radio" @if ($account->activated) checked @endif>
<p>Enabled</p>
<input name="activated" value="false" type="radio" @if (!$account->activated) checked @endif>
<p>Disabled</p>
<label>Status</label>
</div>
<div>
@ -101,7 +104,30 @@
<hr class="large">
@if ($account->id)
<h2>Contacts Lists</h2>
<h2 id="contacts_lists">Contacts Lists</h2>
@if ($contacts_lists->isNotEmpty())
<form method="POST" action="{{ route('admin.account.contacts_lists.attach', $account->id) }}"
accept-charset="UTF-8">
@csrf
@method('post')
<div class="select">
<select name="contacts_list_id" onchange="this.form.submit()">
<option>
Select a contacts list
</option>
@foreach ($contacts_lists as $contacts_list)
<option value="{{ $contacts_list->id }}">
{{ $contacts_list->title }}
</option>
@endforeach
</select>
<label for="contacts_list_id">Add a Contacts lists</label>
</div>
</form>
<br />
@endif
@foreach ($account->contactsLists as $contactsList)
<p class="chip">
@ -114,30 +140,6 @@
</p>
@endforeach
<br />
@if ($contacts_lists->isNotEmpty())
<form method="POST" action="{{ route('admin.account.contacts_lists.attach', $account->id) }}"
accept-charset="UTF-8">
@csrf
@method('post')
<div class="select">
<select name="contacts_list_id">
@foreach ($contacts_lists as $contacts_list)
<option value="{{ $contacts_list->id }}">
{{ $contacts_list->title }}
</option>
@endforeach
</select>
<label for="contacts_list_id">Add a Contacts lists</label>
</div>
<div>
<input class="btn btn-tertiary" type="submit" value="Add">
</div>
</form>
@endif
<h2>Individual contacts</h2>
@foreach ($account->contacts as $contact)

View file

@ -8,6 +8,7 @@
<i class="material-icons">delete</i>
Delete
</a>
<input form="create_edit_contacts_list" class="btn" type="submit" value="{{ $contacts_list->id ? 'Update' : 'Create' }}">
@else
<h1><i class="material-icons">account_box</i> Create a Contacts List</h1>
@endif
@ -18,6 +19,7 @@
@endif
<form method="POST"
id="create_edit_contacts_list"
action="{{ $contacts_list->id ? route('admin.contacts_lists.update', $contacts_list->id) : route('admin.contacts_lists.store') }}"
accept-charset="UTF-8">
@csrf
@ -33,14 +35,10 @@
<label for="description">Description</label>
@include('parts.errors', ['name' => 'description'])
</div>
<div class="large">
<input class="btn oppose" type="submit" value="{{ $contacts_list->id ? 'Update' : 'Create' }}">
</div>
</form>
@if ($contacts_list->id)
<hr class="clear">
<hr>
<header>
<p class="oppose">
@ -55,7 +53,7 @@
<select name="contacts_ids[]" class="list_toggle" data-list-id="d{{ $contacts_list->id }}"></select>
<input type="hidden" name="contacts_list_id" value="{{ $contacts_list->id }}">
<input class="btn btn-tertiary" type="submit" value="Remove" onclick="Utils.clearStorageList('d{{ $contacts_list->id }}')">
<input class="btn btn-tertiary" type="submit" value="Remove contacts" onclick="Utils.clearStorageList('d{{ $contacts_list->id }}')">
</form>
<a class="btn btn-secondary" href="{{ route('admin.contacts_lists.contacts.add', $contacts_list->id) }}">

View file

@ -22,6 +22,7 @@ use App\Http\Controllers\Api\Admin\AccountContactController;
use App\Http\Controllers\Api\Admin\AccountController as AdminAccountController;
use App\Http\Controllers\Api\Admin\AccountTypeController;
use App\Http\Controllers\Api\Admin\ContactsListController;
use App\Http\Controllers\Api\StatisticsMessageController;
use Illuminate\Http\Request;
Route::get('/', 'Api\ApiController@documentation')->name('api');
@ -124,5 +125,10 @@ Route::group(['middleware' => ['auth.digest_or_key']], function () {
Route::post('{id}/contacts/{contacts_id}', 'contactAdd');
Route::delete('{id}/contacts/{contacts_id}', 'contactRemove');
});
Route::prefix('statistics/messages')->controller(StatisticsMessageController::class)->group(function () {
Route::post('/', 'store');
Route::patch('{message_id}/to/{to}/devices/{device_id}', 'storeDevice');
});
});
});

View file

@ -0,0 +1,113 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2020 Belledonne Communications SARL, All rights reserved.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Tests\Feature;
use App\Admin;
use App\StatisticsMessageDevice;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
class ApiStatisticsMessagesTest extends TestCase
{
use WithFaker, RefreshDatabase;
protected $route = '/api/statistics/messages';
public function testMessages()
{
$admin = Admin::factory()->create();
$admin->account->generateApiKey();
$id = '1234';
$this->keyAuthenticated($admin->account)
->json('POST', $this->route, [
'id' => $id,
'from' => $this->faker->email(),
'sent_at' => $this->faker->iso8601(),
'encrypted' => false
])
->assertStatus(200);
$this->assertDatabaseHas('statistics_messages', [
'id' => $id
]);
$this->keyAuthenticated($admin->account)
->json('POST', $this->route, [
'id' => $id,
'from' => $this->faker->email(),
'sent_at' => $this->faker->iso8601(),
'encrypted' => false
])
->assertStatus(422);
$this->keyAuthenticated($admin->account)
->json('POST', $this->route, [
'id' => $id,
'from' => $this->faker->email(),
'sent_at' => 'bad_date',
'encrypted' => false
])
->assertJsonValidationErrors(['sent_at']);
// Patch previous message with devices
$to = $this->faker->email();
$device = $this->faker->uuid();
$receivedAt = $this->faker->iso8601();
$lastStatus = 200;
$newReceivedAt = $this->faker->iso8601();
$newLastStatus = 201;
$this->keyAuthenticated($admin->account)
->json('PATCH', $this->route . '/' . $id . '/to/' . $to . ' /devices/' . $device, [
'last_status' => $lastStatus,
'received_at' => $receivedAt
])
->assertStatus(201);
$this->keyAuthenticated($admin->account)
->json('PATCH', $this->route . '/' . $id . '/to/' . $to . ' /devices/' . $device, [
'last_status' => $newLastStatus,
'received_at' => $newReceivedAt
])
->assertStatus(200);
$this->assertSame(1, StatisticsMessageDevice::count());
$this->assertDatabaseHas('statistics_message_devices', [
'message_id' => $id,
'received_at' => $newReceivedAt,
'last_status' => $newLastStatus
]);
$this->keyAuthenticated($admin->account)
->json('PATCH', $this->route . '/' . $id . '/to/' . $this->faker->email() . ' /devices/' . $this->faker->uuid(), [
'last_status' => $newLastStatus,
'received_at' => $newReceivedAt
])
->assertStatus(201);
$this->assertSame(2, StatisticsMessageDevice::count());
}
}

View file

@ -84,12 +84,6 @@ cp httpd/flexisip-account-manager.conf "$RPM_BUILD_ROOT%{apache_conf_path}/"
mkdir -p %{var_dir}/flexiapi/bootstrap/cache
mkdir -p %{var_dir}/log
#touch %{var_dir}/log/account-manager.log
#chown %{web_user}:%{web_user} %{var_dir}/log/account-manager.log
#%if %{without deb}
# chcon -t httpd_sys_rw_content_t %{var_dir}/log/account-manager.log
#%endif
%if %{without deb}
setsebool -P httpd_can_network_connect_db on