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 # 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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import subprocess
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.db import models 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 _ from django.utils.translation import ugettext_lazy as _
class PhoneCall(models.Model): class PhoneCall(models.Model):
class Meta: class Meta:
verbose_name = _('Phone Call') verbose_name = _('Phone Call')
number = models.CharField(_('Number'), max_length=20)
creation_timestamp = models.DateTimeField(auto_now_add=True) creation_timestamp = models.DateTimeField(auto_now_add=True)
last_update_timestamp = models.DateTimeField(auto_now=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 @classmethod
def get_qualification_form_class(cls): def get_qualification_form_class(cls):
@ -43,7 +43,38 @@ class PhoneCall(models.Model):
def get_qualification_form_submit_url(cls): def get_qualification_form_submit_url(cls):
return reverse('qualif-phone-save') 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): def get_source_context(self, request):
return { return {
'channel': 'phone', '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 # 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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import json
import logging
from django import template from django import template
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.template import RequestContext 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): class Home(object):
source_key = 'phone' source_key = 'phone'
@ -31,3 +39,145 @@ class Home(object):
context['source_type'] = ContentType.objects.get_for_model(PhoneCall) context['source_type'] = ContentType.objects.get_for_model(PhoneCall)
tmpl = template.loader.get_template('welco/phone_home.html') tmpl = template.loader.get_template('welco/phone_home.html')
return tmpl.render(context) 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('', urlpatterns = patterns('',
url(r'^$', 'welco.views.home', name='home'), url(r'^$', 'welco.views.home', name='home'),
url(r'^phone/$', 'welco.views.home_phone', name='home-phone'), 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$', 'welco.views.qualification', name='qualif-zone'),
url(r'^ajax/qualification-done$', 'welco.views.qualification_done', name='qualif-done'), url(r'^ajax/qualification-done$', 'welco.views.qualification_done', name='qualif-done'),
url(r'^ajax/remove-association/(?P<pk>\w+)$', url(r'^ajax/remove-association/(?P<pk>\w+)$',