[VoiceCallHistory]

- implemented VoiceCallHistoryJob for running the jobs
- implement /VoiceCallHistory for displaying, importing the Voice Call History
- added date-range-picker to tt-table (still todo)
This commit is contained in:
Luca Haid
2024-04-10 15:17:02 +02:00
parent 9d5be184a2
commit b42409874b
10 changed files with 651 additions and 3 deletions

View File

@@ -0,0 +1,88 @@
<?php /** @noinspection PhpUndefinedClassInspection
* @var string $mfLayoutPackage
* @var TYPE_NAME $git_merge_ts
*/
//additional css /css/views/RaspberryDisplay.css
$JSGlobals = ["BASE_URL" => self::getUrl("Domain"),
"DASHBOARD_URL" => self::getUrl("Dashboard"),
"MFAPPNAME" => MFAPPNAME_SLUG,
"PAGE_TITLE" => "Domains",
"PATH" => [
["text" => MFAPPNAME_SLUG, "href" => self::getUrl("Dashboard")],
["text" => "Voice Calls History", "href" => self::getUrl("VoiceCallHistory")]
],
"VOICE_CALL_HISTORY_API_URL" => self::getUrl("VoiceCallHistory/api"),
];
$additionalJS = ["plugins/vue/vue.js",
"plugins/axios/axios.min.js",
"plugins/vue/tt-components/tt-page-title.js",
"plugins/vue/tt-components/tt-table.js",
];
$additionalCSS = [
'plugins/vue/tt-components/css/tt-table.css',
];
include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/header.php"); ?>
<div id="app">
<tt-page-title :title="window['TT_CONFIG']['PAGE_TITLE']" :path="window['TT_CONFIG']['PATH']"></tt-page-title>
<tt-table :fetch-url="window['TT_CONFIG']['VOICE_CALL_HISTORY_API_URL'] + '?do=getCalls'" :table-config="VoiceCallHistoryTableConfig"
small ref="table">
<template v-slot:top-buttons>
<button type="button" class="btn btn-primary" @click="importCallsFromToday">
<template v-if="importCallsFromTodayLoading">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
</template>
<template v-else>
<i class="fas fa-sync-alt"></i>
Re-Import Calls from Today
</template>
</button>
</template>
</tt-table>
</div>
<!-- TODO: download from cdn to local -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-select@1.13.14/dist/css/bootstrap-select.min.css">
<script type="text/javascript" src="https://cdn.jsdelivr.net/momentjs/latest/moment.min.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/daterangepicker/daterangepicker.min.js"></script>
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/daterangepicker/daterangepicker.css" />
<script>
new Vue({
el: '#app',
data: {
window: window,
VoiceCallHistoryTableConfig: {
headers: [
{text: "UID", key: "uid"},
{text: "Voice Account", key: "voice_account"},
{text: "Start", key: "start", filter: "dateRange"},
{text: "Source", key: "source"},
{text: "Destination", key: "destination"},
{text: "Billable", key: "billable"},
{text: "Duration", key: "duration"},
],
tableHeader: 'Voice Call History',
},
importCallsFromTodayLoading: false,
},
mounted() {},
methods: {
async importCallsFromToday() {
this.importCallsFromTodayLoading = true;
await axios.get(window['TT_CONFIG']['VOICE_CALL_HISTORY_API_URL'] + '?do=importCallsFromToday');
this.$refs.table.fetchData();
this.importCallsFromTodayLoading = false;
},
}
})
</script>
<?php include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/footer.php"); ?>

View File

@@ -125,7 +125,8 @@
</a>
<ul class="submenu">
<?php if($me->isAdmin() || $me->can("Voipnumbering")): ?><li><a href="<?=self::getUrl("Voicenumberblock")?>"><i class="fad fa-fw fa-phone-office text-info"></i> Rufnummernblöcke</a></li><?php endif; ?>
<?php if($me->isAdmin() || $me->can("Voiceplan")): ?><li><a href="<?=self::getUrl("Voiceplan")?>"><i class="fas fa-fw fa-phone-arrow-up-right text-info"></i> Sprachtarife</a></li><?php endif; ?>
<?php if($me->isAdmin() || $me->can("Voiceplan")): ?><li><a href="<?=self::getUrl("Voiceplan")?>"><i class="fas fa-fw fa-phone-arrow-up-right text-info"></i> Sprachtarife</a></li><?php endif; ?>
<?php if($me->isAdmin() || $me->can("VoiceCallHistory")): ?><li><a href="<?=self::getUrl("VoiceCallHistory")?>"><i class="fas fa-fw fa-phone-arrow-down-left text-info"></i> Voice Call History</a></li><?php endif; ?>
</ul>
</li>
<?php endif; ?>

View File

@@ -0,0 +1,9 @@
<?php
/**
* @property mixed|null $name
*/
class VoiceCallHistory extends mfBaseModel
{
}

View File

@@ -0,0 +1,96 @@
<?php
//display errors
//ini_set('display_errors', 1);
//ini_set('display_startup_errors', 1);
//error_reporting(E_ALL);
class VoiceCallHistoryController extends mfBaseController {
private User $me;
private string $VOICE_PORTAL_HOST = "vportal.xinon.at";
private string $VOICE_PORTAL_API_KEY = "2f9mpw3oamALg7gSgtWUTCKNZ01fFRDh";
private string $VOICE_PORTAL_USERNAME = "700342020";
private KolmisoftMore $kolmisoftMore;
protected function init(): void {
$me = new User();
$me->loadMe();
$this->layout()->set("me", $me);
$this->me = $me;
if (!$this->me->isAdmin()) {
$this->redirect("dashboard");
}
$this->kolmisoftMore = new KolmisoftMore($this->VOICE_PORTAL_HOST, $this->VOICE_PORTAL_API_KEY, $this->VOICE_PORTAL_USERNAME);
}
protected function indexAction(): void {
$this->layout()->setTemplate("VoiceCallHistory/Index");
}
protected function apiAction() {
$do = $this->request->do;
if (!$this->me->isAdmin()) {
$this->redirect("dashboard");
}
switch ($do) {
case "getCalls":
$return = $this->getCalls();
break;
case "importCallsFromToday":
$return = $this->importCallsFromToday();
break;
default:
$return = false;
break;
}
if (!$return) {
$return = [
"status" => "error",
"message" => "Invalid request."
];
}
die(json_encode($return));
}
private function importCallsFromToday(): array {
$startDate = strtotime(date("Y-m-d 8:00:00"));
$endDate = strtotime(date("Y-m-d 9:00:59"));
$callHistory = $this->kolmisoftMore->getVoiceCallHistory($startDate, $endDate);
return VoiceCallHistoryModel::importCallsFromKolmisoft($callHistory);
}
private function getCalls(): array {
$json = json_decode(file_get_contents('php://input'), true);
$filters = $json['filters'] ?? [];
$page = $json['pagination']['page'] ?? 1;
$perPage = $json['pagination']['per_page'] ?? 10;
$calls = VoiceCallHistoryModel::getVoiceCallHistory($filters, $perPage, $perPage * $page - $perPage);
$totalRows = VoiceCallHistoryModel::countVoiceCallHistory($filters);
return [
"rows" => $calls,
"pagination" => [
"page" => $page,
"total_pages" => ceil($totalRows / $perPage),
"per_page" => $perPage,
"total_rows" => intval($totalRows)
]
];
}
}

View File

@@ -0,0 +1,146 @@
<?php
class VoiceCallHistoryModel {
public $uid;
public $voice_account;
public $start;
public $source;
public $destination;
public $billable;
public $duration;
public function __construct($data = []) {
foreach ($data as $field => $value) {
if (property_exists(get_called_class(), $field)) {
$this->$field = $value;
}
}
}
/**
* Generate SQL Filter condition (space separated) for a given column.
*
* @param string|null $filterValue The filter value to match against.
* @param string $columnName The name of the column in the database table.
* @return string The SQL condition generated based on the filter value and column name.
*/
public static function generateFilterCondition(?string $filterValue, string $columnName): string {
$sql = "";
if (!empty($filterValue)) {
$filterItems = explode(" ", $filterValue);
foreach ($filterItems as $item) {
$sql .= " AND `$columnName` LIKE '%" . $item . "%'";
}
}
return $sql;
}
//TODO: combine these two functions into one
public static function importCallsFromKolmisoft($callHistory): array {
{
$modifiedCallHistory = [];
for ($i = 0; $i < count($callHistory); $i++) {
$call = $callHistory[$i];
$date = date("Y-m-d H:i:s", strtotime($call["calldate2"] . " UTC"));
$date = new DateTime($date, new DateTimeZone('UTC'));
$date->setTimezone(new DateTimeZone('Europe/Vienna'));
$date = $date->format('Y-m-d H:i:s');
$modifiedCall = [
"uid" => $call["uniqueid"],
"voice_account" => strpos($call["clid"], "Anonymous") !== false ? 'Anonymous' : preg_replace('/[^0-9]/', '', explode(" ", $call["clid"])[0]),
"start" => $date,
"source" => strpos($call["clid"], "nymous") !== false ? 'Anonymous' : str_replace("+", "", $call["src"]),
"destination" => $call["dst"],
"state" => preg_replace('/[^0-9]/', '', explode("(", $call["dispod"])[1]),
"billable" => gettype($call["did"]) === "string" ? 0 : 1,
"duration" => $call["nice_billsec"],
];
$modifiedCallHistory[] = $modifiedCall;
}
$voiceHistoryImport = VoiceCallHistoryModel::importVoiceCallHistory($modifiedCallHistory);
if ($callHistory === false) {
return [
"status" => "error",
"message" => "Failed to import calls."
];
}
return [
"status" => "success",
"message" => $voiceHistoryImport["message"]
];
}
}
public static function importVoiceCallHistory($callHistory): array {
$db = FronkDB::singleton();
$sql = /** @lang text */ "INSERT INTO `VoiceCallHistory` (`uid`, `voice_account`, `start`, `source`, `destination`, `state`, `billable`, `duration`) VALUES ";
$values = [];
foreach ($callHistory as $voiceCall) {
$uid = $voiceCall['uid'];
$valueStr = "(" .
($voiceCall['uid'] === 'Anonymous' ? 'NULL' : "'$uid'") . ", " .
($voiceCall['voice_account'] === 'Anonymous' ? 'NULL' : "'" . $voiceCall['voice_account']. "'" ) . ", '" .
$voiceCall['start'] . "', '" .
($voiceCall['source'] === 'Anonymous' ? "NULL" : $voiceCall['source']) . "', '" .
($voiceCall['destination'] === 'Anonymous' ? "NULL" : $voiceCall['destination'] ) . "', " .
$voiceCall['state'] . ", " .
$voiceCall['billable'] . ", " .
$voiceCall['duration'] . ")";
$values[] = $valueStr;
}
$sql .= implode(", ", $values);
$sql .= " ON DUPLICATE KEY UPDATE duration = VALUES(duration)";
$db->query($sql);
return [
"message" => "Imported " . count($callHistory) . " calls to the history."
];
}
public static function getSqlFilter($filters): string {
$sql = "";
if (isset($filters['start']['from']) && isset($filters['start']['to'])) {
$sql = " AND `start` >= FROM_UNIXTIME(" . $filters['start']['from'] . ") AND `start` <= FROM_UNIXTIME(" . $filters['start']['to'] . ")";
}
$sql .= isset($filters['end']) ? " AND `start` <= '" . $filters['end'] . "'" : "";
$sql .= isset($filters['source']) ? self::generateFilterCondition($filters['source'], "source") : "";
$sql .= isset($filters['destination']) ? self::generateFilterCondition($filters['destination'], "destination") : "";
$sql .= isset($filters['billable']) ? self::generateFilterCondition($filters['billable'], "billable") : "";
$sql .= isset($filters['duration']) ? self::generateFilterCondition($filters['duration'], "duration") : "";
return $sql;
}
public static function getVoiceCallHistory($filters, $limit = null, $offset = 0): array {
$db = FronkDB::singleton();
$sql = "SELECT * FROM `VoiceCallHistory` WHERE 1 " . self::getSqlFilter($filters);
$sql .= $limit === null ? "" : " LIMIT " . $limit . " OFFSET " . $offset;
$result = $db->query($sql);
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = new VoiceCallHistoryModel($row);
}
return $rows;
}
public static function countVoiceCallHistory($filters) {
$db = FronkDB::singleton();
$sql = "SELECT COUNT(*) as `total_rows` FROM `VoiceCallHistory` WHERE 1 " . self::getSqlFilter($filters);
$result = $db->query($sql);
return $result->fetch_assoc()['total_rows'];
}
}

View File

@@ -0,0 +1,9 @@
<?php
/**
* @property mixed|null $name
*/
class VoiceCallHistoryJob extends mfBaseModel
{
}

View File

@@ -0,0 +1,92 @@
<?php
//display errors
//ini_set('display_errors', 1);
//ini_set('display_startup_errors', 1);
//error_reporting(E_ALL);
class VoiceCallHistoryJobController extends mfBaseController {
private User $me;
private string $VOICE_PORTAL_HOST = "vportal.xinon.at";
private string $VOICE_PORTAL_API_KEY = "2f9mpw3oamALg7gSgtWUTCKNZ01fFRDh";
private string $VOICE_PORTAL_USERNAME = "700342020";
private KolmisoftMore $kolmisoftMore;
protected function init(): void {
$me = new User();
$me->loadMe();
$this->layout()->set("me", $me);
$this->me = $me;
if (!$this->me->isAdmin()) {
$this->redirect("dashboard");
}
$this->kolmisoftMore = new KolmisoftMore($this->VOICE_PORTAL_HOST, $this->VOICE_PORTAL_API_KEY, $this->VOICE_PORTAL_USERNAME);
}
protected function indexAction(): void {
$this->layout()->setTemplate("VoiceCallHistoryJob/Index");
}
protected function apiAction() {
$do = $this->request->do;
if (!$this->me->isAdmin()) {
$this->redirect("dashboard");
}
switch ($do) {
case "runJobs":
$return = $this->runJobs();
break;
case "importCallsFromToday":
$return = $this->importCallsFromToday();
break;
default:
$return = false;
break;
}
if (!$return) {
$return = [
"status" => "error",
"message" => "Invalid request."
];
}
die(json_encode($return));
}
private function runJobs(): array {
VoiceCallHistoryJobModel::createJobsUntilToday();
$jobs = VoiceCallHistoryJobModel::getJobsNotDone();
$messages = [
"success" => [],
"error" => []
];
foreach ($jobs as $job) {
$startDate = strtotime(date("Y-m-d 00:00:00", strtotime($job->date)));
$endDate = strtotime(date("Y-m-d 00:00:00", strtotime($job->date . " +1 day")));
$callHistory = $this->kolmisoftMore->getVoiceCallHistory($startDate, $endDate);
$importedCalls = VoiceCallHistoryModel::importCallsFromKolmisoft($callHistory);
if ($importedCalls) {
$messages["success"][$job->date] = $importedCalls["message"];
VoiceCallHistoryJobModel::updateJobStatus($job->id, "success");
} else {
$messages["error"][$job->date] = "Failed to import calls for job $job->id.";
VoiceCallHistoryJobModel::updateJobStatus($job->id, "failed");
}
}
return $messages;
}
}

View File

@@ -0,0 +1,86 @@
<?php
class VoiceCallHistoryJobModel {
public $id;
public $date;
public $status;
public $create;
public $edit;
public function __construct($data = []) {
foreach ($data as $field => $value) {
if (property_exists(get_called_class(), $field)) {
$this->$field = $value;
}
}
}
/**
* Generate SQL Filter condition (space separated) for a given column.
*
* @param string|null $filterValue The filter value to match against.
* @param string $columnName The name of the column in the database table.
* @return string The SQL condition generated based on the filter value and column name.
*/
public static function generateFilterCondition(?string $filterValue, string $columnName): string {
$sql = "";
if (!empty($filterValue)) {
$filterItems = explode(" ", $filterValue);
foreach ($filterItems as $item) {
$sql .= " AND `$columnName` LIKE '%" . $item . "%'";
}
}
return $sql;
}
public static function createJobsUntilToday(): array {
$db = FronkDB::singleton();
// $i = first day of the month; $i <= today; $i += 1 day
$values = [];
for ($i = strtotime(date("Y-m-01")); $i <= strtotime(date("Y-m-d")); $i += 86400) {
$values[] = "('" . date("Y-m-d", $i) . "')";
}
$valueStr = implode(", ", $values);
$db->query("INSERT INTO `VoiceCallHistoryJob` (`date`) VALUES $valueStr ON DUPLICATE KEY UPDATE date=VALUES(date)");
$db->query("UPDATE `VoiceCallHistoryJob` SET `status` = 'created', `finished` = NULL WHERE `date` = '" . date("Y-m-d") . "'");
return [
"message" => "Created " . count($values) . " jobs."
];
}
public static function updateJobStatus($id, $status): array {
$db = FronkDB::singleton();
$escapedStatus = $db->escape($status);
$escapedId = $db->escape($id);
$finished = $status == "success" ? ", `finished` = NOW()" : "";
$db->query("UPDATE `VoiceCallHistoryJob` SET `status` = '$escapedStatus' $finished WHERE `id` = $escapedId");
return [
"message" => "Updated job $id status to $status."
];
}
public static function getJobsNotDone(): array {
$db = FronkDB::singleton();
$query = $db->query("SELECT * FROM `VoiceCallHistoryJob` WHERE `status` = 'created' OR `status` = 'failed' OR `status` = 'pending' ORDER BY `date`");
$items = [];
if($db->num_rows($query)) {
while($data = $db->fetch_object($query)) {
$items[] = new VoiceCallHistoryJobModel($data);
}
}
return $items;
}
}

View File

@@ -0,0 +1,42 @@
<?php /** @noinspection ALL */
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddVoiceCallHistory extends AbstractMigration {
public function up(): void {
if ($this->getEnvironment() == "thetool") {
//VoiceCallHistory Table
$voiceCallHistoryTable = $this->table("VoiceCallHistory", ["signed" => true]);
$voiceCallHistoryTable->addColumn("id", "integer", ["identity" => true]);
$voiceCallHistoryTable->addColumn("uid", "string", ["null" => false, "limit" => 255]);
$voiceCallHistoryTable->addColumn("voice_account", "string", ["null" => false, "limit" => 255]);
$voiceCallHistoryTable->addColumn("start", "datetime", ["null" => false]);
$voiceCallHistoryTable->addColumn("source", "string", ["null" => true, "limit" => 255]);
$voiceCallHistoryTable->addColumn("destination", "string", ["null" => true, "limit" => 255]);
$voiceCallHistoryTable->addColumn("state", "integer", ["null" => true]);
$voiceCallHistoryTable->addColumn("billable", "integer", ["null" => false, "limit" => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY]);
$voiceCallHistoryTable->addColumn("duration", "integer", ["null" => false]);
//VoiceCallHistory Table Indexes
$voiceCallHistoryTable->addIndex(["billable"]);
$voiceCallHistoryTable->addIndex(["voice_account"]);
//VoiceCallHistoryJob Table
$voiceCallHistoryJobTable = $this->table("VoiceCallHistoryJob", ["signed" => true]);
$voiceCallHistoryJobTable->addColumn("id", "integer", ["identity" => true]);
$voiceCallHistoryJobTable->addColumn("date", "date", ["null" => false]);
$voiceCallHistoryJobTable->addColumn("status", "enum", ["values" => ["created", "pending", "running", "success", "failed"], "default" => "created", "null" => false]);
$voiceCallHistoryJobTable->addColumn("finished", "date", ["null" => true]);
$voiceCallHistoryJobTable->addIndex(["date"], ["unique" => true]);
}
}
public function down(): void {
if ($this->getEnvironment() == "thetool") {
$this->table("VoiceCallHistory")->drop()->save();
$this->table("VoiceCallHistoryJob")->drop()->save();
}
}
}

View File

@@ -17,6 +17,84 @@
* @property {string} class - The CSS class(es) applied to the column.
*/
//TODO: export this to its own file
Vue.component('tt-date-range', {
template: `
<input type="text" class="form-control form-control-sm" ref="input" @click="initialize" style="cursor: pointer;background-color: #ffffff">
`, props: ['value'],
data() {
return {
inputValue: '',
isInitialized: false,
locale: {
"format": "DD.MM.YYYY HH:mm",
"separator": " - ",
"applyLabel": "Übernehmen",
"cancelLabel": "Abbrechen",
"fromLabel": "Von",
"toLabel": "Bis",
"customRangeLabel": "Benutzerdefiniert",
"weekLabel": "W",
"daysOfWeek": [
"So",
"Mo",
"Di",
"Mi",
"Do",
"Fr",
"Sa"
],
"monthNames": [
"Januar",
"Februar",
"März",
"April",
"Mai",
"Juni",
"Juli",
"August",
"September",
"Oktober",
"November",
"Dezember"
],
"firstDay": 1
},
}
},
methods: {
initialize() {
if (!this.isInitialized) {
this.isInitialized = true;
$(this.$refs.input).daterangepicker({
autoUpdateInput: true,
timePicker: true,
timePicker24Hour: true,
locale: this.locale,
});
this.$refs.input.click();
const _this = this;
$(this.$refs.input).on('apply.daterangepicker', function(ev, picker) {
console.log('now emitting chang', picker.startDate.unix(), picker.endDate.unix());
_this.$emit('change', {
target: {
value: {
from: picker.startDate.unix() + 7200,
to: picker.endDate.unix() + 7200
}
}
});
});
}
}
},
beforeDestroy() {
$(this.$refs.input).off('apply.daterangepicker');
},
})
Vue.component('tt-table', {
template: `
<div class="card tt-table-card">
@@ -65,7 +143,7 @@ Vue.component('tt-table', {
</li>
</ul>
<span class="text-center"
v-text="Math.min(pagination.page * pagination.per_page - pagination.per_page + 1, pagination.total_rows)
v-text="Math.max(pagination.page * pagination.per_page - pagination.per_page + 1, pagination.total_rows)
+ ' bis ' + Math.min(pagination.page * pagination.per_page, pagination.total_rows) + ' von ' + pagination.total_rows"></span>
<select v-model="pagination.per_page" v-on:change="fetchRows(1)" class="form-control form-control-sm">
<option value="10">10</option>
@@ -82,7 +160,7 @@ Vue.component('tt-table', {
:class="['table','tt-table','table-condensed',{ 'loading': loading },{ 'table-striped': striped },{ 'table-bordered': bordered },{ 'table-hover': hover },{ 'table-sm': small }]">
<thead>
<tr>
<th scope="col" v-for="column in columns" style="vertical-align: top; text-align: center">
<th scope="col" v-for="column in columns" :style="'vertical-align: top; text-align: center;' + (column.filter === 'dateRange' ? 'min-width: 260px;' : '')">
<div style="text-align:center">{{ column.text }}</div>
<input v-if="column.filter === 'search'" type=text v-on:input="applyFilter($event, column.key)"
class="form-control form-control-sm">
@@ -93,6 +171,7 @@ Vue.component('tt-table', {
{{ filterOption.text }}
</option>
</select>
<tt-date-range v-if="column.filter === 'dateRange'" v-on:change="applyFilter($event, column.key)"></tt-date-range>
</th>
</tr>
</thead>