Sfoglia il codice sorgente

local modifications

Hal De 4 anni fa
parent
commit
014d197589
5 ha cambiato i file con 192 aggiunte e 2 eliminazioni
  1. 1 0
      .gitignore
  2. 80 2
      app/app.py
  3. 90 0
      app/cel.py
  4. 20 0
      docker-compose.yml
  5. 1 0
      ipt.sh

+ 1 - 0
.gitignore

@@ -1,3 +1,4 @@
 .env
 app.env
 __pycache__
+app/static

+ 80 - 2
app/app.py

@@ -114,7 +114,8 @@ class AuthMiddleware:
         return await self.app(scope, receive, send)
     # Paths "/openapi.json" and "/ui" do not require auth
     if (('path' in scope) and
-        (scope['path'] in NO_AUTH_ROUTES)):
+        ((scope['path'] in NO_AUTH_ROUTES) or
+         (scope['path'].startswith('/static/records')))):
       return await self.app(scope, receive, send)
     return await self.error_response(receive, send)
   async def error_response(self, receive, send):
@@ -189,6 +190,50 @@ async def getCDR(start=None,
     cdr.append(_cdr[_id])
   return cdr
 
+async def getUserCDR(user,
+                     start=None,
+                     end=None,
+                     direction=None,
+                     limit=None,
+                     offset=None,
+                     order='ASC'):
+  _q = f'''SELECT * FROM cdr AS c INNER JOIN (SELECT linkedid FROM cdr WHERE'''
+  direction=direction.lower()
+  if direction in ('in', True, '1', 'incoming', 'inbound'):
+    direction = 'inbound'
+    _q += f''' dst="{user}"'''
+  elif direction in ('out', False, '0', 'outgoing', 'outbound'):
+    direction = 'outbound'
+    _q += f''' src="{user}"'''
+  else:
+    direction = None
+    _q += f''' (src="{user}" or dst="{user}")'''
+  if end is None:
+    end = dt.now()
+  if start is None:
+    start=(end - td(hours=24))
+  _q += f''' AND calldate BETWEEN "{start}" AND "{end}" GROUP BY linkedid'''
+  if None not in (limit, offset):
+    _q += f''' LIMIT {offset},{limit}'''
+  _q += f''') AS c2 ON c.linkedid = c2.linkedid;'''
+  app.logger.warning('SQL: {}'.format(_q))
+  _cdr = {}
+  async for row in db.iterate(query=_q):
+    if row['linkedid'] in _cdr:
+      _cdr[row['linkedid']].events.add(row)
+    else:
+      _cdr[row['linkedid']]=CdrUserCall(user, row)
+  cdr = []
+  for _id in sorted(_cdr.keys(), reverse = True if (order.lower() == 'desc') else False):
+    record = _cdr[_id].simple
+    if (direction is not None) and (record['src'] == record['dst']) and (record['direction'] != direction):
+      record['direction'] = direction
+    if record['file'] is not None:
+      record['file'] = '/static/records/{d.year}/{d.month:02}/{d.day:02}/{filename}'.format(d=record['start'],
+                                                                                            filename=record['file'])
+    cdr.append(record)
+  return cdr
+
 async def getCEL(start=None, end=None, table='cel', field='eventtime', sort='id'):
   return await getCDR(start, end, table, field, sort)
 
@@ -501,6 +546,9 @@ async def setQueueStates(user, device, state):
 async def getDeviceUser(device):
   return await amiDBGet('DEVICE', '{}/user'.format(device))
 
+async def getDeviceType(device):
+  return await amiDBGet('DEVICE', '{}/type'.format(device))
+
 async def getDeviceDial(device):
   return await amiDBGet('DEVICE', '{}/dial'.format(device))
 
@@ -570,7 +618,8 @@ async def rebindLostDevices():
   ast = await getGlobalVars()
   for device in app.cache['devices']:
     user = await getDeviceUser(device)
-    if (user != 'none') and (user in app.cache['ustates'].keys()):
+    deviceType = await getDeviceType(device)
+    if (deviceType != 'fixed') and (user != 'none') and (user in app.cache['ustates'].keys()):
       _device = await getUserDevice(user)
       if _device != device:
         app.logger.warning('Fixing bind user {} to device {}'.format(user, device))
@@ -704,9 +753,13 @@ class SetPresenceState(Resource):
       return invalidState(state)
     if user not in app.cache['ustates']:
       return noUser(user)
+    if (state.lower() in ('available','not_set','away','xa','chat')) and (getUserStateCombined(user) == 'dnd'):
+      result = await amiDBDel('DND', '{}'.format(user))
     result = await amiSetVar('PRESENCE_STATE(CustomPresence:{})'.format(user), state)
     if result is not None:
       return errorReply(result)
+    if state.lower() == 'dnd':
+      result = await amiDBPut('DND', '{}'.format(user), 'YES')
     return successfullySetState(user, state)
 
 @app.route('/users/devices')
@@ -845,5 +898,30 @@ class Calls(Resource):
       calls.append(_call)
     return successReply(calls)
 
+@app.route('/user/<user>/calls')
+class UserCalls(Resource):
+  @app.param('user', 'User to query for call stats', 'path')
+  @app.param('end', 'End of datetime range. Defaults to now. Allowed formats are: timestamp, ISO 8601 and YYYYMMDDhhmmss', 'query')
+  @app.param('start', 'Start of datetime range. Defaults to end-24h. Allowed formats are: timestamp, ISO 8601 and YYYYMMDDhhmmss', 'query')
+  @app.param('direction', 'Calls direction, in or out. If not specified both are returned', 'query')
+  @app.param('limit', 'Max number of returned records, defaults to unlimited. Use offset parameter together with limit', 'query')
+  @app.param('offset', 'If limit is specified use offset parameter to request more results', 'query')
+  @app.param('order', 'Calls sort order for datetime field. ASC or DESC. Defaults to ASC', 'query')
+  @app.response(HTTPStatus.OK, 'JSON data {"user":user,"state":state}')
+  @app.response(HTTPStatus.UNAUTHORIZED, 'Authorization required')
+  async def get(self, user):
+    '''Returns user's call stats.
+    '''
+    if user not in app.cache['ustates']:
+      return noUser(user)
+    cdr = await getUserCDR(user,
+                           parseDatetime(request.args.get('start')),
+                           parseDatetime(request.args.get('end')),
+                           request.args.get('direction', None),
+                           request.args.get('limit', None),
+                           request.args.get('offset', None),
+                           request.args.get('order', 'ASC'))
+    return successReply(cdr)
+
 manager.connect()
 app.run(loop=main_loop, host='0.0.0.0', port=app.config['PORT'])

+ 90 - 0
app/cel.py

@@ -2,6 +2,7 @@
 import json
 import re
 from datetime import datetime
+from datetime import timedelta as td
 
 fieldsFilter = ('id', 'linkedid', 'appdata', 'lastdata')
 
@@ -18,6 +19,7 @@ class CdrChannel:
 
 class CdrEvent:
   def __init__(self, eventData):
+    eventData = dict(eventData)
     for key, value in eventData.items():
       if ((key not in fieldsFilter) and (value is not None) and (str(value) != '')):
         if key == 'extra':
@@ -63,6 +65,35 @@ class CdrEvents:
   def __len__(self):
     return len(self._events)
 
+class CdrUserEvents(CdrEvents):
+  def __init__(self, user):
+    self._user = user
+    self._events = []
+    self.start = 0
+    self.end = 0
+  def add(self, event):
+    if not isinstance(event, CdrEvent):
+      event = CdrEvent(event)
+    if self._user in (event.src, event.dst, event.cnum):
+      if len(self._events) == 0:
+        self.start = event.calldate
+        self.end = event.calldate + td(seconds=event.duration)
+        self._events.append(event)
+      else:
+        if (event.calldate + td(seconds=event.duration)) > self.end:
+          self.end = event.calldate + td(seconds=event.duration)
+        if event.calldate < self.start:
+          self.start = event.calldate
+        lo = 0
+        hi = len(self._events)
+        while lo < hi:
+          mid = (lo+hi)//2
+          if event.calldate < self._events[mid].calldate:
+            hi = mid
+          else:
+            lo = mid+1
+        self._events.insert(lo, event)
+
 class CelEvents(CdrEvents):
   def has(self, value, key='eventtype'):
     return super().has(value, key)
@@ -135,6 +166,65 @@ class CdrCall:
   def isAnswered(self):
     return self.disposition == 'ANSWERED';
 
+class CdrUserCall(CdrCall):
+  def __init__(self, user, event=None):
+    self.user = user
+    self.events = CdrUserEvents(user)
+    if event is not None:
+      self.events.add(event)
+      self.linkedid = event['linkedid']
+    else:
+      self.linkedid = None
+  @property
+  def file(self):
+    return self.events.first.recordingfile
+  @property
+  def src(self):
+    return self.events.first.cnum
+  @property
+  def direction(self):
+    if self.user in (self.events.first.src, self.events.first.cnum):
+      return 'outbound'
+    else:
+      return 'inbound'
+  @property
+  def dst(self):
+    return self.events.first.dst
+  @property
+  def did(self):
+    if self.direction == 'inbound':
+      return self.events.first.did
+    else:
+      return None
+  @property
+  def duration(self):
+    if not self.isAnswered:
+      return 0
+    else:
+     return self.events.first.billsec
+  @property
+  def waiting(self):
+    if not self.isAnswered:
+      return self.events.first.duration
+    else:
+      # TODO: exclude time on ivr
+      return self.events.first.duration - self.duration
+  @property
+  def isAnswered(self):
+    return self.disposition == 'ANSWERED';
+  @property
+  def simple(self):
+    return {'start': self.start,
+            'direction': self.direction,
+            'answered': self.isAnswered,
+            'duration': self.duration,
+            'waiting': self.waiting,
+            'src': self.src,
+            'dst': self.dst,
+            'did': self.did,
+            'linkedid': self.linkedid,
+            'file': self.file}
+
 class CelCall:
   def __init__(self, event=None):
     self.events = CelEvents()

+ 20 - 0
docker-compose.yml

@@ -50,6 +50,7 @@ services:
     volumes:
       - /etc/localtime:/etc/localtime:ro
       - ./app:/app
+      - ${PERSISTENT_STORAGE_PATH}/pbx/var/spool/asterisk/monitor:/app/static/records
     command:
       - -p aiohttp
       - -p databases[mysql]
@@ -69,3 +70,22 @@ services:
     - MYSQL_DATABASE
     - FREEPBX_CDRDBNAME
     - MYSQL_SERVER=db
+  nfs:
+    container_name: nfs
+    hostname: nfs.${APP_FQDN}
+    image: itsthenetwork/nfs-server-alpine:latest
+    restart: always
+    env_file:
+    - app.env
+    ports:
+    - 2049:2049
+    volumes:
+      - /etc/localtime:/etc/localtime:ro
+      - ${PERSISTENT_STORAGE_PATH}/pbx/var/spool/asterisk/monitor:/monitor
+    cap_add:
+    - NET_ADMIN
+    privileged: true
+    environment:
+    - READ_ONLY=1
+    - SHARED_DIRECTORY=/monitor
+    - PERMITTED="192.168.171.53"

+ 1 - 0
ipt.sh

@@ -0,0 +1 @@
+iptables -t nat -A PREROUTING -i eth0 -p udp --dport 5060 -j REDIRECT --to-ports 5160