From 5cedd08743d1ccb877137cfe71e89f79c3c92a79 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Wed, 28 Oct 2015 17:41:14 +0100 Subject: [PATCH] implement telephony models and web services (#8789) - 2 new models: PhoneCall, PhoneLine - 4 web-services: - call_event - current_calls - take_line - release_line --- tests/test_source_phone.py | 185 ++++++++++++++++++ .../sources/phone/migrations/0001_initial.py | 25 +++ .../migrations/0002_auto_20151028_1635.py | 59 ++++++ welco/sources/phone/migrations/__init__.py | 0 welco/sources/phone/models.py | 43 +++- welco/sources/phone/urls.py | 27 +++ welco/sources/phone/views.py | 152 +++++++++++++- welco/urls.py | 1 + 8 files changed, 485 insertions(+), 7 deletions(-) create mode 100644 tests/test_source_phone.py create mode 100644 welco/sources/phone/migrations/0001_initial.py create mode 100644 welco/sources/phone/migrations/0002_auto_20151028_1635.py create mode 100644 welco/sources/phone/migrations/__init__.py create mode 100644 welco/sources/phone/urls.py diff --git a/tests/test_source_phone.py b/tests/test_source_phone.py new file mode 100644 index 0000000..0bbc124 --- /dev/null +++ b/tests/test_source_phone.py @@ -0,0 +1,185 @@ +# welco - multichannel request processing +# Copyright (C) 2015 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 . + +import json + +import pytest + +from django.core.urlresolvers import reverse + +from welco.sources.phone import models + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def user(): + from django.contrib.auth.models import User + + user = User.objects.create(username='toto') + user.set_password('toto') + user.save() + return user + + +def test_call_start_stop(client): + assert models.PhoneCall.objects.count() == 0 + payload = { + 'event': 'start', + 'caller': '0033699999999', + 'callee': '102', + 'data': { + 'user': 'boby.lapointe', + } + } + response = client.post(reverse('phone-call-event'), json.dumps(payload), + content_type='application/json') + assert response.status_code == 200 + assert response['content-type'] == 'application/json' + assert json.loads(response.content) == {'err': 0} + assert models.PhoneCall.objects.count() == 1 + assert models.PhoneCall.objects.filter( + caller='0033699999999', + callee='102', + data=json.dumps(payload['data']), stop__isnull=True).count() == 1 + # new start event + response = client.post(reverse('phone-call-event'), json.dumps(payload), + content_type='application/json') + assert response.status_code == 200 + assert response['content-type'] == 'application/json' + assert json.loads(response.content) == {'err': 0} + assert models.PhoneCall.objects.count() == 2 + assert models.PhoneCall.objects.filter( + caller='0033699999999', + callee='102', + data=json.dumps(payload['data']), stop__isnull=True).count() == 1 + # first call has been closed + assert models.PhoneCall.objects.filter( + caller='0033699999999', + callee='102', + data=json.dumps(payload['data']), stop__isnull=False).count() == 1 + payload['event'] = 'stop' + response = client.post(reverse('phone-call-event'), json.dumps(payload), + content_type='application/json') + assert response.status_code == 200 + assert response['content-type'] == 'application/json' + assert json.loads(response.content) == {'err': 0} + assert models.PhoneCall.objects.count() == 2 + assert models.PhoneCall.objects.filter( + caller='0033699999999', + callee='102', + data=json.dumps(payload['data']), stop__isnull=False).count() == 2 + # stop is idempotent + response = client.post(reverse('phone-call-event'), json.dumps(payload), + content_type='application/json') + assert response.status_code == 200 + assert response['content-type'] == 'application/json' + assert json.loads(response.content) == {'err': 0} + assert models.PhoneCall.objects.count() == 2 + assert models.PhoneCall.objects.filter( + caller='0033699999999', + callee='102', + data=json.dumps(payload['data']), stop__isnull=False).count() == 2 + + +def test_current_calls(user, client): + # create some calls + for number in range(0, 10): + payload = { + 'event': 'start', + 'caller': '00336999999%02d' % number, + 'callee': '1%02d' % number, + 'data': { + 'user': 'boby.lapointe', + } + } + response = client.post(reverse('phone-call-event'), json.dumps(payload), + content_type='application/json') + assert response.status_code == 200 + assert response['content-type'] == 'application/json' + assert json.loads(response.content) == {'err': 0} + + # register user to some lines + # then remove from some + for number in range(0, 10): + models.PhoneLine.take(callee='1%02d' % number, user=user) + for number in range(5, 10): + models.PhoneLine.release(callee='1%02d' % number, user=user) + client.login(username='toto', password='toto') + response = client.get(reverse('phone-current-calls')) + assert response.status_code == 200 + assert response['content-type'] == 'application/json' + payload = json.loads(response.content) + assert isinstance(payload, dict) + assert set(payload.keys()) == set(['err', 'data']) + assert payload['err'] == 0 + data = payload['data'] + assert set(data.keys()) == set(['calls', 'lines', 'all-lines']) + assert isinstance(data['calls'], list) + assert isinstance(data['lines'], list) + assert isinstance(data['all-lines'], list) + assert len(data['calls']) == 5 + assert len(data['lines']) == 5 + assert len(data['all-lines']) == 10 + for call in data['calls']: + assert set(call.keys()) <= set(['caller', 'callee', 'start', 'data']) + assert isinstance(call['caller'], unicode) + assert isinstance(call['callee'], unicode) + assert isinstance(call['start'], unicode) + if 'data' in call: + assert isinstance(call['data'], dict) + assert len([call for call in data['lines'] if isinstance(call, unicode)]) == 5 + assert len([call for call in data['all-lines'] if isinstance(call, unicode)]) == 10 + + # unregister user to all remaining lines + for number in range(0, 5): + models.PhoneLine.release(callee='1%02d' % number, user=user) + response = client.get(reverse('phone-current-calls')) + assert response.status_code == 200 + assert response['content-type'] == 'application/json' + payload = json.loads(response.content) + assert isinstance(payload, dict) + assert set(payload.keys()) == set(['err', 'data']) + assert payload['err'] == 0 + assert set(payload['data'].keys()) == set(['calls', 'lines', 'all-lines']) + assert len(payload['data']['calls']) == 0 + assert len(payload['data']['lines']) == 0 + assert len(payload['data']['all-lines']) == 10 + + +def test_take_release_line(user, client): + client.login(username='toto', password='toto') + + assert models.PhoneLine.objects.count() == 0 + payload = { + 'callee': '102', + } + response = client.post(reverse('phone-take-line'), json.dumps(payload), + content_type='application/json') + assert response.status_code == 200 + assert response['content-type'] == 'application/json' + assert json.loads(response.content) == {'err': 0} + assert models.PhoneLine.objects.count() == 1 + assert models.PhoneLine.objects.filter( + users=user, callee='102').count() == 1 + response = client.post(reverse('phone-release-line'), json.dumps(payload), + content_type='application/json') + assert response.status_code == 200 + assert response['content-type'] == 'application/json' + assert json.loads(response.content) == {'err': 0} + assert models.PhoneLine.objects.count() == 1 + assert models.PhoneLine.objects.filter( + users=user, callee='102').count() == 0 diff --git a/welco/sources/phone/migrations/0001_initial.py b/welco/sources/phone/migrations/0001_initial.py new file mode 100644 index 0000000..c64f164 --- /dev/null +++ b/welco/sources/phone/migrations/0001_initial.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='PhoneCall', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('number', models.CharField(max_length=20, verbose_name='Number')), + ('creation_timestamp', models.DateTimeField(auto_now_add=True)), + ('last_update_timestamp', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Phone Call', + }, + ), + ] diff --git a/welco/sources/phone/migrations/0002_auto_20151028_1635.py b/welco/sources/phone/migrations/0002_auto_20151028_1635.py new file mode 100644 index 0000000..332b4e3 --- /dev/null +++ b/welco/sources/phone/migrations/0002_auto_20151028_1635.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +from django.utils.timezone import utc +from django.utils.timezone import now +import datetime +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('phone', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='PhoneLine', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('callee', models.CharField(unique=True, max_length=20, verbose_name='Callee')), + ('users', models.ManyToManyField(to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + ), + migrations.RemoveField( + model_name='phonecall', + name='number', + ), + migrations.AddField( + model_name='phonecall', + name='callee', + field=models.CharField(default='0', max_length=20, verbose_name='Callee'), + preserve_default=False, + ), + migrations.AddField( + model_name='phonecall', + name='caller', + field=models.CharField(default='0', max_length=20, verbose_name='Caller'), + preserve_default=False, + ), + migrations.AddField( + model_name='phonecall', + name='data', + field=models.TextField(verbose_name='Data', blank=True), + ), + migrations.AddField( + model_name='phonecall', + name='start', + field=models.DateTimeField(default=now, verbose_name='Start', auto_now_add=True), + preserve_default=False, + ), + migrations.AddField( + model_name='phonecall', + name='stop', + field=models.DateTimeField(null=True, verbose_name='Stop', blank=True), + ), + ] diff --git a/welco/sources/phone/migrations/__init__.py b/welco/sources/phone/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/welco/sources/phone/models.py b/welco/sources/phone/models.py index bce7837..b0de123 100644 --- a/welco/sources/phone/models.py +++ b/welco/sources/phone/models.py @@ -14,23 +14,23 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import subprocess - from django.core.urlresolvers import reverse from django.db import models -from django.db.models.signals import post_save -from django.dispatch import receiver from django.utils.translation import ugettext_lazy as _ + class PhoneCall(models.Model): class Meta: verbose_name = _('Phone Call') - number = models.CharField(_('Number'), max_length=20) - creation_timestamp = models.DateTimeField(auto_now_add=True) last_update_timestamp = models.DateTimeField(auto_now=True) + caller = models.CharField(_('Caller'), max_length=20) + callee = models.CharField(_('Callee'), max_length=20) + start = models.DateTimeField(_('Start'), auto_now_add=True) + stop = models.DateTimeField(_('Stop'), null=True, blank=True) + data = models.TextField(_('Data'), blank=True) @classmethod def get_qualification_form_class(cls): @@ -43,7 +43,38 @@ class PhoneCall(models.Model): def get_qualification_form_submit_url(cls): return reverse('qualif-phone-save') + @classmethod + def get_current_calls(cls, user): + return cls.objects.filter(callee__in=PhoneLine.get_callees(user), + stop__isnull=True).order_by('start') + + @classmethod + def get_all_callees(cls): + return cls.objects.values_list('callee', flat=True).distinct() + def get_source_context(self, request): return { 'channel': 'phone', } + + +class PhoneLine(models.Model): + callee = models.CharField(_('Callee'), unique=True, max_length=20) + users = models.ManyToManyField('auth.User', verbose_name=_('User')) + + @classmethod + def take(cls, callee, user): + '''Take a line number''' + line, created = cls.objects.get_or_create(callee=callee) + line.users.add(user) + + @classmethod + def release(cls, callee, user): + '''Release a line number''' + line, created = cls.objects.get_or_create(callee=callee) + line.users.remove(user) + + @classmethod + def get_callees(cls, user): + '''Return all line numbers watched by user''' + return PhoneLine.objects.filter(users=user).values_list('callee', flat=True) diff --git a/welco/sources/phone/urls.py b/welco/sources/phone/urls.py new file mode 100644 index 0000000..990d852 --- /dev/null +++ b/welco/sources/phone/urls.py @@ -0,0 +1,27 @@ +# welco - multichannel request processing +# Copyright (C) 2015 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 . + +from django.conf.urls import patterns, url + +from . import views + +urlpatterns = patterns( + '', + url(r'^api/phone/call-event/$', views.call_event, name='phone-call-event'), + url(r'^api/phone/current-calls/$', views.current_calls, name='phone-current-calls'), + url(r'^api/phone/take-line/$', views.take_line, name='phone-take-line'), + url(r'^api/phone/release-line/$', views.release_line, name='phone-release-line'), +) diff --git a/welco/sources/phone/views.py b/welco/sources/phone/views.py index 01928ff..836a81f 100644 --- a/welco/sources/phone/views.py +++ b/welco/sources/phone/views.py @@ -14,11 +14,19 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import json +import logging + from django import template from django.contrib.contenttypes.models import ContentType from django.template import RequestContext +from django.views.decorators.csrf import csrf_exempt +from django.contrib.auth.decorators import login_required +from django.http import HttpResponseBadRequest, HttpResponse +from django.utils.timezone import now + +from .models import PhoneCall, PhoneLine -from .models import PhoneCall class Home(object): source_key = 'phone' @@ -31,3 +39,145 @@ class Home(object): context['source_type'] = ContentType.objects.get_for_model(PhoneCall) tmpl = template.loader.get_template('welco/phone_home.html') return tmpl.render(context) + + +@csrf_exempt +def call_event(request): + '''Log a new call start or stop, input is JSON: + + { + 'event': 'start' or 'stop', + 'caller': '003399999999', + 'callee': '102', + 'data': { + 'user': 'zozo', + }, + } + ''' + logger = logging.getLogger(__name__) + try: + payload = json.loads(request.body) + assert isinstance(payload, dict), 'payload is not a JSON object' + assert set(payload.keys()) <= set(['event', 'caller', 'callee', 'data']), \ + 'payload keys must be "event", "caller", "callee" and optionnaly "data"' + assert set(['event', 'caller', 'callee']) <= set(payload.keys()), \ + 'payload keys must be "event", "caller", "callee" and optionnaly "data"' + assert payload['event'] in ('start', 'stop'), 'event must be "start" or "stop"' + assert isinstance(payload['caller'], unicode), 'caller must be a string' + assert isinstance(payload['callee'], unicode), 'callee must be a string' + if 'data' in payload: + assert isinstance(payload['data'], dict), 'data must be a JSON object' + except (TypeError, ValueError, AssertionError), e: + return HttpResponseBadRequest(json.dumps({'err': 1, 'msg': + unicode(e)}), + content_type='application/json') + # terminate all existing calls between these two endpoints + PhoneCall.objects.filter(caller=payload['caller'], + callee=payload['callee'], stop__isnull=True) \ + .update(stop=now()) + if payload['event'] == 'start': + # start a new call + kwargs = { + 'caller': payload['caller'], + 'callee': payload['callee'], + } + if 'data' in payload: + kwargs['data'] = json.dumps(payload['data']) + PhoneCall.objects.create(**kwargs) + logger.info('start call from %s to %s', payload['caller'], payload['callee']) + else: + logger.info('stop call from %s to %s', payload['caller'], payload['callee']) + return HttpResponse(json.dumps({'err': 0}), content_type='application/json') + + +@login_required +def current_calls(request): + '''Returns the list of current calls for current user as JSON: + + { + 'err': 0, + 'data': { + 'calls': [ + { + 'caller': '00334545445', + 'callee': '102', + 'data': { ... }, + }, + ... + ], + 'lines': [ + '102', + ], + 'all-lines': [ + '102', + ], + } + } + + lines are number the user is currently watching, all-lines is all + registered numbers. + ''' + all_callees = PhoneCall.get_all_callees() + callees = PhoneLine.get_callees(request.user) + phonecalls = PhoneCall.get_current_calls(request.user) + + calls = [] + payload = { + 'err': 0, + 'data': { + 'calls': calls, + 'lines': list(callees), + 'all-lines': list(all_callees), + }, + } + for call in phonecalls: + calls.append({ + 'caller': call.caller, + 'callee': call.callee, + 'start': call.start.isoformat('T').split('.')[0], + }) + if call.data: + calls[-1]['data'] = json.loads(call.data) + response = HttpResponse(content_type='application/json') + json.dump(payload, response, indent=2) + return response + + +@login_required +def take_line(request): + '''Take a line, input is JSON: + + { 'callee': '003369999999' } + ''' + logger = logging.getLogger(__name__) + try: + payload = json.loads(request.body) + assert isinstance(payload, dict), 'payload is not a JSON object' + assert payload.keys() == ['callee'], 'payload must have only one key: callee' + except (TypeError, ValueError, AssertionError), e: + return HttpResponseBadRequest(json.dumps({'err': 1, 'msg': + unicode(e)}), + content_type='application/json') + PhoneLine.take(payload['callee'], request.user) + logger.info(u'user %s took line %s', request.user, payload['callee']) + return HttpResponse(json.dumps({'err': 0}), content_type='application/json') + + +@login_required +def release_line(request): + '''Release a line, input is JSON: + + { 'callee': '003369999999' } + ''' + logger = logging.getLogger(__name__) + try: + payload = json.loads(request.body) + assert isinstance(payload, dict), 'payload is not a JSON object' + assert payload.keys() == ['callee'], 'payload must have only one key: callee' + except (TypeError, ValueError, AssertionError), e: + return HttpResponseBadRequest(json.dumps({'err': 1, 'msg': + unicode(e)}), + content_type='application/json') + PhoneLine.release(payload['callee'], request.user) + logger.info(u'user %s released line %s', request.user, payload['callee']) + return HttpResponse(json.dumps({'err': 0}), content_type='application/json') diff --git a/welco/urls.py b/welco/urls.py index 6a94e8b..9e26ab2 100644 --- a/welco/urls.py +++ b/welco/urls.py @@ -24,6 +24,7 @@ from . import apps urlpatterns = patterns('', url(r'^$', 'welco.views.home', name='home'), url(r'^phone/$', 'welco.views.home_phone', name='home-phone'), + url(r'^', include('welco.sources.phone.urls')), url(r'^ajax/qualification$', 'welco.views.qualification', name='qualif-zone'), url(r'^ajax/qualification-done$', 'welco.views.qualification_done', name='qualif-done'), url(r'^ajax/remove-association/(?P\w+)$',