Compare commits

...

11 Commits

52 changed files with 3232 additions and 764 deletions

View File

@ -53,6 +53,98 @@
width: 100%; width: 100%;
} }
/* Custom utility classes for padding */
.py-1 { padding-top: 0.25rem !important; padding-bottom: 0.25rem !important; }
.py-2 { padding-top: 0.5rem !important; padding-bottom: 0.5rem !important; }
.py-3 { padding-top: 1rem !important; padding-bottom: 1rem !important; }
.py-4 { padding-top: 1.5rem !important; padding-bottom: 1.5rem !important; }
.py-5 { padding-top: 3rem !important; padding-bottom: 3rem !important; }
.px-1 { padding-left: 0.25rem !important; padding-right: 0.25rem !important; }
.px-2 { padding-left: 0.5rem !important; padding-right: 0.5rem !important; }
.px-3 { padding-left: 1rem !important; padding-right: 1rem !important; }
.px-4 { padding-left: 1.5rem !important; padding-right: 1.5rem !important; }
.px-5 { padding-left: 3rem !important; padding-right: 3rem !important; }
.pt-1 { padding-top: 0.25rem !important; }
.pt-2 { padding-top: 0.5rem !important; }
.pt-3 { padding-top: 1rem !important; }
.pt-4 { padding-top: 1.5rem !important; }
.pt-5 { padding-top: 3rem !important; }
.pb-1 { padding-bottom: 0.25rem !important; }
.pb-2 { padding-bottom: 0.5rem !important; }
.pb-3 { padding-bottom: 1rem !important; }
.pb-4 { padding-bottom: 1.5rem !important; }
.pb-5 { padding-bottom: 3rem !important; }
.pl-1 { padding-left: 0.25rem !important; }
.pl-2 { padding-left: 0.5rem !important; }
.pl-3 { padding-left: 1rem !important; }
.pl-4 { padding-left: 1.5rem !important; }
.pl-5 { padding-left: 3rem !important; }
.pr-1 { padding-right: 0.25rem !important; }
.pr-2 { padding-right: 0.5rem !important; }
.pr-3 { padding-right: 1rem !important; }
.pr-4 { padding-right: 1.5rem !important; }
.pr-5 { padding-right: 3rem !important; }
/* Custom utility classes for margin */
.my-1 { margin-top: 0.25rem !important; margin-bottom: 0.25rem !important; }
.my-2 { margin-top: 0.5rem !important; margin-bottom: 0.5rem !important; }
.my-3 { margin-top: 1rem !important; margin-bottom: 1rem !important; }
.my-4 { margin-top: 1.5rem !important; margin-bottom: 1.5rem !important; }
.my-5 { margin-top: 3rem !important; margin-bottom: 3rem !important; }
.mx-1 { margin-left: 0.25rem !important; margin-right: 0.25rem !important; }
.mx-2 { margin-left: 0.5rem !important; margin-right: 0.5rem !important; }
.mx-3 { margin-left: 1rem !important; margin-right: 1rem !important; }
.mx-4 { margin-left: 1.5rem !important; margin-right: 1.5rem !important; }
.mx-5 { margin-left: 3rem !important; margin-right: 3rem !important; }
.mt-1 { margin-top: 0.25rem !important; }
.mt-2 { margin-top: 0.5rem !important; }
.mt-3 { margin-top: 1rem !important; }
.mt-4 { margin-top: 1.5rem !important; }
.mt-5 { margin-top: 3rem !important; }
.mb-1 { margin-bottom: 0.25rem !important; }
.mb-2 { margin-bottom: 0.5rem !important; }
.mb-3 { margin-bottom: 1rem !important; }
.mb-4 { margin-bottom: 1.5rem !important; }
.mb-5 { margin-bottom: 3rem !important; }
.ml-1 { margin-left: 0.25rem !important; }
.ml-2 { margin-left: 0.5rem !important; }
.ml-3 { margin-left: 1rem !important; }
.ml-4 { margin-left: 1.5rem !important; }
.ml-5 { margin-left: 3rem !important; }
.mr-1 { margin-right: 0.25rem !important; }
.mr-2 { margin-right: 0.5rem !important; }
.mr-3 { margin-right: 1rem !important; }
.mr-4 { margin-right: 1.5rem !important; }
.mr-5 { margin-right: 3rem !important; }
/* HTML: <div class="loader"></div> */
.loader {
display: inline-block;
width: 50px;
aspect-ratio: 1;
border-radius: 50%;
background:
radial-gradient(farthest-side,#999 94%,#0000) top/8px 8px no-repeat,
conic-gradient(#0000 30%,#999);
-webkit-mask: radial-gradient(farthest-side,#0000 calc(100% - 8px),#000 0);
animation: l13 1s infinite linear;
}
@keyframes l13{
100%{transform: rotate(1turn)}
}
/** POPUPS ALERT **/ /** POPUPS ALERT **/
.popup-alerts-container .popup-alerts-container
{ {

20
assets/js/chart.js Normal file

File diff suppressed because one or more lines are too long

View File

@ -9,12 +9,16 @@
"symfony/expression-language": "^5.0", "symfony/expression-language": "^5.0",
"robmorgan/phinx": "^0.11.1", "robmorgan/phinx": "^0.11.1",
"monolog/monolog": "^2.0", "monolog/monolog": "^2.0",
"ovh/ovh": "^2.0", "ovh/ovh": "^3.0",
"twilio/sdk": "^6.1", "twilio/sdk": "^6.1",
"symfony/yaml": "^5.0", "symfony/yaml": "^5.0",
"phpmailer/phpmailer": "^6.1", "phpmailer/phpmailer": "^6.1",
"xantios/mimey": ">=2.1", "xantios/mimey": ">=2.1",
"kreait/firebase-php": "^5.14", "kreait/firebase-php": "^7.0",
"benmorel/gsm-charset-converter": "^0.3.0" "benmorel/gsm-charset-converter": "^0.3.0",
"google/cloud-pubsub": "^1.46"
},
"require-dev": {
"fakerphp/faker": "^1.21"
} }
} }

View File

@ -12,6 +12,7 @@
namespace controllers\internals; namespace controllers\internals;
use DateInterval; use DateInterval;
use Faker\Factory;
/** /**
* Class to call the console scripts. * Class to call the console scripts.
@ -62,198 +63,415 @@ use DateInterval;
$phone = $internal_phone->get($id_phone); $phone = $internal_phone->get($id_phone);
if (!$phone) if (!$phone)
{ {
exit(1); exit(1);
}
new \daemons\Phone($phone);
} }
/** new \daemons\Phone($phone);
* Check if a user exists based on email. }
*
* @param string $email : User email /**
*/ * Check if a user exists based on email.
public function user_exists(string $email) *
* @param string $email : User email
*/
public function user_exists(string $email)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_user = new \controllers\internals\User($bdd);
$user = $internal_user->get_by_email($email);
exit($user ? 0 : 1);
}
/**
* Check if a user exists based on id.
*
* @param string $id : User id
*/
public function user_id_exists(string $id)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_user = new \controllers\internals\User($bdd);
$user = $internal_user->get($id);
exit($user ? 0 : 1);
}
/**
* Create a user or update an existing user.
*
* @param $email : User email
* @param $password : User password
* @param $admin : Is user admin
* @param $api_key : User API key, if null random api key is generated
* @param $status : User status, default \models\User::STATUS_ACTIVE
* @param bool $encrypt_password : Should the password be encrypted, by default true
*
* exit code 0 on success | 1 on error
*/
public function create_update_user(string $email, string $password, bool $admin, ?string $api_key = null, string $status = \models\User::STATUS_ACTIVE, bool $encrypt_password = true)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_user = new \controllers\internals\User($bdd);
$user = $internal_user->get_by_email($email);
if ($user)
{ {
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); $api_key = $api_key ?? $internal_user->generate_random_api_key();
$internal_user = new \controllers\internals\User($bdd); $update_datas = [
'email' => $email,
'password' => $encrypt_password ? password_hash($password, PASSWORD_DEFAULT) : $password,
'admin' => $admin,
'api_key' => $api_key,
'status' => $status,
];
$user = $internal_user->get_by_email($email); $success = $internal_user->update($user['id'], $update_datas);
echo json_encode(['id' => $user['id']]);
exit($user ? 0 : 1);
}
/**
* Check if a user exists based on id.
*
* @param string $id : User id
*/
public function user_id_exists(string $id)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_user = new \controllers\internals\User($bdd);
$user = $internal_user->get($id);
exit($user ? 0 : 1);
}
/**
* Create a user or update an existing user.
*
* @param $email : User email
* @param $password : User password
* @param $admin : Is user admin
* @param $api_key : User API key, if null random api key is generated
* @param $status : User status, default \models\User::STATUS_ACTIVE
* @param bool $encrypt_password : Should the password be encrypted, by default true
*
* exit code 0 on success | 1 on error
*/
public function create_update_user(string $email, string $password, bool $admin, ?string $api_key = null, string $status = \models\User::STATUS_ACTIVE, bool $encrypt_password = true)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_user = new \controllers\internals\User($bdd);
$user = $internal_user->get_by_email($email);
if ($user)
{
$api_key = $api_key ?? $internal_user->generate_random_api_key();
$update_datas = [
'email' => $email,
'password' => $encrypt_password ? password_hash($password, PASSWORD_DEFAULT) : $password,
'admin' => $admin,
'api_key' => $api_key,
'status' => $status,
];
$success = $internal_user->update($user['id'], $update_datas);
echo json_encode(['id' => $user['id']]);
exit($success ? 0 : 1);
}
$new_user_id = $internal_user->create($email, $password, $admin, $api_key, $status, $encrypt_password);
echo json_encode(['id' => $new_user_id]);
exit($new_user_id ? 0 : 1);
}
/**
* Update a user status.
*
* @param string $id : User id
* @param string $status : User status, default \models\User::STATUS_ACTIVE
*/
public function update_user_status(string $id, string $status)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_user = new \controllers\internals\User($bdd);
$user = $internal_user->get($id);
if (!$user)
{
exit(1);
}
$success = $internal_user->update_status($user['id'], $status);
exit($success ? 0 : 1); exit($success ? 0 : 1);
} }
/** $new_user_id = $internal_user->create($email, $password, $admin, $api_key, $status, $encrypt_password);
* Delete a user. echo json_encode(['id' => $new_user_id]);
*
* @param string $id : User id exit($new_user_id ? 0 : 1);
*/ }
public function delete_user(string $id)
/**
* Update a user status.
*
* @param string $id : User id
* @param string $status : User status, default \models\User::STATUS_ACTIVE
*/
public function update_user_status(string $id, string $status)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_user = new \controllers\internals\User($bdd);
$user = $internal_user->get($id);
if (!$user)
{ {
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); exit(1);
$internal_user = new \controllers\internals\User($bdd);
$success = $internal_user->delete($id);
exit($success ? 0 : 1);
} }
/** $success = $internal_user->update_status($user['id'], $status);
* Delete medias that are no longer usefull.
*/ exit($success ? 0 : 1);
public function clean_unused_medias() }
/**
* Delete a user.
*
* @param string $id : User id
*/
public function delete_user(string $id)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_user = new \controllers\internals\User($bdd);
$success = $internal_user->delete($id);
exit($success ? 0 : 1);
}
/**
* Delete medias that are no longer usefull.
*/
public function clean_unused_medias()
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_media = new \controllers\internals\Media($bdd);
$medias = $internal_media->gets_unused();
foreach ($medias as $media)
{ {
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); $success = $internal_media->delete_for_user($media['id_user'], $media['id']);
$internal_media = new \controllers\internals\Media($bdd);
$medias = $internal_media->gets_unused(); echo (false === $success ? '[KO]' : '[OK]') . ' - ' . $media['path'] . "\n";
foreach ($medias as $media)
{
$success = $internal_media->delete_for_user($media['id_user'], $media['id']);
echo (false === $success ? '[KO]' : '[OK]') . ' - ' . $media['path'] . "\n";
}
}
/**
* Do alerting for quota limits.
*/
public function quota_limit_alerting()
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_quota = new \controllers\internals\Quota($bdd);
$internal_quota->alerting_for_limit_close_and_reached();
}
/**
* Do quota renewal.
*/
public function renew_quotas()
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_quota = new \controllers\internals\Quota($bdd);
$internal_quota->renew_quotas();
}
/**
* Do some fake population renewal.
*/
public function f()
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_sended = new \controllers\internals\Sended($bdd);
$destinations = ['+33612345678','+33612345679','+33612345680',];
$statuses = [\models\Sended::STATUS_DELIVERED, \models\Sended::STATUS_FAILED, \models\Sended::STATUS_UNKNOWN];
$day = new \DateTime();
$day->sub(new DateInterval('P30D'));
for ($i = 0; $i < 30; $i++)
{
$day->add(new DateInterval('P1D'));
$n = rand(0, 100);
for ($j = 0; $j < $n; $j++)
{
$id_user = 1;
$id_phone = rand(1, 2);
$destination = $destinations[array_rand($destinations)];
$status = $statuses[array_rand($statuses)];
$internal_sended->create(
$id_user,
$id_phone,
$day->format('Y-m-d H:i:s'),
"TEST N°$i:$j",
$destination,
uniqid(),
'adapters\TestAdapter',
false,
false,
null,
[],
null,
$status,
);
}
}
} }
} }
/**
* Do alerting for quota limits.
*/
public function quota_limit_alerting()
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_quota = new \controllers\internals\Quota($bdd);
$internal_quota->alerting_for_limit_close_and_reached();
}
/**
* Do quota renewal.
*/
public function renew_quotas()
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_quota = new \controllers\internals\Quota($bdd);
$internal_quota->renew_quotas();
}
/**
* Do phone reliability verifications
*/
public function phone_reliability()
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_user = new \controllers\internals\User($bdd);
$internal_settings = new \controllers\internals\Setting($bdd);
$internal_sended = new \controllers\internals\Sended($bdd);
$internal_phone_reliability = new \controllers\internals\PhoneReliability($bdd);
$internal_phone = new \controllers\internals\Phone($bdd);
$internal_webhook = new \controllers\internals\Webhook($bdd);
$internal_mailer = new \controllers\internals\Mailer();
$users = $internal_user->get_all_active();
foreach ($users as $user)
{
$settings = $internal_settings->gets_for_user($user['id']);
echo "\nCheck phone reliability for user " . $user['id'] . ":\n";
if ($settings['phone_reliability_failed'])
{
$rate_limit = intval($settings['phone_reliability_failed_rate_limit']) / 100;
$min_volume = intval($settings['phone_reliability_failed_volume']);
$period = intval($settings['phone_reliability_failed_period']);
$grace_period = intval($settings['phone_reliability_failed_grace_period']);
echo " Check for failed SMS with rate > " . $rate_limit . " and volume > " . $min_volume . " on period " . $period . "s with grace period of " . $grace_period . "s.\n";
$unreliable_phones = $internal_phone_reliability->find_unreliable_phones($user['id'], \models\Sended::STATUS_FAILED, $rate_limit, $min_volume, $period, $grace_period);
foreach ($unreliable_phones as $unreliable_phone)
{
$phone = $internal_phone->get($unreliable_phone['id_phone']);
if (!$phone)
{
echo ' Cannot find phone: ' . $unreliable_phone['id_phone'] . "\n";
continue;
}
echo "\n Phone " . $phone['id'] . ' - ' . $phone['name'] . " failed rate = " . $unreliable_phone['rate'] . " > " . $rate_limit . " and volume " . $unreliable_phone['total'] . " > " . $min_volume . "\n";
$internal_phone_reliability->create($user['id'], $phone['id'], \models\Sended::STATUS_FAILED);
if ($settings['phone_reliability_failed_email'])
{
$success = $internal_mailer->enqueue($user['email'], EMAIL_PHONE_RELIABILITY_FAILED, [
'phone' => $phone,
'period' => $period,
'total' => $unreliable_phone['total'],
'unreliable' => $unreliable_phone['unreliable'],
'rate' => $unreliable_phone['rate'],
]);
if (!$success)
{
echo ' Cannot enqueue alert for unreliable failed phone: ' . $unreliable_phone['id_phone'] . "\n";
continue;
}
echo " Alert mail for unreliable failed phone " . $phone['id'] . ' - ' . $phone['name'] . " added\n";
}
if ($settings['phone_reliability_failed_webhook'])
{
$webhook = [
'reliability_type' => \models\Sended::STATUS_FAILED,
'id_phone' => $unreliable_phone['id_phone'],
'period' => $period,
'total' => $unreliable_phone['total'],
'unreliable' => $unreliable_phone['unreliable'],
'rate' => $unreliable_phone['rate'],
];
$internal_webhook->trigger($user['id'], \models\Webhook::TYPE_PHONE_RELIABILITY, $webhook);
echo " Webhook for unreliable failed phone " . $phone['id'] . ' - ' . $phone['name'] . " triggered\n";
}
if ($settings['phone_reliability_failed_auto_disable'])
{
$internal_phone->update_status($unreliable_phone['id_phone'], \models\Phone::STATUS_DISABLED);
}
}
}
if ($settings['phone_reliability_unknown'])
{
$rate_limit = intval($settings['phone_reliability_unknown_rate_limit']) / 100;
$min_volume = intval($settings['phone_reliability_unknown_volume']);
$period = intval($settings['phone_reliability_unknown_period']);
$grace_period = intval($settings['phone_reliability_unknown_grace_period']);
echo "\n Check for unknown SMS with rate > " . $rate_limit . " and volume > " . $min_volume . " on period " . $period . "s with grace period of " . $grace_period . "s.\n";
$unreliable_phones = $internal_phone_reliability->find_unreliable_phones($user['id'], \models\Sended::STATUS_UNKNOWN, $rate_limit, $min_volume, $period, $grace_period);
foreach ($unreliable_phones as $unreliable_phone)
{
$phone = $internal_phone->get($unreliable_phone['id_phone']);
if (!$phone)
{
echo ' Cannot find phone: ' . $unreliable_phone['id_phone'] . "\n";
continue;
}
echo "\n Phone " . $phone['id'] . ' - ' . $phone['name'] . " unknown rate = " . $unreliable_phone['rate'] . " > " . $rate_limit . "\n";
$internal_phone_reliability->create($user['id'], $phone['id'], \models\Sended::STATUS_UNKNOWN);
if ($settings['phone_reliability_unknown_email'])
{
$success = $internal_mailer->enqueue($user['email'], EMAIL_PHONE_RELIABILITY_UNKNOWN, [
'phone' => $phone,
'period' => $period,
'total' => $unreliable_phone['total'],
'unreliable' => $unreliable_phone['unreliable'],
'rate' => $unreliable_phone['rate'],
]);
if (!$success)
{
echo ' Cannot enqueue alert for unreliable unknown phone: ' . $unreliable_phone['id_phone'] . "\n";
continue;
}
echo " Alert mail for unreliable unknown phone " . $phone['id'] . ' - ' . $phone['name'] . " added\n";
}
if ($settings['phone_reliability_unknown_webhook'])
{
$webhook = [
'reliability_type' => \models\Sended::STATUS_UNKNOWN,
'id_phone' => $unreliable_phone['id_phone'],
'period' => $period,
'total' => $unreliable_phone['total'],
'unreliable' => $unreliable_phone['unreliable'],
'rate' => $unreliable_phone['rate'],
];
$internal_webhook->trigger($user['id'], \models\Webhook::TYPE_PHONE_RELIABILITY, $webhook);
echo " Webhook for unreliable unknown phone " . $phone['id'] . ' - ' . $phone['name'] . " triggered\n";
}
if ($settings['phone_reliability_unknown_auto_disable'])
{
$internal_phone->update_status($unreliable_phone['id_phone'], \models\Phone::STATUS_DISABLED);
}
}
}
}
}
/**
* Function to easily populate the database with fake data for testing.
*
* @param int $id_user : User ID for whom data is to be generated
* @param int $received_entries : Number of entries to add to the received table
* @param int $sended_entries : Number of entries to add to the sended table
* @param int $contact_entries : Number of entries to add to the contact table
*/
public function seed_database(int $id_user, int $received_entries, int $sended_entries, int $contact_entries)
{
$this->seed_received($id_user, $received_entries);
$this->seed_sended($id_user, $sended_entries);
$this->seed_contact($id_user, $contact_entries);
}
/**
* Fill table received with fake data
*
* @param int $id_user : User to insert received for
* @param int $entries : How many received to insert
*/
public function seed_received(int $id_user, int $entries)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_received = new \controllers\internals\Received($bdd);
$internal_phone = new \controllers\internals\Phone($bdd);
$faker = Factory::create();
$phones = $internal_phone->gets_for_user($id_user);
for ($i = 0; $i < $entries; $i++)
{
$id_phone = $faker->randomElement($phones)['id'];
$at = $faker->dateTimeBetween('-1 year', 'now')->format('Y-m-d H:i:s');
$text = $faker->sentence(rand(5,10), true);
$origin = $faker->e164PhoneNumber;
$status = $faker->randomElement(['read', 'unread']);
$command = false;
$mms = false;
$media_ids = [];
$internal_received->create($id_user, $id_phone, $at, $text, $origin, $status, $command, $mms, $media_ids);
}
}
/**
* Fill table sended with fake data
*
* @param int $id_user : User to insert sended entries for
* @param int $entries : Number of entries to insert
*/
public function seed_sended(int $id_user, int $entries)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_sended = new \controllers\internals\Sended($bdd);
$internal_phone = new \controllers\internals\Phone($bdd);
$faker = Factory::create();
$phones = $internal_phone->gets_for_user($id_user);
for ($i = 0; $i < $entries; $i++)
{
echo $i."\n";
$phone = $faker->randomElement($phones);
$id_phone = $phone['id'];
$at = $faker->dateTimeBetween('-1 year', 'now')->format('Y-m-d H:i:s');
$text = $faker->sentence(rand(5, 10), true);
$destination = $faker->e164PhoneNumber;
$uid = $faker->uuid;
$adapter = $phone['adapter'];
$flash = $faker->boolean;
$mms = $faker->boolean;
$tag = $faker->optional()->word;
$medias = []; // Add logic for media IDs if needed
$originating_scheduled = $faker->numberBetween(1, 100);
$status = $faker->randomElement([\models\Sended::STATUS_UNKNOWN, \models\Sended::STATUS_DELIVERED, \models\Sended::STATUS_FAILED]);
$internal_sended->create($id_user, $id_phone, $at, $text, $destination, $uid, $adapter, $flash, $mms, $tag, $medias, $originating_scheduled, $status);
}
}
/**
* Fill table contact with fake data
*
* @param int $id_user : User to insert contacts for
* @param int $entries : Number of contacts to insert
*/
public function seed_contact(int $id_user, int $entries)
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$internal_contact = new \controllers\internals\Contact($bdd);
$faker = Factory::create();
for ($i = 0; $i < $entries; $i++)
{
$name = $faker->name;
$number = $faker->e164PhoneNumber;
$data = '[]';
$internal_contact->create($id_user, $number, $name, $data);
}
}
}

View File

@ -117,11 +117,10 @@ class Mailer extends \descartes\Controller
'attachments' => $attachments, 'attachments' => $attachments,
]; ];
$error_code = null; $queue = new Queue(QUEUE_ID_EMAIL);
$queue = msg_get_queue(QUEUE_ID_EMAIL); $queue->push(json_encode($message), QUEUE_TYPE_EMAIL);
$success = msg_send($queue, QUEUE_TYPE_EMAIL, $message, true, true, $error_code);
return (bool) $success; return true;
} }
/** /**

View File

@ -0,0 +1,65 @@
<?php
/*
* This file is part of RaspiSMS.
*
* (c) Pierre-Lin Bonnemaison <plebwebsas@gmail.com>
*
* This source file is subject to the GPL-3.0 license that is bundled
* with this source code in the file LICENSE.
*/
namespace controllers\internals;
use Exception;
class PhoneReliability extends StandardController
{
protected $model;
/**
* Create a phone reliability history entry.
*
* @param int $id_user : Id of user to create sended message for
* @param int $id_phone : Id of the number the message was send with
* @param $type : Type of reliability alert
* @return mixed : false on error, new sended id else
*/
public function create(int $id_user, int $id_phone, string $type)
{
return $this->get_model()->insert([
'id_user' => $id_user,
'id_phone' => $id_phone,
'type' => $type,
]);
return $id_sended;
}
/**
* Find all unreliable phones for a user, based on sended sms status, rate limit, etc.
*
* @param int $id_user : User id
* @param string $sms_status : Status of SMS to use to calculate rate
* @param float $rate_limit : Percentage of SMS matching status after which we consider the phone unreliable
* @param int $min_volume : Minimum number of sms we need to have to consider the statistic relevent
* @param int $period : The time span in minutes from which SMS counting should begin.
* @param int $grace_period : How long in minutes should we wait before including a SMS in counting
*
* @return array : A list of unreliable phone for the user, with phone id, total number of sms, and rate of failed sms
*/
public function find_unreliable_phones (int $id_user, string $sms_status, float $rate_limit, int $min_volume, int $period, int $grace_period)
{
return $this->get_model()->find_unreliable_phones($id_user, $sms_status, $rate_limit, $min_volume, $period, $grace_period);
}
/**
* Get the model for the Controller.
*/
protected function get_model(): \models\PhoneReliabilityHistory
{
$this->model = $this->model ?? new \models\PhoneReliabilityHistory($this->bdd);
return $this->model;
}
}

View File

@ -0,0 +1,86 @@
<?php
/*
* This file is part of RaspiSMS.
*
* (c) Pierre-Lin Bonnemaison <plebwebsas@gmail.com>
*
* This source file is subject to the GPL-3.0 license that is bundled
* with this source code in the file LICENSE.
*/
namespace controllers\internals;
use Exception;
use models\RedisQueue;
use models\SystemVQueue;
class Queue extends \descartes\InternalController
{
private $queue;
/**
* A class to interact with queue, the class is in charge to choose the type of queue (redis/system v) to use
*/
public function __construct($id)
{
if (USE_REDIS_QUEUES ?? false)
{
$params = [];
if (REDIS_HOST ?? false)
{
$params['host'] = REDIS_HOST;
}
if (REDIS_PORT ?? false)
{
$params['port'] = REDIS_PORT;
}
if (REDIS_PASSWORD ?? false)
{
$params['auth'] = REDIS_PASSWORD;
}
$this->queue = new RedisQueue($id, $params, 'raspisms', 'raspisms');
}
else
{
$this->queue = new SystemVQueue($id);
}
}
/**
* Add a message to the queue
*
* @param string $message : The message to add to the queue
* @param ?string $tag : A tag to associate to the message for routing purposes, if null will add to general queue
*/
public function push($message, ?string $tag = null)
{
return $this->queue->push($message, $tag);
}
/**
* Read the older message in the queue
*
* @return mixed $message : The oldest message or null if no message found, can be anything
* @param ?string $tag : A tag to associate to the message for routing purposes, if null will read from general queue
* @param mixed : The message to add to the queue, can be anything, the queue will have to treat it by itself
*/
public function read(?string $tag = null)
{
return $this->queue->read($tag);
}
/**
* Function to close system V queue for cleaning resources, usefull only if system V queue
*/
public function close()
{
if ($this->queue instanceof SystemVQueue)
{
$this->queue->close();
}
}
}

View File

@ -253,9 +253,9 @@ use Exception;
* *
* @return array * @return array
*/ */
public function get_discussions_for_user(int $id_user) public function get_discussions_for_user(int $id_user, ?int $nb_entry = null, ?int $page = null)
{ {
return $this->get_model()->get_discussions_for_user($id_user); return $this->get_model()->get_discussions_for_user($id_user, $nb_entry, $page);
} }
/** /**

View File

@ -225,6 +225,22 @@ use Exception;
return $this->get_model()->get_last_for_destination_and_user($id_user, $destination); return $this->get_model()->get_last_for_destination_and_user($id_user, $destination);
} }
/**
* Get number of sended SMS by day and status between two dates, possibly by sending phone.
*
* @param int $id_user : user id
* @param \DateTime $start_date : Date since which we want the messages
* @param \DateTime $end_date : Date until which we want the messages
* @param ?int $id_phone : Id of the phone to search sended for, null by default get all phones
*
* @return array
*/
public function get_sended_status_stats ($id_user, $start_date, $end_date, ?int $id_phone = null)
{
return $this->get_model()->get_sended_status_stats($id_user, $start_date, $end_date, $id_phone);
}
/** /**
* Send a SMS message. * Send a SMS message.
* *
@ -360,6 +376,22 @@ use Exception;
} }
} }
/**
* Get list of invalid phone number we've sent message to
*
* @param int $id_user : user id
* @param int $volume : Minimum number of sms sent to the number
* @param float $percent_failed : Minimum ratio of failed message
* @param float $percent_unknown : Minimum ratio of unknown message
* @param int $limit : Limit of results
* @param int $page : Page of results (offset = page * limit)
*
*/
public function get_invalid_numbers (int $id_user, int $volume, float $percent_failed, float $percent_unknown, int $limit, int $page)
{
return $this->get_model()->get_invalid_numbers($id_user, $volume, $percent_failed, $percent_unknown, $limit, $page);
}
/** /**
* Get the model for the Controller. * Get the model for the Controller.
*/ */

View File

@ -91,6 +91,12 @@ namespace controllers\internals;
$all_success = true; $all_success = true;
foreach (USER_DEFAULT_SETTINGS as $name => $value) foreach (USER_DEFAULT_SETTINGS as $name => $value)
{ {
// Ignore if already existing settings
if (count($this->get_by_name_for_user($id_user, $name)))
{
continue;
}
$success = $this->create($id_user, $name, $value); $success = $this->create($id_user, $name, $value);
$all_success = ($all_success && $success); $all_success = ($all_success && $success);
} }

View File

@ -168,6 +168,26 @@ use BenMorel\GsmCharsetConverter\Converter;
return $logo; return $logo;
} }
/**
* Check if a string is a valid PHP date
*
* @param string $date : Datestring to validate
*
* @return bool : True if a valid date, false else
*/
public static function is_valid_date($date)
{
try
{
new \DateTime($date);
return true;
}
catch (\Exception $e)
{
return false;
}
}
/** /**
* Cette fonction vérifie une date. * Cette fonction vérifie une date.
* *

View File

@ -31,6 +31,17 @@ namespace controllers\internals;
$this->internal_phone = new Phone($bdd); $this->internal_phone = new Phone($bdd);
} }
/**
* Return all active users.
*
* @return array
*/
public function get_all_active()
{
return $this->model_user->get_all_active();
}
/** /**
* Return a list of users by their ids. * Return a list of users by their ids.
* *

View File

@ -135,12 +135,11 @@ class Webhook extends StandardController
], ],
]; ];
$error_code = null; $queue = new Queue(QUEUE_ID_WEBHOOK);
$queue = msg_get_queue(QUEUE_ID_WEBHOOK); $success = $queue->push(json_encode($message), QUEUE_TYPE_WEBHOOK);
msg_send($queue, QUEUE_TYPE_WEBHOOK, $message, true, true, $error_code);
} }
return true; return (bool) $success;
} }
/** /**

View File

@ -91,6 +91,10 @@ namespace controllers\publics;
{ {
$this->user = $this->internal_user->get_by_api_key($api_key); $this->user = $this->internal_user->get_by_api_key($api_key);
} }
elseif ($_SESSION['user'] ?? false)
{
$this->user = $this->internal_user->get($_SESSION['user']['id']);
}
if (!$this->user) if (!$this->user)
{ {
@ -300,6 +304,47 @@ namespace controllers\publics;
return $this->json($return); return $this->json($return);
} }
/**
* Simplest method to send a SMS immediately with nothing but a URL and a GET query
* @param string $_GET['to'] = Phone number to send sms to
* @param string $_GET['text'] = Text of the SMS
* @param ?int $_GET['id_phone'] = Id of the phone to use, if null use a random phone
*/
public function get_send_sms()
{
$to = \controllers\internals\Tool::parse_phone($_GET['to'] ?? '');
$text = $_GET['text'] ?? false;
$id_phone = empty($_GET['id_phone']) ? null : $_GET['id_phone'];
if (!$to || !$text)
{
$return = self::DEFAULT_RETURN;
$return['error'] = self::ERROR_CODES['MISSING_PARAMETER'];
$return['message'] = self::ERROR_MESSAGES['MISSING_PARAMETER'] . ($to ? '' : 'to ') . ($text ? '' : 'text');
$this->auto_http_code(false);
return $this->json($return);
}
$at = (new \DateTime())->format('Y-m-d H:i:s');
$scheduled_id = $this->internal_scheduled->create($this->user['id'], $at, $text, $id_phone);
if (!$scheduled_id)
{
$return = self::DEFAULT_RETURN;
$return['error'] = self::ERROR_CODES['CANNOT_CREATE'];
$return['message'] = self::ERROR_MESSAGES['CANNOT_CREATE'];
$this->auto_http_code(false);
return $this->json($return);
}
$return = self::DEFAULT_RETURN;
$return['response'] = $scheduled_id;
$this->auto_http_code(true);
return $this->json($return);
}
/** /**
* Schedule a message to be send. * Schedule a message to be send.
* *
@ -1030,6 +1075,15 @@ namespace controllers\publics;
return $this->json($return); return $this->json($return);
} }
if ($phone['status'] === \models\Phone::STATUS_DISABLED)
{
$return['error'] = self::ERROR_CODES['CANNOT_UPDATE'];
$return['message'] = self::ERROR_MESSAGES['CANNOT_UPDATE'] . 'Phone have been manually disabled, you need to re-enable it manually.';
$this->auto_http_code(false);
return $this->json($return);
}
// If user have activated phone limits, check if RaspiSMS phone limit have already been reached // If user have activated phone limits, check if RaspiSMS phone limit have already been reached
$limit_reached = false; $limit_reached = false;
if ((int) ($this->user['settings']['phone_limit'] ?? false)) if ((int) ($this->user['settings']['phone_limit'] ?? false))
@ -1068,4 +1122,176 @@ namespace controllers\publics;
return $this->json($return); return $this->json($return);
} }
/**
* Manually disable/enable phones
* @param int id : id of phone we want to update status
* @param string $_POST['new_status'] : New status of the phone, either 'disabled' or 'available'
* @param $csrf : CSRF token
*/
public function post_change_phone_status ($id)
{
$new_status = $_POST['status'] ?? '';
if (!in_array($new_status, [\models\Phone::STATUS_AVAILABLE, \models\Phone::STATUS_DISABLED]))
{
$return['error'] = self::ERROR_CODES['INVALID_PARAMETER'];
$return['message'] = self::ERROR_MESSAGES['INVALID_PARAMETER'] . ' "status" must be "disabled" or "available".';
$this->auto_http_code(false);
return $this->json($return);
}
$phone = $this->internal_phone->get_for_user($this->user['id'], $id);
if (!$phone)
{
$return['error'] = self::ERROR_CODES['CANNOT_UPDATE'];
$return['message'] = self::ERROR_MESSAGES['CANNOT_UPDATE'];
$this->auto_http_code(false);
return $this->json($return);
}
$status_update = $this->internal_phone->update_status($id, $new_status);
$return['response'] = $new_status;
$this->auto_http_code(true);
return $this->json($return);
}
/**
* Return statistics about status of sended sms for a period by phone
*
* @param string $_GET['start'] : Date from which to get sms volume, format Y-m-d H:i:s.
* @param string $_GET['end'] : Date up to which to get sms volume, format Y-m-d H:i:s.
* @param ?int $_GET['id_phone'] : Id of the phone we want to check the status for. Default to null will return stats for all phone.
*
* @return : List of entries
*/
public function get_sms_status_stats()
{
$start = $_GET['start'] ?? null;
$end = $_GET['end'] ?? null;
$id_phone = $_GET['id_phone'] ?? null;
if (!$start || !$end)
{
$return = self::DEFAULT_RETURN;
$return['error'] = self::ERROR_CODES['MISSING_PARAMETER'];
$return['message'] = self::ERROR_MESSAGES['MISSING_PARAMETER'] . 'start and end date are required.';
$this->auto_http_code(false);
return $this->json($return);
}
$return = self::DEFAULT_RETURN;
if (!\controllers\internals\Tool::is_valid_date($start))
{
$return = self::DEFAULT_RETURN;
$return['error'] = self::ERROR_CODES['INVALID_PARAMETER'];
$return['message'] = self::ERROR_MESSAGES['INVALID_PARAMETER'] . 'start must be a date of format "Y-m-d H:i:s".';
$this->auto_http_code(false);
return $this->json($return);
}
$start = new \DateTime($start);
if (!\controllers\internals\Tool::is_valid_date($end))
{
$return = self::DEFAULT_RETURN;
$return['error'] = self::ERROR_CODES['INVALID_PARAMETER'];
$return['message'] = self::ERROR_MESSAGES['INVALID_PARAMETER'] . 'end must be a date of format "Y-m-d H:i:s".';
$this->auto_http_code(false);
return $this->json($return);
}
$end = new \DateTime($end);
if ($id_phone)
{
$phone = $this->internal_phone->get_for_user($this->user['id'], $id_phone);
if (!$phone)
{
$return['error'] = self::ERROR_CODES['INVALID_PARAMETER'];
$return['message'] = self::ERROR_MESSAGES['INVALID_PARAMETER'] . 'phone with id ' . $id_phone . ' does not exists.';
$this->auto_http_code(false);
return $this->json($return);
}
}
$stats = $this->internal_sended->get_sended_status_stats($this->user['id'], $start, $end, $id_phone);
$return = self::DEFAULT_RETURN;
$return['response'] = $stats;
$this->auto_http_code(true);
return $this->json($return);
}
/**
* Return statistics about invalid numbers
*
* @param int $page : Pagination number, Default = 0. Group of 25 results.
* @param int $_GET['volume'] : Minimum number of SMS sent to the number
* @param int $_GET['percent_failed'] : Minimum percentage of failed SMS to the number
* @param int $_GET['percent_unknown'] : Minimum percentage of unknown SMS to the number
*
* @return : List of entries
*/
public function get_invalid_numbers($page = 0)
{
$page = (int) $page;
$limit = 25;
$volume = $_GET['volume'] ?? false;
$percent_failed = $_GET['percent_failed'] ?? false;
$percent_unknown = $_GET['percent_unknown'] ?? false;
if ($volume === false || $percent_failed === false || $percent_unknown === false)
{
$return = self::DEFAULT_RETURN;
$return['error'] = self::ERROR_CODES['MISSING_PARAMETER'];
$return['message'] = self::ERROR_MESSAGES['MISSING_PARAMETER'] . 'volume, percent_failed and percent_unknown are required.';
$this->auto_http_code(false);
return $this->json($return);
}
$volume = (int) $volume;
$percent_failed = ((float) $percent_failed) / 100;
$percent_unknown = ((float) $percent_unknown) / 100;
$return = self::DEFAULT_RETURN;
$invalid_numbers = $this->internal_sended->get_invalid_numbers($this->user['id'], $volume, $percent_failed, $percent_unknown, $limit, $page);
$return = self::DEFAULT_RETURN;
if (\count($invalid_numbers) === $limit)
{
$return['next'] = \descartes\Router::url('Api', __FUNCTION__, ['page' => $page + 1], [
'api_key' => $this->user['api_key'],
'volume' => $volume,
'percent_failed' => $percent_failed * 100,
'percent_unknown' => $percent_unknown * 100
]);
}
if ($page > 0)
{
$return['prev'] = \descartes\Router::url('Api', __FUNCTION__, ['page' => $page - 1], [
'api_key' => $this->user['api_key'],
'volume' => $volume,
'percent_failed' => $percent_failed * 100,
'percent_unknown' => $percent_unknown * 100
]);
}
$return['response'] = $invalid_numbers;
$this->auto_http_code(true);
return $this->json($return, false);
}
} }

View File

@ -87,16 +87,50 @@ namespace controllers\publics;
$stats_start_date_formated = $stats_start_date->format('Y-m-d'); $stats_start_date_formated = $stats_start_date->format('Y-m-d');
} }
$this->render('dashboard/show', [
'nb_contacts' => $nb_contacts,
'nb_groups' => $nb_groups,
'nb_scheduleds' => $nb_scheduleds,
'nb_sendeds' => $nb_sendeds,
'nb_receiveds' => $nb_receiveds,
'nb_unreads' => $nb_unreads,
'quota_unused' => $quota_unused,
'sendeds' => $sendeds,
'receiveds' => $receiveds,
'events' => $events,
'stats_start_date_formated' => $stats_start_date_formated,
]);
}
/**
* Return stats about sended sms
*/
public function stats_sended()
{
$id_user = $_SESSION['user']['id'];
//Création de la date d'il y a 30 jours
$now = new \DateTime();
$one_month = new \DateInterval('P1M');
$stats_start_date = clone $now;
$stats_start_date->sub($one_month);
$stats_start_date_formated = $stats_start_date->format('Y-m-d');
//If user have a quota and the quota start before today, use quota start date instead
$quota = $this->internal_quota->get_user_quota($id_user);
if ($quota && (new \DateTime($quota['start_date']) <= $now) && (new \DateTime($quota['expiration_date']) > $now))
{
$stats_start_date = new \DateTime($quota['start_date']);
$stats_start_date_formated = $stats_start_date->format('Y-m-d');
}
$nb_sendeds_by_day = $this->internal_sended->count_by_day_and_status_since_for_user($id_user, $stats_start_date_formated); $nb_sendeds_by_day = $this->internal_sended->count_by_day_and_status_since_for_user($id_user, $stats_start_date_formated);
$nb_receiveds_by_day = $this->internal_received->count_by_day_since_for_user($id_user, $stats_start_date_formated);
//On va traduire ces données pour les afficher en graphique //On va traduire ces données pour les afficher en graphique
$array_bar_chart_sended = []; $array_bar_chart_sended = [];
$array_bar_chart_received = [];
$date = clone $stats_start_date; $date = clone $stats_start_date;
$one_day = new \DateInterval('P1D'); $one_day = new \DateInterval('P1D');
$i = 0;
//On va construire un tableau avec la date en clef, et les données pour chaque date //On va construire un tableau avec la date en clef, et les données pour chaque date
while ($date <= $now) while ($date <= $now)
@ -109,15 +143,13 @@ namespace controllers\publics;
'sendeds_delivered' => 0, 'sendeds_delivered' => 0,
]; ];
$array_bar_chart_received[$date_f] = ['period' => $date_f, 'receiveds' => 0];
$date->add($one_day); $date->add($one_day);
} }
$total_sendeds = 0; $total_sendeds = 0;
$total_receiveds = 0; $total_receiveds = 0;
//0n remplie le tableau avec les données adaptées //On remplie le tableau avec les données adaptées
foreach ($nb_sendeds_by_day as $nb_sended) foreach ($nb_sendeds_by_day as $nb_sended)
{ {
$array_bar_chart_sended[$nb_sended['at_ymd']]['sendeds_' . $nb_sended['status']] = $nb_sended['nb']; $array_bar_chart_sended[$nb_sended['at_ymd']]['sendeds_' . $nb_sended['status']] = $nb_sended['nb'];
@ -125,6 +157,59 @@ namespace controllers\publics;
$total_sendeds += $nb_sended['nb']; $total_sendeds += $nb_sended['nb'];
} }
$nb_days = $stats_start_date->diff($now)->days + 1;
$avg_sendeds = round($total_sendeds / $nb_days, 2);
$array_bar_chart_sended = array_values($array_bar_chart_sended);
header('content-type:application/json');
echo json_encode([
'data_bar_chart_sended' => $array_bar_chart_sended,
'avg_sendeds' => $avg_sendeds,
]);
}
/**
* Return stats about received sms
*/
public function stats_received()
{
$id_user = $_SESSION['user']['id'];
//Création de la date d'il y a 30 jours
$now = new \DateTime();
$one_month = new \DateInterval('P1M');
$stats_start_date = clone $now;
$stats_start_date->sub($one_month);
$stats_start_date_formated = $stats_start_date->format('Y-m-d');
$quota = $this->internal_quota->get_user_quota($id_user);
if ($quota && (new \DateTime($quota['start_date']) <= $now) && (new \DateTime($quota['expiration_date']) > $now))
{
$stats_start_date = new \DateTime($quota['start_date']);
$stats_start_date_formated = $stats_start_date->format('Y-m-d');
}
$nb_receiveds_by_day = $this->internal_received->count_by_day_since_for_user($id_user, $stats_start_date_formated);
//On va traduire ces données pour les afficher en graphique
$array_bar_chart_received = [];
$date = clone $stats_start_date;
$one_day = new \DateInterval('P1D');
//On va construire un tableau avec la date en clef, et les données pour chaque date
while ($date <= $now)
{
$date_f = $date->format('Y-m-d');
$array_bar_chart_received[$date_f] = ['period' => $date_f, 'receiveds' => 0];
$date->add($one_day);
}
$total_receiveds = 0;
foreach ($nb_receiveds_by_day as $date => $nb_received) foreach ($nb_receiveds_by_day as $date => $nb_received)
{ {
$array_bar_chart_received[$date]['receiveds'] = $nb_received; $array_bar_chart_received[$date]['receiveds'] = $nb_received;
@ -132,28 +217,15 @@ namespace controllers\publics;
} }
$nb_days = $stats_start_date->diff($now)->days + 1; $nb_days = $stats_start_date->diff($now)->days + 1;
$avg_sendeds = round($total_sendeds / $nb_days, 2);
$avg_receiveds = round($total_receiveds / $nb_days, 2); $avg_receiveds = round($total_receiveds / $nb_days, 2);
$array_bar_chart_sended = array_values($array_bar_chart_sended);
$array_bar_chart_received = array_values($array_bar_chart_received); $array_bar_chart_received = array_values($array_bar_chart_received);
$this->render('dashboard/show', [ header('content-type:application/json');
'nb_contacts' => $nb_contacts, echo json_encode([
'nb_groups' => $nb_groups, 'data_bar_chart_received' => $array_bar_chart_received,
'nb_scheduleds' => $nb_scheduleds,
'nb_sendeds' => $nb_sendeds,
'nb_receiveds' => $nb_receiveds,
'nb_unreads' => $nb_unreads,
'avg_sendeds' => $avg_sendeds,
'avg_receiveds' => $avg_receiveds, 'avg_receiveds' => $avg_receiveds,
'quota_unused' => $quota_unused,
'sendeds' => $sendeds,
'receiveds' => $receiveds,
'events' => $events,
'data_bar_chart_sended' => json_encode($array_bar_chart_sended),
'data_bar_chart_received' => json_encode($array_bar_chart_received),
'stats_start_date_formated' => $stats_start_date_formated,
]); ]);
} }
} }

View File

@ -56,7 +56,7 @@ namespace controllers\publics;
*/ */
public function list_json() public function list_json()
{ {
$entities = $this->internal_received->get_discussions_for_user($_SESSION['user']['id']); $entities = $this->internal_received->get_discussions_for_user($_SESSION['user']['id'], 1000);
foreach ($entities as &$entity) foreach ($entities as &$entity)
{ {

View File

@ -535,6 +535,16 @@ class Phone extends \descartes\Controller
foreach ($ids as $id) foreach ($ids as $id)
{ {
$phone = $this->internal_phone->get_for_user($id_user, $id); $phone = $this->internal_phone->get_for_user($id_user, $id);
if (!$phone)
{
continue;
}
if ($phone['status'] === \models\Phone::STATUS_DISABLED)
{
\FlashMessage\FlashMessage::push('error', 'Certains téléphones ont été désactivés manuellements, vous devez les réactiver manuellement.');
continue;
}
// If user have activated phone limits, check if RaspiSMS phone limit have already been reached // If user have activated phone limits, check if RaspiSMS phone limit have already been reached
$limit_reached = false; $limit_reached = false;
@ -581,6 +591,48 @@ class Phone extends \descartes\Controller
return $this->redirect(\descartes\Router::url('Phone', 'list')); return $this->redirect(\descartes\Router::url('Phone', 'list'));
} }
/**
* Manually disable/enable phones
* @param array int $_GET['ids'] : ids of phones we want to update status
* @param string $new_status : New status of the phone, either 'disabled' or 'available'
* @param $csrf : CSRF token
*/
public function change_status ($new_status, $csrf)
{
if (!$this->verify_csrf($csrf))
{
\FlashMessage\FlashMessage::push('danger', 'Jeton CSRF invalid !');
return $this->redirect(\descartes\Router::url('Phone', 'add'));
}
if (!in_array($new_status, [\models\Phone::STATUS_AVAILABLE, \models\Phone::STATUS_DISABLED]))
{
\FlashMessage\FlashMessage::push('danger', 'Seul les status disponibles et désactivés peuvent être définis manuellement.');
return $this->redirect(\descartes\Router::url('Phone', 'add'));
}
$ids = $_GET['ids'] ?? [];
$id_user = $_SESSION['user']['id'];
foreach ($ids as $id)
{
$phone = $this->internal_phone->get_for_user($id_user, $id);
if (!$phone)
{
continue;
}
$status_update = $this->internal_phone->update_status($id, $new_status);
}
\FlashMessage\FlashMessage::push('success', 'Les status des téléphones ont bien été mis à jour manuellement.');
return $this->redirect(\descartes\Router::url('Phone', 'list'));
}
/** /**
* Return a list of phones as a JSON array * Return a list of phones as a JSON array
*/ */

View File

@ -0,0 +1,53 @@
<?php
/*
* This file is part of RaspiSMS.
*
* (c) Pierre-Lin Bonnemaison <plebwebsas@gmail.com>
*
* This source file is subject to the GPL-3.0 license that is bundled
* with this source code in the file LICENSE.
*/
namespace controllers\publics;
/**
* Statistics pages
*/
class Stat extends \descartes\Controller
{
private $internal_sended;
private $internal_phone;
public function __construct()
{
$bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$this->internal_sended = new \controllers\internals\Sended($bdd);
$this->internal_phone = new \controllers\internals\Phone($bdd);
\controllers\internals\Tool::verifyconnect();
}
/**
* Show the stats about sms status for a period by phone
*
* @return void;
*/
public function sms_status()
{
$id_user = $_SESSION['user']['id'];
$phones = $this->internal_phone->gets_for_user($id_user);
$now = new \DateTime();
$seven_days_interval = new \DateInterval('P7D');
$seven_days_ago = clone($now);
$seven_days_ago->sub($seven_days_interval);
$this->render('stat/sms-status', [
'phones' => $phones,
'now' => $now,
'seven_days_ago' => $seven_days_ago,
]);
}
}

View File

@ -11,6 +11,7 @@
namespace daemons; namespace daemons;
use controllers\internals\Queue;
use Monolog\Handler\StreamHandler; use Monolog\Handler\StreamHandler;
use Monolog\Logger; use Monolog\Logger;
@ -19,7 +20,7 @@ use Monolog\Logger;
*/ */
class Mailer extends AbstractDaemon class Mailer extends AbstractDaemon
{ {
private $mailer_queue; private ?Queue $mailer_queue;
private $last_message_at; private $last_message_at;
private $bdd; private $bdd;
@ -49,27 +50,15 @@ class Mailer extends AbstractDaemon
$find_message = true; $find_message = true;
while ($find_message) while ($find_message)
{ {
//Call message $message = $this->mailer_queue->read(QUEUE_TYPE_EMAIL);
$msgtype = null;
$maxsize = 409600;
$message = null;
$error_code = null; if ($message === null)
$success = msg_receive($this->mailer_queue, QUEUE_TYPE_EMAIL, $msgtype, $maxsize, $message, true, MSG_IPC_NOWAIT, $error_code); //MSG_IPC_NOWAIT == dont wait if no message found
if (!$success && MSG_ENOMSG !== $error_code)
{ {
$this->logger->critical('Error for mailer queue reading, error code : ' . $error_code);
$find_message = false; $find_message = false;
continue; continue;
} }
if (!$message) $message = json_decode($message, true);
{
$find_message = false;
continue;
}
$this->logger->info('Try sending email : ' . json_encode($message)); $this->logger->info('Try sending email : ' . json_encode($message));
@ -92,7 +81,7 @@ class Mailer extends AbstractDaemon
public function on_start() public function on_start()
{ {
//Set last message at to construct time //Set last message at to construct time
$this->mailer_queue = msg_get_queue(QUEUE_ID_EMAIL); $this->mailer_queue = new Queue(QUEUE_ID_EMAIL);
$this->logger->info('Starting Mailer daemon with pid ' . getmypid()); $this->logger->info('Starting Mailer daemon with pid ' . getmypid());
} }
@ -101,8 +90,6 @@ class Mailer extends AbstractDaemon
{ {
//Delete queue on daemon close //Delete queue on daemon close
$this->logger->info('Closing queue : ' . QUEUE_ID_EMAIL); $this->logger->info('Closing queue : ' . QUEUE_ID_EMAIL);
msg_remove_queue($this->mailer_queue);
$this->logger->info('Stopping Mailer daemon with pid ' . getmypid()); $this->logger->info('Stopping Mailer daemon with pid ' . getmypid());
} }

View File

@ -11,6 +11,7 @@
namespace daemons; namespace daemons;
use controllers\internals\Queue;
use Monolog\Handler\StreamHandler; use Monolog\Handler\StreamHandler;
use Monolog\Logger; use Monolog\Logger;
@ -22,7 +23,7 @@ class Phone extends AbstractDaemon
private $max_inactivity = 5 * 60; private $max_inactivity = 5 * 60;
private $read_delay = 20 / 0.5; private $read_delay = 20 / 0.5;
private $read_tick = 0; private $read_tick = 0;
private $msg_queue; private ?Queue $queue;
private $webhook_queue; private $webhook_queue;
private $last_message_at; private $last_message_at;
private $phone; private $phone;
@ -85,7 +86,7 @@ class Phone extends AbstractDaemon
//Set last message at to construct time //Set last message at to construct time
$this->last_message_at = microtime(true); $this->last_message_at = microtime(true);
$this->msg_queue = msg_get_queue(QUEUE_ID_PHONE); $this->queue = new Queue(QUEUE_ID_PHONE);
//Instanciate adapter //Instanciate adapter
$adapter_class = $this->phone['adapter']; $adapter_class = $this->phone['adapter'];
@ -96,7 +97,7 @@ class Phone extends AbstractDaemon
public function on_stop() public function on_stop()
{ {
$this->logger->info('Stopping Phone daemon with pid ' . getmypid()); $this->logger->info('Stopping Phone daemon with pid ' . getmypid());
} }
public function handle_other_signals($signal) public function handle_other_signals($signal)
@ -114,30 +115,17 @@ class Phone extends AbstractDaemon
$find_message = true; $find_message = true;
while ($find_message) while ($find_message)
{ {
//Call message
$msgtype = null;
$maxsize = 409600;
$message = null;
// Message type is forged from a prefix concat with the phone ID
$message_type = (int) QUEUE_TYPE_SEND_MSG_PREFIX . $this->phone['id']; $message_type = (int) QUEUE_TYPE_SEND_MSG_PREFIX . $this->phone['id'];
$error_code = null; $message = $this->queue->read($message_type);
$success = msg_receive($this->msg_queue, $message_type, $msgtype, $maxsize, $message, true, MSG_IPC_NOWAIT, $error_code); //MSG_IPC_NOWAIT == dont wait if no message found
if (!$success && MSG_ENOMSG !== $error_code) if ($message === null)
{
$this->logger->critical('Error reading MSG SEND Queue, error code : ' . $error_code);
return false;
}
if (!$message)
{ {
$find_message = false; $find_message = false;
continue; continue;
} }
$message = json_decode($message, true);
//Update last message time //Update last message time
$this->last_message_at = microtime(true); $this->last_message_at = microtime(true);

View File

@ -11,6 +11,8 @@
namespace daemons; namespace daemons;
use controllers\internals\Queue;
use Exception;
use Monolog\Handler\StreamHandler; use Monolog\Handler\StreamHandler;
use Monolog\Logger; use Monolog\Logger;
@ -19,12 +21,9 @@ use Monolog\Logger;
*/ */
class Sender extends AbstractDaemon class Sender extends AbstractDaemon
{ {
private $internal_phone;
private $internal_scheduled; private $internal_scheduled;
private $internal_received;
private $internal_sended;
private $bdd; private $bdd;
private $msg_queue; private ?Queue $queue;
public function __construct() public function __construct()
{ {
@ -44,9 +43,7 @@ class Sender extends AbstractDaemon
public function run() public function run()
{ {
//Create the internal controllers
$this->internal_scheduled = new \controllers\internals\Scheduled($this->bdd); $this->internal_scheduled = new \controllers\internals\Scheduled($this->bdd);
$this->internal_sended = new \controllers\internals\Sended($this->bdd);
//Get smss and transmit order to send to appropriate phone daemon //Get smss and transmit order to send to appropriate phone daemon
$smss_per_scheduled = $this->internal_scheduled->get_smss_to_send(); $smss_per_scheduled = $this->internal_scheduled->get_smss_to_send();
@ -64,12 +61,6 @@ class Sender extends AbstractDaemon
{ {
foreach ($smss_per_scheduled as $id_scheduled => $smss) foreach ($smss_per_scheduled as $id_scheduled => $smss)
{ {
//If queue not already exists
if (!msg_queue_exists(QUEUE_ID_PHONE) || !isset($this->msg_queue))
{
$this->msg_queue = msg_get_queue(QUEUE_ID_PHONE);
}
foreach ($smss as $sms) foreach ($smss as $sms)
{ {
$msg = [ $msg = [
@ -84,9 +75,10 @@ class Sender extends AbstractDaemon
'medias' => $sms['medias'] ?? [], 'medias' => $sms['medias'] ?? [],
]; ];
// Message type is forged from a prefix concat with the phone ID // Message type is forged from a prefix concat with the phone ID
$message_type = (int) QUEUE_TYPE_SEND_MSG_PREFIX . $sms['id_phone']; $message_type = (int) QUEUE_TYPE_SEND_MSG_PREFIX . $sms['id_phone'];
msg_send($this->msg_queue, $message_type, $msg); $this->queue->push(json_encode($msg), $message_type);
$this->logger->info('Transmit sms send signal to phone ' . $sms['id_phone'] . ' on queue ' . QUEUE_ID_PHONE . ' with message type ' . $message_type . '.'); $this->logger->info('Transmit sms send signal to phone ' . $sms['id_phone'] . ' on queue ' . QUEUE_ID_PHONE . ' with message type ' . $message_type . '.');
} }
@ -97,16 +89,25 @@ class Sender extends AbstractDaemon
public function on_start() public function on_start()
{ {
$this->logger->info('Starting Sender with pid ' . getmypid()); try
$this->bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); {
$this->logger->info('Starting Sender with pid ' . getmypid());
$this->bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD);
$this->queue = new Queue(QUEUE_ID_PHONE);
}
catch (Exception $e)
{
$this->logger->error('Failed to start sender daemon : ' . $e->getMessage());
}
} }
public function on_stop() public function on_stop()
{ {
//Delete queue on daemon close //Delete queue on daemon close
$this->logger->info('Closing queue : ' . $this->msg_queue); $this->logger->info('Closing queue : ' . QUEUE_ID_PHONE);
msg_remove_queue($this->msg_queue); $this->queue->close();
$this->logger->info('Stopping Sender with pid ' . getmypid()); $this->logger->info('Stopping Sender with pid ' . getmypid());
} }

View File

@ -11,6 +11,7 @@
namespace daemons; namespace daemons;
use controllers\internals\Queue;
use GuzzleHttp\Promise\Utils; use GuzzleHttp\Promise\Utils;
use Monolog\Handler\StreamHandler; use Monolog\Handler\StreamHandler;
use Monolog\Logger; use Monolog\Logger;
@ -20,11 +21,7 @@ use Monolog\Logger;
*/ */
class Webhook extends AbstractDaemon class Webhook extends AbstractDaemon
{ {
private $webhook_queue; private ?Queue $webhook_queue;
private $last_message_at;
private $phone;
private $adapter;
private $bdd;
private $guzzle_client; private $guzzle_client;
/** /**
@ -56,30 +53,17 @@ class Webhook extends AbstractDaemon
$promises = []; $promises = [];
while ($find_message) while ($find_message)
{ {
//Call message $message = $this->webhook_queue->read(QUEUE_TYPE_WEBHOOK);
$msgtype = null;
$maxsize = 409600;
$message = null;
$error_code = null; if ($message === null)
$success = msg_receive($this->webhook_queue, QUEUE_TYPE_WEBHOOK, $msgtype, $maxsize, $message, true, MSG_IPC_NOWAIT, $error_code); //MSG_IPC_NOWAIT == dont wait if no message found
if (!$success && MSG_ENOMSG !== $error_code)
{ {
$this->logger->critical('Error for webhook queue reading, error code : ' . $error_code);
$find_message = false; $find_message = false;
continue; continue;
} }
if (!$message) $this->logger->info('Trigger webhook : ' . $message);
{
$find_message = false;
continue;
}
$this->logger->info('Trigger webhook : ' . json_encode($message));
$message = json_decode($message, true);
$promises[] = $this->guzzle_client->postAsync($message['url'], ['form_params' => $message['data']]); $promises[] = $this->guzzle_client->postAsync($message['url'], ['form_params' => $message['data']]);
} }
@ -97,10 +81,13 @@ class Webhook extends AbstractDaemon
public function on_start() public function on_start()
{ {
//Set last message at to construct time try{
$this->last_message_at = microtime(true); $this->webhook_queue = new Queue(QUEUE_ID_WEBHOOK);
}
$this->webhook_queue = msg_get_queue(QUEUE_ID_WEBHOOK); catch (\Exception $e)
{
$this->logger->info('Webhook : failed with ' . $e->getMessage());
}
$this->logger->info('Starting Webhook daemon with pid ' . getmypid()); $this->logger->info('Starting Webhook daemon with pid ' . getmypid());
} }
@ -109,7 +96,7 @@ class Webhook extends AbstractDaemon
{ {
//Delete queue on daemon close //Delete queue on daemon close
$this->logger->info('Closing queue : ' . QUEUE_ID_WEBHOOK); $this->logger->info('Closing queue : ' . QUEUE_ID_WEBHOOK);
msg_remove_queue($this->webhook_queue); unset($this->webhook_queue);
$this->logger->info('Stopping Webhook daemon with pid ' . getmypid()); $this->logger->info('Stopping Webhook daemon with pid ' . getmypid());
} }

View File

@ -0,0 +1,38 @@
<?php
use Phinx\Migration\AbstractMigration;
class AddStatusDisabledPhone extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html
*
* The following commands can be used in this method and Phinx will
* automatically reverse them when rolling back:
*
* createTable
* renameTable
* addColumn
* addCustomColumn
* renameColumn
* addIndex
* addForeignKey
*
* Any other destructive changes will result in an error when trying to
* rollback the migration.
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change()
{
$table = $this->table('phone');
$table->changeColumn('status', 'enum', ['values' => ['available', 'unavailable', 'no_credit', 'limit_reached', 'disabled'], 'default' => 'available']);
$table->save();
}
}

View File

@ -0,0 +1,38 @@
<?php
use Phinx\Migration\AbstractMigration;
class AddWebhookPhoneReliability extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html
*
* The following commands can be used in this method and Phinx will
* automatically reverse them when rolling back:
*
* createTable
* renameTable
* addColumn
* addCustomColumn
* renameColumn
* addIndex
* addForeignKey
*
* Any other destructive changes will result in an error when trying to
* rollback the migration.
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change()
{
$table = $this->table('webhook');
$table->changeColumn('type', 'enum', ['values' => ['send_sms','send_sms_status_change','receive_sms','inbound_call', 'phone_reliability']]);
$table->save();
}
}

View File

@ -0,0 +1,22 @@
<?php
use Phinx\Migration\AbstractMigration;
class AddTablePhoneReliabilityHistory extends AbstractMigration
{
public function change()
{
// Create the phone_reliability_history table
// This table store history of reliability alert for phones, so we can use last alert as min date
// for surveillance periode, preventing triggering same alert in a loop
$this->table('phone_reliability_history')
->addColumn('id_user', 'integer', ['null' => false])
->addColumn('id_phone', 'integer', ['null' => false])
->addColumn('type', 'string', ['null' => false, 'limit' => 100])
->addColumn('created_at', 'timestamp', ['null' => false, 'default' => 'CURRENT_TIMESTAMP'])
->addColumn('updated_at', 'timestamp', ['null' => true, 'update' => 'CURRENT_TIMESTAMP'])
->addForeignKey('id_user', 'user', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE'])
->addForeignKey('id_phone', 'phone', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE'])
->create();
}
}

View File

@ -0,0 +1,38 @@
<?php
use Phinx\Migration\AbstractMigration;
class AddIndexSendedStatus extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html
*
* The following commands can be used in this method and Phinx will
* automatically reverse them when rolling back:
*
* createTable
* renameTable
* addColumn
* addCustomColumn
* renameColumn
* addIndex
* addForeignKey
*
* Any other destructive changes will result in an error when trying to
* rollback the migration.
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change()
{
$table = $this->table('sended');
$table->addIndex(['id_user', 'status']);
$table->update();
}
}

View File

@ -0,0 +1,38 @@
<?php
use Phinx\Migration\AbstractMigration;
class AddIndexDestinationStatusSended extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html
*
* The following commands can be used in this method and Phinx will
* automatically reverse them when rolling back:
*
* createTable
* renameTable
* addColumn
* addCustomColumn
* renameColumn
* addIndex
* addForeignKey
*
* Any other destructive changes will result in an error when trying to
* rollback the migration.
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change()
{
$table = $this->table('sended');
$table->addIndex(['destination', 'status']);
$table->update();
}
}

View File

@ -91,6 +91,22 @@
'shorten_url' => 0, 'shorten_url' => 0,
'smsstop_respond' => 1, 'smsstop_respond' => 1,
'smsstop_response' => 'Demande prise en compte, vous ne recevrez plus de messages.', 'smsstop_response' => 'Demande prise en compte, vous ne recevrez plus de messages.',
'phone_reliability_failed' => 1,
'phone_reliability_failed_rate_limit' => 10,
'phone_reliability_failed_volume' => 25,
'phone_reliability_failed_period' => 120,
'phone_reliability_failed_grace_period' => 1,
'phone_reliability_failed_email' => 0,
'phone_reliability_failed_webhook' => 1,
'phone_reliability_failed_auto_disable' => 0,
'phone_reliability_unknown' => 0,
'phone_reliability_unknown_rate_limit' => 25,
'phone_reliability_unknown_volume' => 25,
'phone_reliability_unknown_period' => 120,
'phone_reliability_unknown_grace_period' => 1,
'phone_reliability_unknown_email' => 0,
'phone_reliability_unknown_webhook' => 1,
'phone_reliability_unknown_auto_disable' => 0,
], ],
]; ];

View File

@ -26,6 +26,11 @@
'HOST' => '%APP_URL_SHORTENER_HOST%', 'HOST' => '%APP_URL_SHORTENER_HOST%',
'USER' => '%APP_URL_SHORTENER_USER%', 'USER' => '%APP_URL_SHORTENER_USER%',
'PASS' => '%APP_URL_SHORTENER_PASS%', 'PASS' => '%APP_URL_SHORTENER_PASS%',
] ],
// Define if we should use a Redis instance instead of System V Queues
'USE_REDIS_QUEUES' => false,
'REDIS_HOST' => '%APP_REDIS_HOST%',
'REDIS_PORT' => '%APP_REDIS_PORT%',
'REDIS_PASSWORD' => '%APP_REDIS_PASSWORD%',
]; ];

View File

@ -14,10 +14,11 @@ namespace models;
class Phone extends StandardModel class Phone extends StandardModel
{ {
const STATUS_AVAILABLE = 'available'; const STATUS_AVAILABLE = 'available'; # Everything OK
const STATUS_UNAVAILABLE = 'unavailable'; const STATUS_UNAVAILABLE = 'unavailable'; # RaspiSMS cannot communication with the phone
const STATUS_NO_CREDIT = 'no_credit'; const STATUS_DISABLED = 'disabled'; # Phone have been manually or automatically disabled by user/system
const STATUS_LIMIT_REACHED = 'limit_reached'; const STATUS_NO_CREDIT = 'no_credit'; # Phone have no more credit available
const STATUS_LIMIT_REACHED = 'limit_reached'; # We reached the limit in of SMS in RaspiSMS for this phone
/** /**
* Return all phones that belongs to active users * Return all phones that belongs to active users
@ -109,7 +110,6 @@ namespace models;
return $this->_delete('phone_limit', ['id_phone' => $id_phone]); return $this->_delete('phone_limit', ['id_phone' => $id_phone]);
} }
/** /**
* Return table name. * Return table name.
*/ */

View File

@ -0,0 +1,95 @@
<?php
/*
* This file is part of RaspiSMS.
*
* (c) Pierre-Lin Bonnemaison <plebwebsas@gmail.com>
*
* This source file is subject to the GPL-3.0 license that is bundled
* with this source code in the file LICENSE.
*/
namespace models;
class PhoneReliabilityHistory extends StandardModel
{
/**
* Find all unreliable phones for a user, based on sended sms status, rate limit, etc.
*
* @param int $id_user : User id
* @param string $sms_status : Status of SMS to use to calculate rate
* @param float $rate_limit : Percentage of SMS matching status after which we consider the phone unreliable
* @param int $min_volume : Minimum number of sms we need to have to consider the statistic relevent
* @param int $period : The time span in minutes from which SMS counting should begin.
* @param int $grace_period : How long in minutes should we wait before including a SMS in counting
*
* @return array : A list of unreliable phone for the user, with phone id, total number of sms, and rate of failed sms
*/
public function find_unreliable_phones (int $id_user, string $sms_status, float $rate_limit, int $min_volume, int $period, int $grace_period)
{
return $this->_run_query("
WITH recent_messages AS (
SELECT
sended.id_phone AS id_phone,
COUNT(sended.id) AS total,
SUM(sended.status = :sms_status) AS unreliable
FROM
sended
JOIN
phone
ON
sended.id_phone = phone.id
LEFT JOIN
(
SELECT
id_phone,
MAX(created_at) AS last_alert_time
FROM
phone_reliability_history
WHERE
type = :sms_status
GROUP BY
id_phone
) AS last_alerts
ON
sended.id_phone = last_alerts.id_phone
WHERE
sended.id_user = :id_user
AND
phone.status != 'disabled'
AND
sended.at > IFNULL(last_alerts.last_alert_time, '1970-01-01')
AND
sended.at BETWEEN NOW() - INTERVAL :period MINUTE AND NOW() - INTERVAL :grace_period MINUTE
GROUP BY
id_phone
)
SELECT
id_phone,
total,
unreliable,
(unreliable / total) AS rate
FROM
recent_messages
WHERE
total >= :min_volume
AND
(unreliable / total) >= :rate_limit;
", [
'id_user' => $id_user,
'sms_status' => $sms_status,
'period' => $period,
'grace_period' => $grace_period,
'min_volume' => $min_volume,
'rate_limit' => $rate_limit,
]);
}
/**
* Return table name.
*/
protected function get_table_name(): string
{
return 'phone_reliability_history';
}
}

39
models/Queue.php Normal file
View File

@ -0,0 +1,39 @@
<?php
/*
* This file is part of RaspiSMS.
*
* (c) Pierre-Lin Bonnemaison <plebwebsas@gmail.com>
*
* This source file is subject to the GPL-3.0 license that is bundled
* with this source code in the file LICENSE.
*/
namespace models;
/**
*
*/
interface Queue
{
/**
* A FIFO Queue to exchange messages, the backend mechanism can be whatever we want, but the queue take message, tag for routing is optionnal
* @param string $id : A unique identifier for the queue
*/
public function __construct($id);
/**
* Add a message to the queue
*
* @param string $message : The message to add to the queue, must be a string, for complex data just use json
* @param ?string $tag : A tag to associate to the message for routing purposes, if not set will add to general queue
*/
public function push($message, ?string $tag = null);
/**
* Read the older message in the queue (non-blocking)
* @param ?string $tag : A tag to associate to the message for routing purposes, if not set will read from general queue
* @return ?string $message : The oldest message or null if no message found, can be anything
*/
public function read(?string $tag = null);
}

View File

@ -267,27 +267,33 @@ namespace models;
* *
* @return array * @return array
*/ */
public function get_discussions_for_user(int $id_user) public function get_discussions_for_user(int $id_user, ?int $nb_entry = null, ?int $page = null)
{ {
$query = ' $query = '
SELECT discussions.at, discussions.number, contact.name as contact_name SELECT at, destination AS number, contact.name AS contact_name
FROM ( FROM sended
SELECT at, destination as number FROM sended LEFT JOIN contact ON contact.number = sended.destination
WHERE id_user = :id_user WHERE sended.id_user = :id_user
UNION (
SELECT at, origin as number FROM received UNION ALL
WHERE id_user = :id_user
) SELECT at, origin AS number, contact.name AS contact_name
) as discussions FROM received
LEFT JOIN contact LEFT JOIN contact ON contact.number = received.origin
ON discussions.number = contact.number AND id_user = :id_user WHERE received.id_user = :id_user
GROUP BY number
ORDER BY at DESC ORDER BY at DESC
'; ';
$params = ['id_user' => $id_user]; $params = ['id_user' => $id_user];
return $this->_run_query($query, $params); if ($nb_entry !== null)
{
$query .= 'LIMIT ' . intval($nb_entry) * intval($page) . ', ' . intval($nb_entry);
}
$results = $this->_run_query($query, $params);
return $results;
} }
/** /**

108
models/RedisQueue.php Normal file
View File

@ -0,0 +1,108 @@
<?php
/*
* This file is part of RaspiSMS.
*
* (c) Pierre-Lin Bonnemaison <plebwebsas@gmail.com>
*
* This source file is subject to the GPL-3.0 license that is bundled
* with this source code in the file LICENSE.
*/
namespace models;
use Exception;
/**
*
*/
class RedisQueue implements Queue
{
private \Redis $redis;
private $group;
private $consumer;
private $id;
/**
* A Redis queue to store and exchange messages using redis streams
* routing is based on queue uniq id as stream name, combined with ':tag' if routing is needed, messages are stored as json
* @param string $id : A unique identifier for the queue
* @param array $redis_parameters : Parameters for the redis server, such as host, port, etc. Default to a basic local redis on port 6379
* @param string $group : Name to use for the redis group that must read this queue, default to 'default'
* @param string $consumer : Name to use for the redis consumer in the group that must read this queue, default to 'default'
*/
public function __construct($id, $redis_parameters = [], $group = 'default', $consumer = 'default')
{
$this->id = $id;
$this->redis = new \Redis();
$success = $this->redis->connect($redis_parameters['host'], intval($redis_parameters['port']), 1, '', 0, 0, ['auth' => $redis_parameters['auth']]);
if (!$success)
{
throw new \Exception('Failed to connect to redis server !');
}
$this->group = $group;
$this->consumer = $consumer;
}
/**
* Add a message to the queue
*
* @param string $message : The message to add to the queue
* @param ?string $tag : A tag to associate to the message for routing purposes, if null will add to general queue
*/
public function push($message, ?string $tag = null)
{
$stream = $this->id . ($tag !== null ? ":$tag" : '');
$success = $this->redis->xAdd($stream, '*', ['message' => $message]);
if (!$success)
{
throw new \Exception('Failed to push a message !');
}
return true;
}
/**
* Read the older message in the queue
*
* @return mixed $message : The oldest message or null if no message found, can be anything
* @param ?string $tag : A tag to associate to the message for routing purposes, if null will read from general queue
* @param mixed : The message to add to the queue, can be anything, the queue will have to treat it by itself
*/
public function read(?string $tag = null)
{
$stream = $this->id . ($tag !== null ? ":$tag" : '');
// Create the consumer group if it doesn't already exist
try
{
$this->redis->xGroup('CREATE', $stream, $this->group, '$', true);
}
catch (Exception $e)
{
// Ignore error if the group already exists
}
// Read a single message starting from the oldest (>)
$messages = $this->redis->xReadGroup($this->group, $this->consumer, [$stream => '>'], 1);
if (!count($messages))
{
return null;
}
// Find the message, acknowledge it and return it
foreach ($messages as $stream_name => $entries)
{
foreach ($entries as $message_id => $message)
{
$success = $this->redis->xAck($stream, $this->group, [$message_id]);
return $message['message'];
}
}
return null;
}
}

View File

@ -294,6 +294,90 @@ namespace models;
return $result[0] ?? []; return $result[0] ?? [];
} }
/**
* Get number of sended SMS by day and status between two dates, possibly by sending phone.
*
* @param int $id_user : user id
* @param \DateTime $start_date : Date since which we want the messages
* @param \DateTime $end_date : Date until which we want the messages
* @param ?int $id_phone : Id of the phone to search sended for, null by default get all phones
*
* @return array
*/
public function get_sended_status_stats ($id_user, $start_date, $end_date, ?int $id_phone = null)
{
$params = [
'start_date' => $start_date->format('y-m-d H:i:s'),
'end_date' => $end_date->format('y-m-d H:i:s'),
'id_user' => $id_user,
];
$query = "
SELECT DATE_FORMAT(at, '%Y-%m-%d') as at_ymd, id_phone, status, COUNT(id) as nb
FROM sended
WHERE id_user = :id_user
AND id_phone IS NOT NULL
AND at >= :start_date
AND at <= :end_date
";
if ($id_phone)
{
$params['id_phone'] = $id_phone;
$query .= "
AND id_phone = :id_phone
";
}
$query .= "
GROUP BY at_ymd, status, id_phone
ORDER BY at_ymd, id_phone, status
";
return $this->_run_query($query, $params);
}
/**
* Get list of invalid phone number we've sent message to
*
* @param int $id_user : user id
* @param int $volume : Minimum number of sms sent to the number
* @param float $percent_failed : Minimum ratio of failed message
* @param float $percent_unknown : Minimum ratio of unknown message
* @param int $limit : Limit of results
* @param int $page : Page of results (offset = page * limit)
*
*/
public function get_invalid_numbers (int $id_user, int $volume, float $percent_failed, float $percent_unknown, int $limit, int $page)
{
$query = "
SELECT
destination,
COUNT(*) AS total_sms_sent,
ROUND(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) / COUNT(*), 2) AS failed_percentage,
ROUND(SUM(CASE WHEN status = 'unknown' THEN 1 ELSE 0 END) / COUNT(*), 2) AS unknown_percentage
FROM
sended
GROUP BY
destination
HAVING
total_sms_sent >= :volume
AND failed_percentage >= :percent_failed
AND unknown_percentage >= :percent_unknown
LIMIT " . intval($page * $limit) . "," . intval($limit) . "
";
$params = [
'volume' => $volume,
'percent_failed' => $percent_failed,
'percent_unknown' => $percent_unknown
];
return $this->_run_query($query, $params);
}
/** /**
* Return table name. * Return table name.
*/ */

115
models/SystemVQueue.php Normal file
View File

@ -0,0 +1,115 @@
<?php
/*
* This file is part of RaspiSMS.
*
* (c) Pierre-Lin Bonnemaison <plebwebsas@gmail.com>
*
* This source file is subject to the GPL-3.0 license that is bundled
* with this source code in the file LICENSE.
*/
namespace models;
/**
*
*/
class SystemVQueue implements Queue
{
private $id;
private $queue;
/**
* A queue using System V message queues to store and exchange messages
* routing is based on queue id and message type
*
* ** Attention : Instead of string, all ids and tags must be numbers, its the system v queues works, no reliable way arround it**
* @param int $id : A unique identifier for the queue, *this must be generated with ftok*
*/
public function __construct($id)
{
$this->id = (int) $id;
}
/**
* Function to close the system v queue on destruction
*/
public function close()
{
if ($this->queue)
{
msg_remove_queue($this->queue);
}
}
/**
* Function to get the message queue and ensure it is open, we should always call it during push/read just to
* make sure another process didn't close the queue
*/
private function get_queue()
{
$this->queue = msg_get_queue($this->id);
if (!$this->queue)
{
throw new \Exception('Impossible to get a System V message queue for id ' . $this->id);
}
}
/**
* Add a message to the queue
*
* @param string $message : The message to add to the queue
* @param ?string $tag : A tag to associate to the message for routing purposes.
* Though this is a string, we MUST pass a valid number, its the way System V queue works
*/
public function push($message, ?string $tag = '0')
{
$tag = (int) $tag;
$this->get_queue();
$error_code = null;
$success = msg_send($this->queue, $tag, $message, true, false, $error_code);
if (!$success)
{
throw new \Exception('Impossible to send the message on system V queue, error code : ' . $error_code);
}
return true;
}
/**
* Read the older message in the queue
*
* @param ?string $tag : A tag to associate to the message for routing purposes
* Though this is a string, we MUST pass a valid number, its the way System V queue works
*
* @return mixed $message : The oldest message or null if no message found, can be anything
*/
public function read(?string $tag = '0')
{
$tag = (int) $tag;
$msgtype = null;
$maxsize = 409600;
$message = null;
// Message type is forged from a prefix concat with the phone ID
$error_code = null;
$this->get_queue();
$success = msg_receive($this->queue, $tag, $msgtype, $maxsize, $message, true, MSG_IPC_NOWAIT, $error_code); //MSG_IPC_NOWAIT == dont wait if no message found
if (!$success && MSG_ENOMSG !== $error_code)
{
throw new \Exception('Impossible to read messages on system V queue, error code : ' . $error_code);
}
if (!$message)
{
return null;
}
return $message;
}
}

View File

@ -31,6 +31,16 @@ namespace models;
return $this->_select_one('user', ['id' => $id]); return $this->_select_one('user', ['id' => $id]);
} }
/**
* Return all active users.
*
* @return array
*/
public function get_all_active()
{
return $this->_select('user', ['status' => self::STATUS_ACTIVE]);
}
/** /**
* Find user by ids. * Find user by ids.
* *

View File

@ -19,6 +19,7 @@ namespace models;
const TYPE_INBOUND_CALL = 'inbound_call'; const TYPE_INBOUND_CALL = 'inbound_call';
const TYPE_QUOTA_LEVEL_ALERT = 'quota_level'; const TYPE_QUOTA_LEVEL_ALERT = 'quota_level';
const TYPE_QUOTA_REACHED = 'quota_reached'; const TYPE_QUOTA_REACHED = 'quota_reached';
const TYPE_PHONE_RELIABILITY = 'phone_reliability';
/** /**
* Find all webhooks for a user and for a type of webhook. * Find all webhooks for a user and for a type of webhook.

View File

@ -11,6 +11,8 @@
'Dashboard' => [ 'Dashboard' => [
'show' => '/dashboard/', 'show' => '/dashboard/',
'stats_sended' => '/dashboard/stats/sended.json/',
'stats_received' => '/dashboard/stats/received.json/',
], ],
'Account' => [ 'Account' => [
@ -165,6 +167,7 @@
'edit' => '/phone/edit/', 'edit' => '/phone/edit/',
'update' => '/phone/update/{csrf}/', 'update' => '/phone/update/{csrf}/',
'update_status' => '/phone/update_status/{csrf}/', 'update_status' => '/phone/update_status/{csrf}/',
'change_status' => '/phone/change_status/{new_status}/{csrf}/',
'json_list' => '/phones.json/', 'json_list' => '/phones.json/',
], ],
@ -202,6 +205,10 @@
'inbound_call' => '/callback/inbound_call/{id_phone}/', 'inbound_call' => '/callback/inbound_call/{id_phone}/',
'end_call' => '/callback/end_call/{id_phone}/', 'end_call' => '/callback/end_call/{id_phone}/',
], ],
'Stat' => [
'sms_status' => '/stats/sms-status/',
],
'Api' => [ 'Api' => [
'get_entries' => [ 'get_entries' => [
@ -209,6 +216,14 @@
'/api/list/{entry_type}/{page}/', '/api/list/{entry_type}/{page}/',
], ],
'get_usage' => '/api/usage/', 'get_usage' => '/api/usage/',
'get_sms_status_stats' => '/api/stats/sms-status/',
'get_invalid_numbers' => [
'/api/invalid_number/',
'/api/invalid_number/{page}/',
],
'get_send_sms' => [
'/api/send-sms/',
],
'post_scheduled' => [ 'post_scheduled' => [
'/api/scheduled/', '/api/scheduled/',
], ],
@ -224,6 +239,9 @@
'post_update_phone_status' => [ 'post_update_phone_status' => [
'/api/phone/{id}/status/', '/api/phone/{id}/status/',
], ],
'post_change_phone_status' => [
'/api/phone/{id}/status/force/',
],
'delete_phone' => [ 'delete_phone' => [
'/api/phone/{id}/', '/api/phone/{id}/',
], ],

View File

@ -121,13 +121,14 @@
<div class="panel panel-default dashboard-panel-chart"> <div class="panel panel-default dashboard-panel-chart">
<div class="panel-heading"> <div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-area-chart fa-fw"></i> SMS envoyés depuis le <?= $stats_start_date_formated; ?> : </h3> <h3 class="panel-title"><i class="fa fa-area-chart fa-fw"></i> SMS envoyés depuis le <?= $stats_start_date_formated; ?> : </h3>
<span style="color: #5CB85C;">SMS envoyés (moyenne = <?php echo $avg_sendeds; ?> par jour).</span><br/> <span style="color: #5CB85C;">SMS envoyés (moyenne = <span id="avg_sendeds">0</span> par jour).</span><br/>
<?php if ($quota_unused) { ?> <?php if ($quota_unused) { ?>
<br/> <br/>
<span style="color: #d9534f">Crédits restants : <?= $quota_unused; ?>.</span> <span style="color: #d9534f">Crédits restants : <?= $quota_unused; ?>.</span>
<?php } ?> <?php } ?>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div id="morris-bar-chart-sended-loader" class="text-center"><div class="loader"></div></div>
<div id="morris-bar-chart-sended"></div> <div id="morris-bar-chart-sended"></div>
</div> </div>
</div> </div>
@ -139,9 +140,10 @@
<div class="panel panel-default dashboard-panel-chart"> <div class="panel panel-default dashboard-panel-chart">
<div class="panel-heading"> <div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-area-chart fa-fw"></i> SMS reçus depuis le <?= $stats_start_date_formated; ?> : </h3> <h3 class="panel-title"><i class="fa fa-area-chart fa-fw"></i> SMS reçus depuis le <?= $stats_start_date_formated; ?> : </h3>
<span style="color: #EDAB4D">SMS reçus (moyenne = <?php echo $avg_receiveds; ?> par jour).</span> <span style="color: #EDAB4D">SMS reçus (moyenne = <span id="avg_receiveds">0</span> par jour).</span>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div id="morris-bar-chart-received-loader" class="text-center"><div class="loader"></div></div>
<div id="morris-bar-chart-received"></div> <div id="morris-bar-chart-received"></div>
</div> </div>
</div> </div>
@ -255,18 +257,23 @@
</div> </div>
<script> <script>
jQuery(document).ready(function() async function drawChartSended() {
{ let url = <?= json_encode(\descartes\Router::url('Dashboard', 'stats_sended'))?>;
const response = await fetch(url);
const data = await response.json();
document.getElementById('avg_sendeds').textContent = data.avg_sendeds;
Morris.Bar({ Morris.Bar({
element: 'morris-bar-chart-sended', element: 'morris-bar-chart-sended',
fillOpacity: 0.4, fillOpacity: 0.4,
data: <?php echo $data_bar_chart_sended;?>, data: data.data_bar_chart_sended,
xkey: 'period', xkey: 'period',
parseTime: false, parseTime: false,
ykeys: ['sendeds_failed', 'sendeds_unknown', 'sendeds_delivered'], ykeys: ['sendeds_failed', 'sendeds_unknown', 'sendeds_delivered'],
labels: ['SMS échoués', 'SMS inconnus', 'SMS délivrés'], labels: ['SMS échoués', 'SMS inconnus', 'SMS délivrés'],
barColors: ['#D9534F', '#337AB7', '#5CB85C'], barColors: ['#D9534F', '#337AB7', '#5CB85C'],
goals: [<?php echo $avg_sendeds; ?>,], goals: [data.avg_sendeds],
goalLineColors: ['#5CB85C'], goalLineColors: ['#5CB85C'],
goalStrokeWidth: 2, goalStrokeWidth: 2,
pointSize: 4, pointSize: 4,
@ -290,22 +297,42 @@
} }
}); });
Morris.Bar({ document.getElementById('morris-bar-chart-sended-loader').classList.add('hidden');
}
async function drawChartReceived() {
let url = <?= json_encode(\descartes\Router::url('Dashboard', 'stats_received'))?>;
const response = await fetch(url);
const data = await response.json();
console.log(data);
document.getElementById('avg_receiveds').textContent = data.avg_receiveds;
Morris.Bar({
element: 'morris-bar-chart-received', element: 'morris-bar-chart-received',
fillOpacity: 0.4, fillOpacity: 0.4,
data: <?php echo $data_bar_chart_received;?>, data: data.data_bar_chart_received,
xkey: 'period', xkey: 'period',
parseTime: false, parseTime: false,
ykeys: ['receiveds'], ykeys: ['receiveds'],
labels: ['SMS reçus'], labels: ['SMS reçus'],
barColors: ['#EDAB4D'], barColors: ['#EDAB4D'],
goals: [<?php echo $avg_receiveds; ?>], goals: [data.avg_receiveds],
goalLineColors: ['#EDAB4D'], goalLineColors: ['#EDAB4D'],
goalStrokeWidth: 2, goalStrokeWidth: 2,
pointSize: 4, pointSize: 4,
hideHover: 'auto', hideHover: 'auto',
resize: true, resize: true,
}); });
document.getElementById('morris-bar-chart-received-loader').classList.add('hidden');
}
jQuery(document).ready(function()
{
drawChartSended();
drawChartReceived();
}); });
</script> </script>
<!-- /#wrapper --> <!-- /#wrapper -->

View File

@ -64,7 +64,7 @@ jQuery(document).ready(function ()
{ {
jQuery('.datatable').DataTable({ jQuery('.datatable').DataTable({
"pageLength": 25, "pageLength": 25,
"lengthMenu": [[25, 50, 100, 1000, 10000, -1], [25, 50, 100, 1000, 10000, "All"]], "lengthMenu": [[25, 50, 100, 1000], [25, 50, 100, 1000]],
"language": { "language": {
"url": HTTP_PWD + "/assets/js/datatables/french.json", "url": HTTP_PWD + "/assets/js/datatables/french.json",
}, },
@ -73,7 +73,6 @@ jQuery(document).ready(function ()
'targets': 'checkcolumn', 'targets': 'checkcolumn',
'orderable': false, 'orderable': false,
}], }],
"ajax": { "ajax": {
'url': '<?php echo \descartes\Router::url('Discussion', 'list_json'); ?>', 'url': '<?php echo \descartes\Router::url('Discussion', 'list_json'); ?>',
'dataSrc': 'data', 'dataSrc': 'data',

View File

@ -0,0 +1,10 @@
Le téléphone <?php $this->s($phone['name']); ?> semble rencontrer un taux de SMS échoués anormalement élevé.
Période prise en compte : <?php $this->s($period); ?> dernières minutes
Total de SMS : <?php $this->s($total); ?>
Nombre d'échecs : <?php $this->s($unreliable); ?>
Taux d'échecs : <?php $this->s($rate); ?>%
--------------------------------------------------------------------------------------------
Pour plus d'informations sur le système RaspiSMS, rendez-vous sur le site https://raspisms.fr

View File

@ -0,0 +1,10 @@
Le téléphone <?php $this->s($phone['name']); ?> semble rencontrer un taux de SMS incconnus anormalement élevé.
Période prise en compte : <?php $this->s($period); ?> dernières minutes
Total de SMS : <?php $this->s($total); ?>
Nombre d'inconnus : <?php $this->s($unreliable); ?>
Taux d'inconnus : <?php $this->s($rate); ?>%
--------------------------------------------------------------------------------------------
Pour plus d'informations sur le système RaspiSMS, rendez-vous sur le site https://raspisms.fr

View File

@ -42,6 +42,9 @@
<script src="<?php echo HTTP_PWD_JS; ?>/datatables/datatables.min.js"></script> <script src="<?php echo HTTP_PWD_JS; ?>/datatables/datatables.min.js"></script>
<!-- Qrcode lib --> <!-- Qrcode lib -->
<script src="<?php echo HTTP_PWD_JS; ?>/qrcode.min.js"></script> <script src="<?php echo HTTP_PWD_JS; ?>/qrcode.min.js"></script>
<!-- Chartjs -->
<script src="<?php echo HTTP_PWD_JS; ?>/chart.js"></script>
<!-- Custom JS --> <!-- Custom JS -->
<script src="<?php echo HTTP_PWD_JS; ?>/custom.js"></script> <script src="<?php echo HTTP_PWD_JS; ?>/custom.js"></script>

View File

@ -122,6 +122,11 @@
</ul> </ul>
</li> </li>
<?php } ?> <?php } ?>
<?php if (!in_array('stats', json_decode($_SESSION['user']['settings']['hide_menus'], true) ?? [])) { ?>
<li <?php echo $page == 'stats' ? 'class="active"' : ''; ?>>
<a href="<?php echo \descartes\Router::url('Stat', 'sms_status'); ?>"><i class="fa fa-fw fa-area-chart"></i> Statistiques</a>
</li>
<?php } ?>
<?php if (!in_array('settings', json_decode($_SESSION['user']['settings']['hide_menus'], true) ?? [])) { ?> <?php if (!in_array('settings', json_decode($_SESSION['user']['settings']['hide_menus'], true) ?? [])) { ?>
<li <?php echo $page == 'settings' ? 'class="active"' : ''; ?>> <li <?php echo $page == 'settings' ? 'class="active"' : ''; ?>>
<a href="<?php echo \descartes\Router::url('Setting', 'show'); ?>"><i class="fa fa-fw fa-cogs"></i> Réglages</a> <a href="<?php echo \descartes\Router::url('Setting', 'show'); ?>"><i class="fa fa-fw fa-cogs"></i> Réglages</a>

View File

@ -62,9 +62,11 @@
</div> </div>
<div class="text-right col-xs-6 no-padding"> <div class="text-right col-xs-6 no-padding">
<strong>Action pour la séléction :</strong> <strong>Action pour la séléction :</strong>
<button class="btn btn-default" type="submit" formaction="<?php echo \descartes\Router::url('Phone', 'update_status', ['csrf' => $_SESSION['csrf']]); ?>"><span class="fa fa-refresh"></span> Rafraichir le status</button> <button class="btn btn-default mb-2" type="submit" formaction="<?php echo \descartes\Router::url('Phone', 'update_status', ['csrf' => $_SESSION['csrf']]); ?>"><span class="fa fa-refresh"></span> Rafraichir le status</button>
<button class="btn btn-default" type="submit" formaction="<?php echo \descartes\Router::url('Phone', 'edit'); ?>"><span class="fa fa-edit"></span> Modifier</button> <button class="btn btn-default mb-2" type="submit" formaction="<?php echo \descartes\Router::url('Phone', 'change_status', ['csrf' => $_SESSION['csrf'], 'new_status' => 'available']); ?>"><span class="fa fa-toggle-on"></span> Activer</button>
<button class="btn btn-default btn-confirm" type="submit" formaction="<?php echo \descartes\Router::url('Phone', 'delete', ['csrf' => $_SESSION['csrf']]); ?>"><span class="fa fa-trash-o"></span> Supprimer</button> <button class="btn btn-default mb-2" type="submit" formaction="<?php echo \descartes\Router::url('Phone', 'change_status', ['csrf' => $_SESSION['csrf'], 'new_status' => 'disabled']); ?>"><span class="fa fa-toggle-off"></span> Désactiver</button>
<button class="btn btn-default mb-2" type="submit" formaction="<?php echo \descartes\Router::url('Phone', 'edit'); ?>"><span class="fa fa-edit"></span> Modifier</button>
<button class="btn btn-default btn-confirm mb-2" type="submit" formaction="<?php echo \descartes\Router::url('Phone', 'delete', ['csrf' => $_SESSION['csrf']]); ?>"><span class="fa fa-trash-o"></span> Supprimer</button>
</div> </div>
</div> </div>
</form> </form>
@ -105,6 +107,10 @@ jQuery(document).ready(function ()
html += ' - <span class="text-success">Disponible</span>' html += ' - <span class="text-success">Disponible</span>'
break; break;
case 'disabled':
html += ' - <span class="text-warning">Désactivé</span>'
break;
case 'unavailable': case 'unavailable':
html += ' - <span class="text-danger">Indisponible</span>' html += ' - <span class="text-danger">Indisponible</span>'
break; break;

View File

@ -14,6 +14,7 @@
<div class="col-lg-12"> <div class="col-lg-12">
<h1 class="page-header"> <h1 class="page-header">
Dashboard <small>SMS envoyés</small> Dashboard <small>SMS envoyés</small>
<a class="btn btn-warning float-right" id="btn-invalid-numbers" href="#"><span class="fa fa-eraser"></span> Télécharger les numéros invalides</a>
</h1> </h1>
<ol class="breadcrumb"> <ol class="breadcrumb">
<li> <li>
@ -65,9 +66,122 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal fade" tabindex="-1" id="invalid-numbers-modal">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<form id="invalid-numbers-form" action="<?php $this->s(\descartes\Router::url('Api', 'get_invalid_numbers')); ?>" method="GET">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title">Télécharger les numéros invalides</h4>
</div>
<div class="modal-body">
<p class="help">Vous pouvez téléchager une liste de destinataires qui affichent un taux d'erreur anormal selon les critères de votre choix (liste limitée à 25 000 numéros).</p>
<div class="form-group">
<label>Volume minimum de SMS envoyés au numéros</label>
<div class="form-group input-group">
<span class="input-group-addon"><span class="fa fa-arrow-circle-up"></span></span>
<input name="volume" class="form-control" type="number" min="1" step="1" placeholder="" autofocus required>
</div>
</div>
<div class="form-group">
<label>Pourcentage d'échecs minimum</label>
<div class="form-group input-group">
<span class="input-group-addon"><span class="fa fa-percent"></span></span>
<input name="percent_failed" class="form-control" type="number" min="0" step="1" placeholder="" autofocus required>
</div>
</div>
<div class="form-group">
<label>Pourcentage d'inconnus minimum</label>
<div class="form-group input-group">
<span class="input-group-addon"><span class="fa fa-percent"></span></span>
<input name="percent_unknown" class="form-control" type="number" min="0" step="1" placeholder="" autofocus required>
</div>
</div>
<div id="invalid-numbers-loader" class="text-center hidden"><div class="loader"></div></div>
</div>
<div class="modal-footer">
<a type="button" class="btn btn-danger" data-dismiss="modal">Annuler</a>
<input type="submit" class="btn btn-success" value="Valider" />
</div>
</form>
</div>
</div>
</div>
<script> <script>
jQuery(document).ready(function () jQuery(document).ready(function ()
{ {
jQuery('body').on('click', '#btn-invalid-numbers', function ()
{
jQuery('#invalid-numbers-modal').modal({'keyboard': true});
});
jQuery('body').on('submit', '#invalid-numbers-form', function (e)
{
e.preventDefault();
jQuery('#invalid-numbers-loader').removeClass('hidden');
const form = this;
const formData = jQuery(form).serialize();
let invalidNumbers = []; // Array to store cumulative results
// Function to fetch data and handle pagination
const fetchData = (url, limit = -1, params = null) => {
if (params) {
url += '?' + params;
}
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(jsonResponse => {
invalidNumbers = invalidNumbers.concat(jsonResponse.response);
// Check if there is a "next" URL to fetch more data
if (jsonResponse.next && limit != 0) {
fetchData(jsonResponse.next, limit - 1); // Recursive call for next page
} else {
exportToCSV(invalidNumbers);
jQuery('#invalid-numbers-loader').addClass('hidden');
}
})
.catch(error => {
console.error('There was a problem with the fetch operation:', error);
});
};
// Function to export data to CSV
const exportToCSV = (results) => {
// Define the CSV headers
let csvContent = "Destination,Total SMS Sent,Failed Percentage,Unknown Percentage\n";
// Append each row of data to the CSV content
results.forEach(item => {
csvContent += `${item.destination},${item.total_sms_sent},${item.failed_percentage},${item.unknown_percentage}\n`;
});
// Create a downloadable link for the CSV file
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const downloadLink = document.createElement('a');
downloadLink.href = url;
downloadLink.download = 'invalid_numbers.csv';
// Trigger download
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink); // Clean up
};
// Initial call to fetch data
fetchData(form.action, 1000, formData);
});
jQuery('.datatable').DataTable({ jQuery('.datatable').DataTable({
"pageLength": 25, "pageLength": 25,
"lengthMenu": [[25, 50, 100, 1000, 10000, Math.pow(10, 10)], [25, 50, 100, 1000, 10000, "All"]], "lengthMenu": [[25, 50, 100, 1000, 10000, Math.pow(10, 10)], [25, 50, 100, 1000, 10000, "All"]],

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,217 @@
<?php
//Template dashboard
$this->render('incs/head')
?>
<div id="wrapper">
<?php
$this->render('incs/nav', ['page' => 'stats'])
?>
<div id="page-wrapper">
<div class="container-fluid">
<!-- Page Heading -->
<div class="row">
<div class="col-lg-12">
<h1 class="page-header">
Statistiques <small>Statistiques avancées</small>
</h1>
<ol class="breadcrumb">
<li class="active">
<i class="fa fa-dashboard"></i> Statistiques avancées
</li>
</ol>
</div>
</div>
<!-- /.row -->
<div class="row">
<div class="col-lg-12">
<div class="panel panel-default dashboard-panel-chart">
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-area-chart fa-fw"></i> Status des SMS envoyés par téléphone : </h3>
</div>
<div class="panel-body">
<form id="sms-status-form" class="form-inline text-right mb-3" action="" method="POST">
<div class="form-group">
<label for="id_phone">Téléphone : </label>
<div class="form-group">
<select id="id_phone" name="id_phone" class="form-control">
<option value="">Tous les téléphones</option>
<?php foreach ($phones as $phone) { ?>
<option value="<?php $this->s($phone['id']); ?>"><?php $this->s($phone['name']); ?></option>
<?php } ?>
</select>
</div>
</div>
<div class="form-group ml-4">
<label for="start">Période : </label>
<input id="start" name="start" class="form-control form-date auto-width" type="date" value="<?php $this->s($seven_days_ago->format('Y-m-d')) ?>">
- <input id="end" name="end" class="form-control form-date auto-width" type="date" value="<?php $this->s($now->format('Y-m-d')) ?>">
</div>
<input type="submit" class="btn btn-success ml-4" value="Valider" />
</form>
<canvas id="bar-chart-sms-status"></canvas>
<div id="bar-chart-sms-status-loader" class="text-center mb-5"><div class="loader"></div></div>
</div>
</div>
</div>
</div>
<!-- /.row -->
</div>
<!-- /.container-fluid -->
</div>
<!-- /#page-wrapper -->
</div>
<script>
smsStatusChart = null;
const phones = {};
for (const phone of <?= json_encode($phones); ?>) {
phones[phone.id] = phone;
};
async function drawChart(e = null) {
const startDate = new Date(document.getElementById('start').value);
const formatedStartDate = startDate.toISOString().split('T')[0]
const endDate = new Date(document.getElementById('end').value);
const formatedEndDate = endDate.toISOString().split('T')[0]
const id_phone = document.getElementById('id_phone').value;
let url = <?= json_encode(\descartes\Router::url('Api', 'get_sms_status_stats'))?>;
url += `?start=${formatedStartDate}&end=${formatedEndDate}`;
url += id_phone ? `&id_phone=${id_phone}` : '';
const response = await fetch(url);
const data = (await response.json()).response;
// Get all dates to avoid holes in data
const dates = [];
let currentDate = new Date(startDate);
while (currentDate <= endDate) {
const formated_date = (new Date(currentDate)).toISOString().split('T')[0]
dates.push(formated_date);
currentDate.setDate(currentDate.getDate() + 1);
}
const empty_dataset = Array(dates.length + 1).fill(0)
const colors = {'failed': '#d9534f', 'unknown': '#337ab7', 'delivered': '#5cb85c'};
let datasets = {};
for (const entry of data) {
if (!datasets[entry.id_phone]) {
datasets[entry.id_phone] = {
'failed': {
'data': [...empty_dataset],
'label': `Phone ${phones[entry.id_phone]['name']} - Failed`,
'backgroundColor': colors['failed'],
'stack': entry.id_phone,
},
'unknown': {
'data': [...empty_dataset],
'label': `Phone ${phones[entry.id_phone]['name']} - Unknown`,
'backgroundColor': colors['unknown'],
'stack': entry.id_phone,
},
'delivered': {
'data': [...empty_dataset],
'label': `Phone ${phones[entry.id_phone]['name']} - Delivered`,
'backgroundColor': colors['delivered'],
'stack': entry.id_phone,
},
};
}
const date_index = dates.indexOf(entry.at_ymd);
// This should never happen, but better be sure
if (date_index == -1) {
throw Error('Data for a date not in dates array');
}
datasets[entry.id_phone][entry.status]['data'][date_index] = entry.nb;
}
// Pass all from dict to array
const formated_datasets = [];
for (const key in datasets) {
formated_datasets.push(datasets[key]['failed']);
formated_datasets.push(datasets[key]['unknown']);
formated_datasets.push(datasets[key]['delivered']);
}
// Custom plugin to display "Pas de données sur cette période"
const noDataPlugin = {
id: 'noDataPlugin',
afterDraw: (chart) => {
const datasets = chart.data.datasets;
const hasData = datasets.some(dataset => dataset.data.some(value => value !== null && value !== undefined && value !== 0));
if (!hasData) {
const ctx = chart.ctx;
const { width, height } = chart;
ctx.save();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '3em Helvetica';
ctx.fillText('Pas de données sur cette période', width / 2, height / 2);
ctx.restore();
}
}
};
// Create the chart
const ctx = document.getElementById('bar-chart-sms-status');
const config = {
type: 'bar',
data: {
labels: dates,
datasets: formated_datasets,
},
options: {
responsive: true,
scales: {
x: {
stacked: true,
},
y: {
stacked: true,
beginAtZero: true
}
}
},
plugins: [noDataPlugin],
};
document.getElementById('bar-chart-sms-status-loader').classList.add('hidden');
// On first run create chart, after update
if (!smsStatusChart) {
smsStatusChart = new Chart(ctx, config);
} else {
for (const key in config) {
smsStatusChart[key] = config[key];
}
smsStatusChart.update();
}
}
jQuery(document).ready(function()
{
drawChart();
});
jQuery('#sms-status-form').on('submit', (e) => {
e.preventDefault();
drawChart();
return false;
});
</script>
<!-- /#wrapper -->
<?php
$this->render('incs/footer');

View File

@ -49,6 +49,7 @@
<option value="send_sms" <?= ($_SESSION['previous_http_post']['type'] ?? '') == 'send_sms' ? 'selected' : '' ?>>Envoi d'un SMS</option> <option value="send_sms" <?= ($_SESSION['previous_http_post']['type'] ?? '') == 'send_sms' ? 'selected' : '' ?>>Envoi d'un SMS</option>
<option value="send_sms_status_change" <?= ($_SESSION['previous_http_post']['type'] ?? '') == 'send_sms_status_change' ? 'selected' : '' ?>>Mise à jour du statut d'un SMS envoyé</option> <option value="send_sms_status_change" <?= ($_SESSION['previous_http_post']['type'] ?? '') == 'send_sms_status_change' ? 'selected' : '' ?>>Mise à jour du statut d'un SMS envoyé</option>
<option value="inbound_call" <?= ($_SESSION['previous_http_post']['type'] ?? '') == 'inbound_call' ? 'selected' : '' ?>>Réception d'un appel téléphonique</option> <option value="inbound_call" <?= ($_SESSION['previous_http_post']['type'] ?? '') == 'inbound_call' ? 'selected' : '' ?>>Réception d'un appel téléphonique</option>
<option value="phone_reliability" <?= ($_SESSION['previous_http_post']['type'] ?? '') == 'phone_reliability' ? 'selected' : '' ?>>Détection d'un problème de fiabilité sur un téléphone</option>
</select> </select>
</div> </div>
<a class="btn btn-danger" href="<?php echo \descartes\Router::url('Webhook', 'list'); ?>">Annuler</a> <a class="btn btn-danger" href="<?php echo \descartes\Router::url('Webhook', 'list'); ?>">Annuler</a>

View File

@ -52,6 +52,7 @@
<option <?php echo $webhook['type'] == 'send_sms' ? 'selected="selected"' : '' ?> value="send_sms">Envoi d'un SMS</option> <option <?php echo $webhook['type'] == 'send_sms' ? 'selected="selected"' : '' ?> value="send_sms">Envoi d'un SMS</option>
<option <?php echo $webhook['type'] == 'send_sms_status_change' ? 'selected="selected"' : '' ?> value="send_sms_status_change">Mise à jour du statut d'un SMS envoyé</option> <option <?php echo $webhook['type'] == 'send_sms_status_change' ? 'selected="selected"' : '' ?> value="send_sms_status_change">Mise à jour du statut d'un SMS envoyé</option>
<option <?php echo $webhook['type'] == 'inbound_call' ? 'selected="selected"' : '' ?> value="inbound_call">Réception d'un appel téléphonique</option> <option <?php echo $webhook['type'] == 'inbound_call' ? 'selected="selected"' : '' ?> value="inbound_call">Réception d'un appel téléphonique</option>
<option <?php echo $webhook['type'] == 'phone_reliability' ? 'selected="selected"' : '' ?> value="phone_reliability">Détection d'un problème de fiabilité sur un téléphone</option>
</select> </select>
</div> </div>
<hr/> <hr/>

View File

@ -96,6 +96,8 @@ jQuery(document).ready(function ()
return 'Réception de SMS'; return 'Réception de SMS';
case 'inbound_call': case 'inbound_call':
return 'Réception d\'un appel téléphonique'; return 'Réception d\'un appel téléphonique';
case 'phone_reliability':
return 'Détection d\'un problème de fiabilité sur un téléphone';
default: default:
return 'Inconnu'; return 'Inconnu';
} }