implement telephony models and web services (#8789)

- 2 new models: PhoneCall, PhoneLine
- 4 web-services:
 - call_event
 - current_calls
 - take_line
 - release_line
This commit is contained in:
Benjamin Dauvergne 2015-10-28 17:41:14 +01:00 committed by Frédéric Péters
parent 661ccf4747
commit 5cedd08743
8 changed files with 485 additions and 7 deletions

185
tests/test_source_phone.py Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
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

View File

@ -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',
},
),
]

View File

@ -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),
),
]

View File

@ -14,23 +14,23 @@
# 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/>.
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)

View File

@ -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 <http://www.gnu.org/licenses/>.
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'),
)

View File

@ -14,11 +14,19 @@
# 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/>.
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')

View File

@ -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<pk>\w+)$',