From 04b2728860b10b03d812c603695c2c4dbd4be47b Mon Sep 17 00:00:00 2001
From: Thomas NOEL <tnoel@entrouvert.com>
Date: Wed, 16 Jan 2019 15:36:13 +0100
Subject: [PATCH] add phone calls connector (#29829)

---
 passerelle/apps/phonecalls/__init__.py        |   0
 .../phonecalls/migrations/0001_initial.py     |  54 +++++
 .../apps/phonecalls/migrations/__init__.py    |   0
 passerelle/apps/phonecalls/models.py          | 146 ++++++++++++
 passerelle/settings.py                        |   1 +
 tests/test_phonecalls.py                      | 211 ++++++++++++++++++
 6 files changed, 412 insertions(+)
 create mode 100644 passerelle/apps/phonecalls/__init__.py
 create mode 100644 passerelle/apps/phonecalls/migrations/0001_initial.py
 create mode 100644 passerelle/apps/phonecalls/migrations/__init__.py
 create mode 100644 passerelle/apps/phonecalls/models.py
 create mode 100644 tests/test_phonecalls.py

diff --git a/passerelle/apps/phonecalls/__init__.py b/passerelle/apps/phonecalls/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/passerelle/apps/phonecalls/migrations/0001_initial.py b/passerelle/apps/phonecalls/migrations/0001_initial.py
new file mode 100644
index 0000000..ed8fcf3
--- /dev/null
+++ b/passerelle/apps/phonecalls/migrations/0001_initial.py
@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.17 on 2019-01-21 14:14
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+import jsonfield.fields
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('base', '0010_loggingparameters_trace_emails'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Call',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('callee', models.CharField(max_length=64)),
+                ('caller', models.CharField(max_length=64)),
+                ('start_timestamp', models.DateTimeField(auto_now_add=True)),
+                ('end_timestamp', models.DateTimeField(default=None, null=True)),
+                ('details', jsonfield.fields.JSONField(default={})),
+            ],
+            options={
+                'ordering': ['-start_timestamp'],
+                'verbose_name': 'Phone Call',
+            },
+        ),
+        migrations.CreateModel(
+            name='PhoneCalls',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('title', models.CharField(max_length=50, verbose_name='Title')),
+                ('description', models.TextField(verbose_name='Description')),
+                ('slug', models.SlugField(unique=True, verbose_name='Identifier')),
+                ('max_call_duration', models.PositiveIntegerField(default=120, help_text='Each hour, too long calls are closed.', verbose_name='Maximum duration of a call, in minutes.')),
+                ('data_retention_period', models.PositiveIntegerField(default=60, help_text='Each day, old calls are removed.', verbose_name='Data retention period, in days.')),
+                ('users', models.ManyToManyField(blank=True, to='base.ApiUser')),
+            ],
+            options={
+                'verbose_name': 'Phone Calls',
+            },
+        ),
+        migrations.AddField(
+            model_name='call',
+            name='resource',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='phonecalls.PhoneCalls'),
+        ),
+    ]
diff --git a/passerelle/apps/phonecalls/migrations/__init__.py b/passerelle/apps/phonecalls/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/passerelle/apps/phonecalls/models.py b/passerelle/apps/phonecalls/models.py
new file mode 100644
index 0000000..80658a0
--- /dev/null
+++ b/passerelle/apps/phonecalls/models.py
@@ -0,0 +1,146 @@
+# passerelle - uniform access to multiple data sources and services
+# Copyright (C) 2019  Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from django.db import models
+from django.utils.timezone import now, timedelta, make_naive
+from django.utils.translation import ugettext_lazy as _
+from jsonfield import JSONField
+
+from passerelle.base.models import BaseResource
+from passerelle.utils.api import endpoint
+
+
+class PhoneCalls(BaseResource):
+    category = _('Telephony')
+
+    max_call_duration = models.PositiveIntegerField(
+            _('Maximum duration of a call, in minutes.'),
+            help_text=_('Each hour, too long calls are closed.'),
+            default=120)
+    data_retention_period = models.PositiveIntegerField(
+            _('Data retention period, in days.'),
+            help_text=_('Each day, old calls are removed.'),
+            default=60)
+
+    class Meta:
+        verbose_name = _('Phone Calls')
+
+    @endpoint(name='call-start',
+              perm='can_access',
+              parameters={
+                  'callee': {'description': _('Callee number'),
+                             'example_value': '142'},
+                  'caller': {'description': _('Caller number'),
+                             'example_value': '0143350135'},
+              })
+    def call_start(self, request, callee, caller, **kwargs):
+        existing_call = Call.objects.filter(resource=self,
+                                            callee=callee, caller=caller,
+                                            end_timestamp=None).last()
+        if existing_call:
+            existing_call.details = kwargs
+            existing_call.save()
+            return {'data': existing_call.json()}
+        new_call = Call(resource=self, callee=callee, caller=caller, details=kwargs)
+        new_call.save()
+        return {'data': new_call.json()}
+
+    @endpoint(name='call-stop',
+              perm='can_access',
+              parameters={
+                  'callee': {'description': _('Callee number'),
+                             'example_value': '142'},
+                  'caller': {'description': _('Caller number'),
+                             'example_value': '0143350135'},
+              })
+    def call_stop(self, request, callee, caller, **kwargs):
+        # close all current callee/caller calls
+        data = []
+        for current_call in Call.objects.filter(resource=self,
+                                                callee=callee, caller=caller,
+                                                end_timestamp=None):
+            current_call.end_timestamp = now()
+            current_call.save()
+            data.append(current_call.json())
+        return {'data': data}
+
+    @endpoint(name='calls',
+              perm='can_access',
+              parameters={
+                  'callee': {'description': _('Callee number'),
+                             'example_value': '142'},
+                  'limit': {'description': _('Maximal number of results')},
+              })
+    def calls(self, request, callee=None, caller=None, limit=30):
+        calls = Call.objects.filter(resource=self)
+        if callee:
+            calls = calls.filter(callee=callee)
+        if caller:
+            calls = calls.filter(caller=caller)
+
+        def json_list(calls):
+            return [call.json() for call in calls[:limit]]
+        return {
+            'data': {
+                'current': json_list(calls.filter(end_timestamp__isnull=True)),
+                'past': json_list(calls.filter(end_timestamp__isnull=False)),
+            }
+        }
+
+    def hourly(self):
+        super(PhoneCalls, self).hourly()
+        # close unfinished long calls
+        maximal_time = now() - timedelta(minutes=self.max_call_duration)
+        Call.objects.filter(resource=self, end_timestamp=None,
+                            start_timestamp__lt=maximal_time).update(end_timestamp=now())
+
+    def daily(self):
+        super(PhoneCalls, self).daily()
+        # remove finished old calls
+        maximal_time = now() - timedelta(days=self.data_retention_period)
+        Call.objects.filter(resource=self, end_timestamp__isnull=False,
+                            end_timestamp__lt=maximal_time).delete()
+
+
+class Call(models.Model):
+    resource = models.ForeignKey(PhoneCalls)
+    callee = models.CharField(blank=False, max_length=64)
+    caller = models.CharField(blank=False, max_length=64)
+    start_timestamp = models.DateTimeField(auto_now_add=True)
+    end_timestamp = models.DateTimeField(null=True, default=None)
+    details = JSONField(default={})
+
+    class Meta:
+        verbose_name = _('Phone Call')
+        ordering = ['-start_timestamp']
+
+    def json(self):
+        # We use make_naive to send localtime, because this API will be used
+        # by javascript, which will not be comfortable with UTC datetimes
+        if self.end_timestamp:
+            is_current = False
+            end_timestamp = make_naive(self.end_timestamp)
+        else:
+            is_current = True
+            end_timestamp = None
+        return {
+            'caller': self.caller,
+            'callee': self.callee,
+            'start': make_naive(self.start_timestamp),
+            'end': end_timestamp,
+            'is_current': is_current,
+            'details': self.details,
+        }
diff --git a/passerelle/settings.py b/passerelle/settings.py
index 4ef433a..35d1726 100644
--- a/passerelle/settings.py
+++ b/passerelle/settings.py
@@ -138,6 +138,7 @@ INSTALLED_APPS = (
     'passerelle.apps.ovh',
     'passerelle.apps.oxyd',
     'passerelle.apps.pastell',
+    'passerelle.apps.phonecalls',
     'passerelle.apps.solis',
     'passerelle.apps.arpege_ecp',
     'passerelle.apps.vivaticket',
diff --git a/tests/test_phonecalls.py b/tests/test_phonecalls.py
new file mode 100644
index 0000000..bc48cd8
--- /dev/null
+++ b/tests/test_phonecalls.py
@@ -0,0 +1,211 @@
+import pytest
+import utils
+
+from django.contrib.contenttypes.models import ContentType
+from django.utils.timezone import now, timedelta
+
+from passerelle.apps.phonecalls.models import PhoneCalls, Call
+from passerelle.base.models import ApiUser, AccessRight
+
+
+@pytest.fixture
+def phonecalls(db):
+    phonecalls = PhoneCalls.objects.create(slug='test')
+    apikey = ApiUser.objects.create(username='all', keytype='API', key='123')
+    obj_type = ContentType.objects.get_for_model(phonecalls)
+    AccessRight.objects.create(codename='can_access', apiuser=apikey,
+                               resource_type=obj_type,
+                               resource_pk=phonecalls.pk)
+    return phonecalls
+
+
+def test_phonecalls_start_stop(app, phonecalls):
+    start_endpoint = utils.generic_endpoint_url('phonecalls', 'call-start',
+                                                slug=phonecalls.slug)
+    assert start_endpoint == '/phonecalls/test/call-start'
+    stop_endpoint = utils.generic_endpoint_url('phonecalls', 'call-stop',
+                                               slug=phonecalls.slug)
+    assert stop_endpoint == '/phonecalls/test/call-stop'
+    calls_endpoint = utils.generic_endpoint_url('phonecalls', 'calls',
+                                                slug=phonecalls.slug)
+    assert calls_endpoint == '/phonecalls/test/calls'
+
+    resp = app.get(start_endpoint, status=403)
+    assert resp.json['err'] == 1
+    assert resp.json['err_class'] == 'django.core.exceptions.PermissionDenied'
+    resp = app.get(stop_endpoint, status=403)
+    assert resp.json['err'] == 1
+    assert resp.json['err_class'] == 'django.core.exceptions.PermissionDenied'
+    resp = app.get(calls_endpoint, status=403)
+    assert resp.json['err'] == 1
+    assert resp.json['err_class'] == 'django.core.exceptions.PermissionDenied'
+
+    resp = app.get(start_endpoint, params={'apikey': '123'}, status=400)
+    assert resp.json['err'] == 1
+    assert resp.json['err_class'] == 'passerelle.views.WrongParameter'
+    assert 'missing parameters' in resp.json['err_desc']
+    resp = app.get(stop_endpoint, params={'apikey': '123'}, status=400)
+    assert resp.json['err'] == 1
+    assert resp.json['err_class'] == 'passerelle.views.WrongParameter'
+    assert 'missing parameters' in resp.json['err_desc']
+
+    Call.objects.all().delete()
+
+    resp = app.get(start_endpoint, status=200, params={'apikey': '123',
+                                                       'callee': '42',
+                                                       'caller': '0612345678'})
+    assert resp.json['err'] == 0
+    assert resp.json['data']['callee'] == '42'
+    assert resp.json['data']['caller'] == '0612345678'
+    assert 'start' in resp.json['data']
+    assert resp.json['data']['end'] is None
+    assert resp.json['data']['is_current'] is True
+    assert resp.json['data']['details'] == {}
+    assert Call.objects.count() == 1
+    call = Call.objects.first()
+    assert call.callee == '42'
+    assert call.caller == '0612345678'
+    assert call.end_timestamp is None
+    assert call.details == {}
+    json_start = resp.json['data']['start']
+    start = call.start_timestamp
+
+    # same call, do nothing
+    resp = app.get(start_endpoint, status=200, params={'apikey': '123',
+                                                       'callee': '42',
+                                                       'caller': '0612345678'})
+    assert resp.json['err'] == 0
+    assert resp.json['data']['callee'] == '42'
+    assert resp.json['data']['caller'] == '0612345678'
+    assert resp.json['data']['start'] == json_start
+    assert resp.json['data']['end'] is None
+    assert resp.json['data']['is_current'] is True
+    assert Call.objects.count() == 1
+    call = Call.objects.first()
+    assert call.callee == '42'
+    assert call.caller == '0612345678'
+    assert call.end_timestamp is None
+    assert call.start_timestamp == start
+    assert call.details == {}
+    resp = app.get(start_endpoint, status=200, params={'apikey': '123',
+                                                       'callee': '42',
+                                                       'caller': '0612345678',
+                                                       'foo': 'bar'})  # add details
+    assert resp.json['err'] == 0
+    assert resp.json['data']['callee'] == '42'
+    assert resp.json['data']['caller'] == '0612345678'
+    assert resp.json['data']['start'] == json_start
+    assert resp.json['data']['end'] is None
+    assert resp.json['data']['is_current'] is True
+    assert resp.json['data']['details'] == {'foo': 'bar'}
+    assert Call.objects.count() == 1
+    call = Call.objects.first()
+    assert call.callee == '42'
+    assert call.caller == '0612345678'
+    assert call.end_timestamp is None
+    assert call.start_timestamp == start
+    assert call.details == {'foo': 'bar'}
+
+    resp = app.get(calls_endpoint, status=200, params={'apikey': '123'})
+    assert resp.json['err'] == 0
+    assert len(resp.json['data']['current']) == 1
+    assert len(resp.json['data']['past']) == 0
+    resp = app.get(calls_endpoint, status=200, params={'apikey': '123', 'callee': '42'})
+    assert resp.json['err'] == 0
+    assert len(resp.json['data']['current']) == 1
+    assert len(resp.json['data']['past']) == 0
+    resp = app.get(calls_endpoint, status=200, params={'apikey': '123', 'callee': '43'})
+    assert resp.json['err'] == 0
+    assert len(resp.json['data']['current']) == 0
+    assert len(resp.json['data']['past']) == 0
+
+    resp = app.get(start_endpoint, status=200, params={'apikey': '123',
+                                                       'callee': '43',
+                                                       'caller': '0687654321'})
+    assert resp.json['err'] == 0
+    assert resp.json['data']['callee'] == '43'
+    assert resp.json['data']['caller'] == '0687654321'
+    assert Call.objects.count() == 2
+    assert Call.objects.filter(end_timestamp__isnull=True).count() == 2
+
+    resp = app.get(calls_endpoint, status=200, params={'apikey': '123', 'callee': '42'})
+    assert resp.json['err'] == 0
+    assert len(resp.json['data']['current']) == 1
+    assert len(resp.json['data']['past']) == 0
+    resp = app.get(calls_endpoint, status=200, params={'apikey': '123', 'callee': '43'})
+    assert resp.json['err'] == 0
+    assert len(resp.json['data']['current']) == 1
+    assert len(resp.json['data']['past']) == 0
+
+    resp = app.get(stop_endpoint, status=200, params={'apikey': '123',
+                                                      'callee': '43',
+                                                      'caller': '0687654321'})
+    assert resp.json['err'] == 0
+    assert len(resp.json['data']) == 1
+    assert resp.json['data'][0]['callee'] == '43'
+    assert resp.json['data'][0]['caller'] == '0687654321'
+    assert resp.json['data'][0]['start'] is not None
+    assert resp.json['data'][0]['end'] is not None
+    assert resp.json['data'][0]['is_current'] is False
+    assert Call.objects.count() == 2
+    assert Call.objects.filter(end_timestamp__isnull=True).count() == 1
+    assert Call.objects.filter(end_timestamp__isnull=False).count() == 1
+
+    # calls by callee
+    resp = app.get(calls_endpoint, status=200, params={'apikey': '123', 'callee': '42'})
+    assert resp.json['err'] == 0
+    assert len(resp.json['data']['current']) == 1
+    assert len(resp.json['data']['past']) == 0
+    resp = app.get(calls_endpoint, status=200, params={'apikey': '123', 'callee': '43'})
+    assert resp.json['err'] == 0
+    assert len(resp.json['data']['current']) == 0
+    assert len(resp.json['data']['past']) == 1
+    resp = app.get(calls_endpoint, status=200, params={'apikey': '123'})
+    assert resp.json['err'] == 0
+    assert len(resp.json['data']['current']) == 1
+    assert len(resp.json['data']['past']) == 1
+    resp = app.get(calls_endpoint, status=200, params={'apikey': '123', 'callee': 'foo'})
+    assert resp.json['err'] == 0
+    assert len(resp.json['data']['current']) == 0
+    assert len(resp.json['data']['past']) == 0
+
+    # calls by caller
+    resp = app.get(calls_endpoint, status=200, params={'apikey': '123',
+                                                       'caller': '0612345678'})
+    assert resp.json['err'] == 0
+    assert len(resp.json['data']['current']) == 1
+    assert len(resp.json['data']['past']) == 0
+    resp = app.get(calls_endpoint, status=200, params={'apikey': '123',
+                                                       'caller': '0687654321'})
+    assert resp.json['err'] == 0
+    assert len(resp.json['data']['current']) == 0
+    assert len(resp.json['data']['past']) == 1
+    resp = app.get(calls_endpoint, status=200, params={'apikey': '123',
+                                                       'caller': 'foo'})
+    assert resp.json['err'] == 0
+    assert len(resp.json['data']['current']) == 0
+    assert len(resp.json['data']['past']) == 0
+
+    # create a "too long" current call (> 120 minutes == phonecalls.max_call_duration)
+    assert Call.objects.count() == 2
+    assert Call.objects.filter(end_timestamp__isnull=True).count() == 1
+    assert Call.objects.filter(end_timestamp__isnull=False).count() == 1
+    current_call = Call.objects.filter(end_timestamp__isnull=True).first()
+    current_call.start_timestamp = now() - timedelta(minutes=200)
+    current_call.save()
+    # close too long calls
+    phonecalls.hourly()
+    assert Call.objects.count() == 2
+    assert Call.objects.filter(end_timestamp__isnull=True).count() == 0
+    assert Call.objects.filter(end_timestamp__isnull=False).count() == 2
+
+    # create a "too old" call (> 60 days == phonecalls.data_retention_period)
+    old_call = Call.objects.first()
+    old_call.start_timestamp = old_call.start_timestamp - timedelta(days=100)
+    old_call.end_timestamp = old_call.end_timestamp - timedelta(days=100)
+    old_call.save()
+    # remove old calls
+    phonecalls.daily()
+    assert Call.objects.count() == 1
+    assert Call.objects.filter(end_timestamp__isnull=True).count() == 0
+    assert Call.objects.filter(end_timestamp__isnull=False).count() == 1
-- 
2.20.1

