Add some basic subscriptions statistics

Move the statistics to a specific Library
Show some subscriptions charts in the admin panel
Inject the browser user agent to the database if available
Split statistics in different view (day/week/month)
Install cron scripts
Update the dependencies
This commit is contained in:
Timothée Jaussoin 2021-09-23 17:25:09 +02:00
parent e832eae21e
commit 4f11deeaf9
26 changed files with 601 additions and 7 deletions

View file

@ -28,6 +28,7 @@ package-common:
# General
cp README.md $(OUTPUT_DIR)/flexisip-account-manager/
cp -R httpd/ $(OUTPUT_DIR)/flexisip-account-manager/
cp -R cron/ $(OUTPUT_DIR)/flexisip-account-manager/
cp flexisip-account-manager.spec $(OUTPUT_DIR)/rpmbuild/SPECS/
tar cvf flexisip-account-manager.tar.gz -C $(OUTPUT_DIR) flexisip-account-manager

6
cron/flexiapi.debian Normal file
View file

@ -0,0 +1,6 @@
#!/bin/sh
cd /opt/belledonne-communications/share/flexisip-account-manager/flexiapi/
sudo -su www-data && php artisan digest:expired-nonces-clear 60
sudo -su www-data && php artisan accounts:clear-accounts-tombstones 7 --apply
sudo -su www-data && php artisan accounts:clear-unconfirmed 30 --apply

6
cron/flexiapi.redhat Normal file
View file

@ -0,0 +1,6 @@
#!/bin/sh
cd /opt/belledonne-communications/share/flexisip-account-manager/flexiapi/
scl enable rh-php73 "php artisan digest:expired-nonces-clear 60"
scl enable rh-php73 "php artisan accounts:clear-accounts-tombstones 7 --apply"
scl enable rh-php73 "php artisan accounts:clear-unconfirmed 30 --apply"

View file

@ -58,6 +58,12 @@ class Utils
return mt_rand(1000, 9999);
}
public static function percent($value, $max)
{
if ($max == 0) $max = 1;
return round(($value*100)/$max, 2);
}
public static function markdownDocumentationView($view)
{
$environment = Environment::createCommonMarkEnvironment();

View file

@ -91,7 +91,7 @@ class RegisterController extends Controller
$account->domain = config('app.sip_domain');
$account->ip_address = $request->ip();
$account->creation_time = Carbon::now();
$account->user_agent = config('app.name');
$account->user_agent = $request->header('User-Agent') ?? config('app.name');
$account->save();
$account->confirmation_key = Str::random($this->emailCodeSize);
@ -143,7 +143,7 @@ class RegisterController extends Controller
$account->domain = config('app.sip_domain');
$account->ip_address = $request->ip();
$account->creation_time = Carbon::now();
$account->user_agent = config('app.name');
$account->user_agent = $request->header('User-Agent') ?? config('app.name');
$account->save();
$alias = new Alias;

View file

@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Libraries\StatisticsCruncher;
class StatisticsController extends Controller
{
public function showDay(Request $request)
{
$day = StatisticsCruncher::day();
$maxDay = 0;
foreach ($day as $hour) {
if ($maxDay < $hour['all']) $maxDay = $hour['all'];
}
return view('admin.statistics.show_day', [
'day' => $day,
'max_day' => $maxDay,
]);
}
public function showWeek(Request $request)
{
$week = StatisticsCruncher::week();
$maxWeek = 0;
foreach ($week as $day) {
if ($maxWeek < $day['all']) $maxWeek = $day['all'];
}
return view('admin.statistics.show_week', [
'week' => $week,
'max_week' => $maxWeek,
]);
}
public function showMonth(Request $request)
{
$month = StatisticsCruncher::month();
$maxMonth = 0;
foreach ($month as $day) {
if ($maxMonth < $day['all']) $maxMonth = $day['all'];
}
return view('admin.statistics.show_month', [
'month' => $month,
'max_month' => $maxMonth,
]);
}
}

View file

@ -124,7 +124,7 @@ class AccountController extends Controller
$account->domain = $request->has('domain') && config('app.everyone_is_admin')
? $request->get('domain')
: config('app.sip_domain');
$account->user_agent = config('app.name');
$account->user_agent = $request->header('User-Agent') ?? config('app.name');
if (!$request->has('activated') || !(bool)$request->get('activated')) {
$account->confirmation_key = Str::random(WebAuthenticateController::$emailCodeSize);

View file

@ -0,0 +1,43 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2021 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\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Libraries\StatisticsCruncher;
class StatisticController extends Controller
{
public function month(Request $request)
{
return StatisticsCruncher::month();
}
public function week(Request $request)
{
return StatisticsCruncher::week();
}
public function day(Request $request)
{
return StatisticsCruncher::day();
}
}

View file

@ -0,0 +1,197 @@
<?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\Libraries;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
use Carbon\CarbonPeriod;
use Carbon\CarbonInterval;
use App\Account;
class StatisticsCruncher
{
public static function month()
{
$data = self::getAccountFrom(Carbon::now()->subMonth())
->get(array(
DB::raw("date_format(creation_time,'%Y-%m-%d') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
$dataAliases = self::getAccountFrom(Carbon::now()->subMonth())
->whereIn('id', function($query) {
$query->select('account_id')
->from('aliases');
})
->get(array(
DB::raw("date_format(creation_time,'%Y-%m-%d') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
$dataActivated = self::getAccountFrom(Carbon::now()->subMonth())
->where('activated', true)
->get(array(
DB::raw("date_format(creation_time,'%Y-%m-%d') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
$dataAliasesActivated = self::getAccountFrom(Carbon::now()->subMonth())
->where('activated', true)
->whereIn('id', function($query) {
$query->select('account_id')
->from('aliases');
})
->get(array(
DB::raw("date_format(creation_time,'%Y-%m-%d') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
return self::compileStatistics(
collect(CarbonPeriod::create(Carbon::now()->subMonth(), Carbon::now()))->map->format('Y-m-d'),
$data,
$dataAliases,
$dataActivated,
$dataAliasesActivated
);
}
public static function week()
{
$data = self::getAccountFrom(Carbon::now()->subWeek())
->get(array(
DB::raw("date_format(creation_time,'%Y-%m-%d') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
$dataAliases = self::getAccountFrom(Carbon::now()->subWeek())
->whereIn('id', function($query) {
$query->select('account_id')
->from('aliases');
})
->get(array(
DB::raw("date_format(creation_time,'%Y-%m-%d') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
$dataActivated = self::getAccountFrom(Carbon::now()->subWeek())
->where('activated', true)
->get(array(
DB::raw("date_format(creation_time,'%Y-%m-%d') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
$dataAliasesActivated = self::getAccountFrom(Carbon::now()->subWeek())
->where('activated', true)
->whereIn('id', function($query) {
$query->select('account_id')
->from('aliases');
})
->get(array(
DB::raw("date_format(creation_time,'%Y-%m-%d') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
return self::compileStatistics(
collect(CarbonPeriod::create(Carbon::now()->subWeek(), Carbon::now()))->map->format('Y-m-d'),
$data,
$dataAliases,
$dataActivated,
$dataAliasesActivated
);
}
public static function day()
{
$data = self::getAccountFrom(Carbon::now()->subDay())
->get(array(
DB::raw("date_format(creation_time,'%Y-%m-%d %H') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
$dataAliases = self::getAccountFrom(Carbon::now()->subDay())
->whereIn('id', function($query) {
$query->select('account_id')
->from('aliases');
})
->get(array(
DB::raw("date_format(creation_time,'%Y-%m-%d %H') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
$dataActivated = self::getAccountFrom(Carbon::now()->subDay())
->where('activated', true)
->get(array(
DB::raw("date_format(creation_time,'%Y-%m-%d %H') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
$dataAliasesActivated = self::getAccountFrom(Carbon::now()->subDay())
->where('activated', true)
->whereIn('id', function($query) {
$query->select('account_id')
->from('aliases');
})
->get(array(
DB::raw("date_format(creation_time,'%Y-%m-%d %H') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
return self::compileStatistics(
collect(CarbonInterval::hour()->toPeriod(Carbon::now()->subDay(), Carbon::now()))->map->format('Y-m-d H'),
$data,
$dataAliases,
$dataActivated,
$dataAliasesActivated
);
}
private static function getAccountFrom($date)
{
return Account::where('creation_time', '>=', $date)
->groupBy('moment')
->orderBy('moment', 'DESC')
->setEagerLoads([]);
}
private static function compileStatistics($period, $data, $dataAliases, $dataActivated, $dataAliasesActivated)
{
$stats = [];
foreach ($period as $moment) {
$all = $data[$moment] ?? 0;
$aliases = $dataAliases[$moment] ?? 0;
$activated = $dataActivated[$moment] ?? 0;
$activatedAliases = $dataAliasesActivated[$moment] ?? 0;
$stats[$moment] = [
'all' => $all,
'phone' => $aliases,
'email' => $all - $aliases,
'activated_phone' => $activatedAliases,
'activated_email' => $activated - $activatedAliases
];
}
return $stats;
}
}

Binary file not shown.

View file

@ -257,9 +257,9 @@ return [
'Storage' => Illuminate\Support\Facades\Storage::class,
'Str' => Illuminate\Support\Str::class,
'URL' => Illuminate\Support\Facades\URL::class,
'Utils' => App\Helpers\Utils::class,
'Validator' => Illuminate\Support\Facades\Validator::class,
'View' => Illuminate\Support\Facades\View::class,
],
];

View file

@ -1,4 +1,21 @@
<?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/>.
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;

View file

@ -1,4 +1,21 @@
<?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/>.
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;

77
flexiapi/public/css/charts.css vendored Normal file
View file

@ -0,0 +1,77 @@
.legend {
margin-bottom: 1rem;
}
.legend div:before {
content: '';
display: inline-block;
width: 1rem;
height: 1rem;
vertical-align: middle;
background-color: gray;
margin: 0 0.5rem;
opacity: 0.5;
}
.columns {
display: flex;
height: 600px;
align-items: stretch;
border: 1px solid #CCC;
margin-bottom: 10rem;
}
.columns .column {
flex: 1;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-end;
position: relative;
}
.columns .column .bar {
border-right: 1px solid white;
font-size: 0.5rem;
text-align: center;
position: relative;
opacity: 0.5;
}
.columns .column::after {
display: block;
content: attr(data-value);
position: absolute;
top: calc(100% + 1rem);
writing-mode: vertical-rl;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.columns .column .bar.activated,
.legend div.activated:before {
opacity: 1;
}
.columns .column .bar.first,
.legend .first:before {
background-color: salmon;
}
.columns .column .bar.second,
.legend .second:before {
background-color: seagreen;
}
.columns .column .bar::after {
display: block;
content: attr(data-value);
color: white;
position: absolute;
font-size: 0.75rem;
line-height: 1rem;
left: 0rem;
top: calc(50% - 0.5rem);
width: 100%;
}

View file

@ -84,6 +84,14 @@ Finally the account page allows you to provision the account, using a QR Code or
The provisioning link can be generated and refreshed from this page as well.
### Create and edit an account
Administrators can create and edit accounts directly from the admin panel.
### Delete an account
The deletion of an account is definitive, all the database related data (password, aliases…) will be destroyed after the deletion.
## Statistics
The statistics panel show registrations statistics based on their type (mobile and email based registration) and their activations states.

View file

@ -54,6 +54,12 @@
</div>
<p class="mb-1">Manage the Flexisip accounts</p>
</a>
<a href="{{ route('admin.statistics.show.day') }}" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Statistics</h5>
</div>
<p class="mb-1">Show some registration statistics</p>
</a>
</div>
<h5>API Key</h5>

View file

@ -0,0 +1,12 @@
<div class="bar first" style="flex-basis: {{ Utils::percent($slice['phone'] - $slice['activated_phone'], $max) }}%"
data-value="{{ $slice['phone'] - $slice['activated_phone'] }}"
title="Unactivated phone: {{ $slice['phone'] - $slice['activated_phone'] }}"></div>
<div class="bar first activated" style="flex-basis: {{ Utils::percent($slice['activated_phone'], $max) }}%"
data-value="{{ $slice['activated_phone'] }}"
title="Activated phone: {{ $slice['activated_phone'] }}"></div>
<div class="bar second" style="flex-basis: {{ Utils::percent($slice['email'] - $slice['activated_email'], $max) }}%"
data-value="{{ $slice['email'] - $slice['activated_email'] }}"
title="Unactivated email: {{ $slice['email'] - $slice['activated_email'] }}"></div>
<div class="bar second activated" style="flex-basis: {{ Utils::percent($slice['activated_email'], $max) }}%"
data-value="{{ $slice['activated_email'] }}"
title="Activated email: {{ $slice['activated_email'] }}"></div>

View file

@ -0,0 +1,6 @@
<div class="legend">
<div class="first">Unactivated phones</div>
<div class="first activated">Activated phones</div>
<div class="second">Unactivated emails</div>
<div class="second activated">Activated emails</div>
</div>

View file

@ -0,0 +1,37 @@
@extends('layouts.account')
@section('breadcrumb')
<li class="breadcrumb-item active" aria-current="page">
Statistics
</li>
@endsection
@section('content')
<ul class="nav justify-content-center">
<li class="nav-item">
<a class="nav-link disabled" href="#">Day</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('admin.statistics.show.week') }}">Week</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('admin.statistics.show.month') }}">Month</a>
</li>
</ul>
<h2>Statistics</h2>
@include('admin.statistics.parts.legend')
<h3>Day</h3>
<div class="columns">
@foreach ($day as $key => $hour)
<div class="column" data-value="{{ substr($key, -2, 2) }}:00">
@include('admin.statistics.parts.columns', ['slice' => $hour, 'max' => $max_day])
</div>
@endforeach
</div>
@endsection

View file

@ -0,0 +1,37 @@
@extends('layouts.account')
@section('breadcrumb')
<li class="breadcrumb-item active" aria-current="page">
Statistics
</li>
@endsection
@section('content')
<ul class="nav justify-content-center">
<li class="nav-item">
<a class="nav-link" href="{{ route('admin.statistics.show.day') }}">Day</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('admin.statistics.show.week') }}">Week</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" href="#">Month</a>
</li>
</ul>
<h2>Statistics</h2>
@include('admin.statistics.parts.legend')
<h3>Month</h3>
<div class="columns">
@foreach ($month as $key => $day)
<div class="column" data-value="{{ $key }}">
@include('admin.statistics.parts.columns', ['slice' => $day, 'max' => $max_month])
</div>
@endforeach
</div>
@endsection

View file

@ -0,0 +1,37 @@
@extends('layouts.account')
@section('breadcrumb')
<li class="breadcrumb-item active" aria-current="page">
Statistics
</li>
@endsection
@section('content')
<ul class="nav justify-content-center">
<li class="nav-item">
<a class="nav-link" href="{{ route('admin.statistics.show.day') }}">Day</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" href="#">Week</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('admin.statistics.show.month') }}">Month</a>
</li>
</ul>
<h2>Statistics</h2>
@include('admin.statistics.parts.legend')
<h3>Week</h3>
<div class="columns">
@foreach ($week as $key => $day)
<div class="column" data-value="{{ $key }}">
@include('admin.statistics.parts.columns', ['slice' => $day, 'max' => $max_week])
</div>
@endforeach
</div>
@endsection

View file

@ -186,6 +186,17 @@ Activate an account.
#### `GET /accounts/{id}/deactivate`
Deactivate an account.
### Statistics
#### `GET /statistics/day`
Retrieve registrations statistics for 24 hours.
#### `GET /statistics/week`
Retrieve registrations statistics for a week.
#### `GET /statistics/month`
Retrieve registrations statistics for a month.
# Provisioning
When an account is having an available `confirmation_key` it can be provisioned using the two following URL.

View file

@ -13,6 +13,7 @@
@else
<link rel="stylesheet" type="text/css" href="{{ asset('css/style.css') }}" >
@endif
<link rel="stylesheet" type="text/css" href="{{ asset('css/charts.css') }}" >
@endif
</head>
<body>

View file

@ -33,6 +33,10 @@ Route::post('accounts/{sip}/activate/email', 'Api\AccountController@activateEmai
Route::post('accounts/{sip}/activate/phone', 'Api\AccountController@activatePhone');
Route::group(['middleware' => ['auth.digest_or_key']], function () {
Route::get('statistic/month', 'Api\StatisticController@month');
Route::get('statistic/week', 'Api\StatisticController@week');
Route::get('statistic/day', 'Api\StatisticController@day');
Route::get('accounts/me', 'Api\AccountController@show');
Route::delete('accounts/me', 'Api\AccountController@delete');

View file

@ -71,6 +71,10 @@ Route::group(['middleware' => 'auth'], function () {
Route::group(['middleware' => 'auth.admin'], function () {
Route::post('admin/api_key', 'Admin\AccountController@generateApiKey')->name('admin.api_key.generate');
Route::get('admin/statistics/day', 'Admin\StatisticsController@showDay')->name('admin.statistics.show.day');
Route::get('admin/statistics/week', 'Admin\StatisticsController@showWeek')->name('admin.statistics.show.week');
Route::get('admin/statistics/month', 'Admin\StatisticsController@showMonth')->name('admin.statistics.show.month');
Route::get('admin/accounts/{account}/show', 'Admin\AccountController@show')->name('admin.account.show');
Route::get('admin/accounts/{account}/activate', 'Admin\AccountController@activate')->name('admin.account.activate');

View file

@ -8,7 +8,7 @@
#%define _datadir %{_datarootdir}
#%define _docdir %{_datadir}/doc
%define build_number 105
%define build_number 106
%define var_dir /var/opt/belledonne-communications
%define opt_dir /opt/belledonne-communications/share/flexisip-account-manager
@ -64,12 +64,16 @@ cp README* "$RPM_BUILD_ROOT%{opt_dir}/"
mkdir -p "$RPM_BUILD_ROOT/etc/flexisip-account-manager"
cp -R conf/* "$RPM_BUILD_ROOT/etc/flexisip-account-manager/"
mkdir -p $RPM_BUILD_ROOT/etc/cron.daily
%if %{with deb}
mkdir -p $RPM_BUILD_ROOT/etc/apache2/conf-available
cp httpd/flexisip-account-manager.conf "$RPM_BUILD_ROOT/etc/apache2/conf-available/"
cp cron/flexiapi.debian "$RPM_BUILD_ROOT/etc/cron.daily/"
%else
mkdir -p $RPM_BUILD_ROOT/opt/rh/httpd24/root/etc/httpd/conf.d
cp httpd/flexisip-account-manager.conf "$RPM_BUILD_ROOT/opt/rh/httpd24/root/etc/httpd/conf.d/"
cp cron/flexiapi.redhat "$RPM_BUILD_ROOT/etc/cron.daily/"
%endif
# POST INSTALLATION
@ -168,15 +172,19 @@ fi
%if %{with deb}
%config(noreplace) /etc/apache2/conf-available/flexisip-account-manager.conf
%config(noreplace) /etc/cron.daily/flexiapi.debian
%else
%config(noreplace) /opt/rh/httpd24/root/etc/httpd/conf.d/flexisip-account-manager.conf
%config(noreplace) /etc/cron.daily/flexiapi.redhat
%endif
%clean
rm -rf $RPM_BUILD_ROOT
%changelog
* Tue Jan 5 2020 Timothée Jaussoin <timothee.jaussoin@belledonne-communications.com>
* Tue Sep 28 2021 Timothée Jaussoin <timothee.jaussoin@belledonne-communications.com>
- Install cron scripts
* Sun Jan 5 2020 Timothée Jaussoin <timothee.jaussoin@belledonne-communications.com>
- Import and configure the new API package
* Thu Jul 4 2019 Sylvain Berfini <sylvain.berfini@belledonne-communications.com>
- New files layout