Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow messages from multiple iSMS servers to be captured properly #6

Merged
merged 1 commit into from Apr 16, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
34 changes: 27 additions & 7 deletions README.rst
Expand Up @@ -16,13 +16,33 @@ for more details.
Settings
--------

"multimodem-1": {
"ENGINE": "rapidsms_multimodem.outgoing.MultiModemBackend",
"sendsms_url": "http://192.168.170.200:81/sendmsg",
"sendsms_user": "admin",
"sendsms_pass": "admin",
"sendsms_params": { "modem": 1 },
},
The following parameters are required: ``sendsms_url``, ``sendsms_user``, ``sendsms_pass``,
``modem_port``, and ``server_slug``::

"multimodem-1": {
"ENGINE": "rapidsms_multimodem.outgoing.MultiModemBackend",
"sendsms_url": "http://192.168.170.200:81/sendmsg",
"sendsms_user": "admin",
"sendsms_pass": "admin",
"modem_port": 1,
"server_slug": "isms-lebanon",
},

Single port modems only have 1 port, but it should still be specified.

The ``server_slug`` parameter serves 2 purposes. It uniquely identifies the iSMS server, so that
RapidSMS doesn't get confused by 2 different servers having the same port number (since those are
restricted to be integers from 1 to 8). It's also used to create the RapidSMS URL that the iSMS
server will send messages to. Your ``urls.py`` should look something like this::

urlpatterns = [
url(r"^backend/multimodem/(?P<server_slug>[\w_-]+)/$",
receive_multimodem_message,
name='multimodem-backend'),
]

With the 2 code examples above, your iSMS server should POST messages to
http://your-rapidsms-server.example.com/backend/multimodem/isms-lebanon/.


Contributing
Expand Down
42 changes: 29 additions & 13 deletions docs/quick-start.rst
Expand Up @@ -15,7 +15,7 @@ Log into the MultiModem Web Management system and:
* Enable Non Polling Receive API Status under ``SMS Services (top nav) > SMS API (sidebar nav) > Non Polling Receive API Configuration``. Once saved, fill in the following fields:
* **Server:** Server URI or hosname. For local development, this will most likely just be your IP address, e.g. ``192.168.1.100``.
* **Port:** Either ``8000`` for local development or ``80`` for production.
* **Server Default Page:** You backend URL as defined below, e.g. ``backend/multimodem/``
* **Server Default Page:** You backend URL as defined below, e.g. ``backend/multimodem/isms-lebanon/``


RapidSMS Setup
Expand All @@ -42,34 +42,50 @@ Add the following to your existing ``INSTALLED_BACKENDS`` configuration in your
``settings.py`` file:

.. code-block:: python
:emphasize-lines: 4-9
:emphasize-lines: 4-11

INSTALLED_BACKENDS = {
# ...
# other backends, if any
"multimodem-backend": {
"isms-lebanon-1": {
"ENGINE": "rapidsms_multimodem.outgoing.MultiModemBackend",
"sendsms_url": "http://<multimodem-ip-address>:81/sendmsg",
"sendsms_user": "<username>",
"sendsms_pass": "<password>",
"modem_port": 1,
"server_slug": "isms-lebanon",
},
}

Single port modems only have 1 port, but it should still be specified.

The ``server_slug`` parameter serves 2 purposes. It uniquely identifies the iSMS server, so that
RapidSMS doesn't get confused by 2 different servers having the same port number (since those are
restricted to be integers from 1 to 8). It's also used to create the RapidSMS URL to which the iSMS
server will send messages.

Next, you need to add an endpoint to your ``urls.py`` for the newly created
backend. You can do this like so:

.. code-block:: python
:emphasize-lines: 6-7
:emphasize-lines: 5-6

from django.conf.urls import patterns, include, url
from rapidsms_multimodem.views import MultiModemBackend
from django.conf.urls import url
from rapidsms_multimodem.views import receive_multimodem_message

urlpatterns = patterns('',
# ...
url(r"^backend/multimodem/$",
MultiModemBackend.as_view(backend_name="multimodem-backend")),
)
urlpatterns = [
url(r"^backend/multimodem/(?P<server_slug>[\w_-]+)/$",
receive_multimodem_message, name='multimodem-backend'),
]

Now inbound MultiModem messages can be received at
``<your-server>/backend/multimodem/`` and outbound messages will be
Now inbound MultiModem messages can be received at
``<your-server>/backend/multimodem/isms-lebanon/`` and outbound messages will be
sent via the MultiModem backend.

Additional modems on the same iSMS server will need additional entries in ``INSTALLED_BACKENDS``.
The only parameter that will be different than above will be the ``modem_port``.

If you have more than one iSMS server, you'll create additional entries in ``INSTALLED_BACKENDS``,
making sure that ``server_slug`` is unique for each iSMS server. You will NOT need to add additional
patterns to ``urls.py``. The regular expression will catch the ``server_slug`` and match messages to
the proper backend.
8 changes: 5 additions & 3 deletions rapidsms_multimodem/outgoing.py
Expand Up @@ -15,11 +15,13 @@
class MultiModemBackend(BackendBase):
"""Outgoing SMS backend for MultiModem iSMS."""

def configure(self, sendsms_url, sendsms_user, sendsms_pass,
sendsms_params=None, **kwargs):
def configure(self, sendsms_url, sendsms_user, sendsms_pass, modem_port,
server_slug, sendsms_params=None):
self.sendsms_url = sendsms_url
self.sendsms_user = sendsms_user
self.sendsms_pass = sendsms_pass
self.modem_port = modem_port
self.server_slug = server_slug
self.sendsms_params = sendsms_params or {}

def prepare_querystring(self, id_, text, identities, context):
Expand All @@ -31,7 +33,7 @@ def prepare_querystring(self, id_, text, identities, context):
params['passwd'] = self.sendsms_pass
params['cat'] = 1
params['enc'] = ISMS_ASCII
params['modem'] = self.sendsms_params.get('modem', 0)
params['modem'] = self.modem_port
params['to'] = to
# 'text' is tricky. iSMS has 3 encodings: ascii, enhanced ascii, and 'unicode'
# (Note: it's not really Unicode, but a iSMS custom binary format)
Expand Down
3 changes: 2 additions & 1 deletion rapidsms_multimodem/tests/test_outgoing.py
Expand Up @@ -16,7 +16,8 @@ def setUp(self):
'sendsms_url': 'http://192.168.170.200:81/sendmsg',
'sendsms_user': 'admin',
'sendsms_pass': 'admin',
'sendsms_params': {'modem': 1},
'modem_port': 1,
'server_slug': 'isms-lebanon',
}
self.backend = MultiModemBackend(None, "multimodem", **config)

Expand Down
70 changes: 61 additions & 9 deletions rapidsms_multimodem/tests/test_views.py
Expand Up @@ -14,11 +14,17 @@
'sendsms_url': 'http://192.168.170.200:81/sendmsg',
'sendsms_user': 'admin',
'sendsms_pass': 'admin',
'sendsms_params': {'modem': 1},
'modem_port': 1,
'server_slug': 'server-a',
}

MODEM_2 = copy.deepcopy(MODEM_1)
MODEM_2['sendsms_params']['modem'] = 2
MODEM_2['modem_port'] = 2

# setup a third modem on a completely different iSMS server
MODEM_3 = copy.deepcopy(MODEM_1)
MODEM_3['sendsms_url'] = 'http://192.168.0.1/sendmsg'
MODEM_3['server_slug'] = 'server-b'


class MultimodemViewTest(RapidTest):
Expand All @@ -28,7 +34,9 @@ class MultimodemViewTest(RapidTest):
backends = {
'backend-1': MODEM_1,
'backend-2': MODEM_2,
'server-b-backend': MODEM_3,
}
backend_url = reverse('multimodem-backend', kwargs={'server_slug': 'server-a'})

def build_xml_request(self, n=1, message_params=None):
"""Build a valid iSMS XML request.
Expand Down Expand Up @@ -76,19 +84,48 @@ def build_xml_request(self, n=1, message_params=None):
def test_invalid_response(self):
"""HTTP 400 should return if data is invalid."""
data = {}
response = self.client.post(reverse('multimodem-backend'), data)
response = self.client.post(self.backend_url, data)
self.assertEqual(response.status_code, 400)

def test_message_from_unknown_server(self):
"""HTTP 400 should return if server is not known to us."""
data = {'XMLDATA': self.build_xml_request()}
bad_url = reverse('multimodem-backend', kwargs={'server_slug': 'unknown'})
response = self.client.post(bad_url, data)
self.assertEqual(response.status_code, 400)

def test_message_from_unknown_port_on_known_server(self):
"""HTTP 400 should return if port is not known to us."""
data = {'XMLDATA': self.build_xml_request(message_params=[{
'modem_number': 99,
}])}
bad_url = reverse('multimodem-backend', kwargs={'server_slug': 'server-a'})
response = self.client.post(bad_url, data)
self.assertEqual(response.status_code, 400)

def test_improper_configuration_duplicate_server_port_combo(self):
"""HTTP 400 should return if server / port combo exist in more than 1 backend"""
data = {'XMLDATA': self.build_xml_request()}
# Misconfigure so that both backend-1 and backend-2 refer to port 1
self.backends['backend-2']['modem_port'] = 1
self.set_router()
bad_url = reverse('multimodem-backend', kwargs={'server_slug': 'server-a'})
response = self.client.post(bad_url, data)
self.assertEqual(response.status_code, 400)
# undo the misconfiguration for the rest of the testcases in this class
self.backends['backend-2']['modem_port'] = 2
self.set_router()

def test_get_disabled(self):
"""HTTP 405 should return if GET is used."""
data = {}
response = self.client.get(reverse('multimodem-backend'), data)
response = self.client.get(self.backend_url, data)
self.assertEqual(response.status_code, 405)

def test_valid_post_one_message_single_port(self):
"""Valid POSTs should pass message object to router."""
data = {'XMLDATA': self.build_xml_request()}
response = self.client.post(reverse('multimodem-backend'), data)
response = self.client.post(self.backend_url, data)
self.assertEqual(response.status_code, 200)
message = self.inbound[0]
self.assertEqual('a test message', message.text)
Expand All @@ -103,7 +140,7 @@ def test_valid_post_one_message_multiport(self):
}
])
data = {'XMLDATA': xmldata}
response = self.client.post(reverse('multimodem-backend'), data)
response = self.client.post(self.backend_url, data)
self.assertEqual(response.status_code, 200)
message = self.inbound[0]
self.assertEqual('a test message', message.text)
Expand All @@ -120,7 +157,7 @@ def test_valid_unicode_message(self):
}
])
data = {'XMLDATA': xmldata}
response = self.client.post(reverse('multimodem-backend'), data)
response = self.client.post(self.backend_url, data)
self.assertEqual(response.status_code, 200)
message = self.inbound[0]
self.assertEqual(unicode_string, message.text)
Expand All @@ -139,7 +176,7 @@ def test_valid_post_two_messages_single_port(self):
}
])
data = {'XMLDATA': xmldata}
response = self.client.post(reverse('multimodem-backend'), data)
response = self.client.post(self.backend_url, data)
self.assertEqual(response.status_code, 200)
message1, message2 = self.inbound
self.assertEqual('first message', message1.text)
Expand All @@ -160,10 +197,25 @@ def test_messages_from_different_ports_get_to_different_backends(self):
}
])
data = {'XMLDATA': xmldata}
response = self.client.post(reverse('multimodem-backend'), data)
response = self.client.post(self.backend_url, data)
self.assertEqual(response.status_code, 200)
message1, message2 = self.inbound
self.assertEqual('port 1 message', message1.text)
self.assertEqual('backend-1', message1.connection.backend.name)
self.assertEqual('port 2 message', message2.text)
self.assertEqual('backend-2', message2.connection.backend.name)

def test_messages_from_different_servers_dont_get_confused(self):
"""Modem 1 on Server A is not confused for Modem 1 on Server B."""
xmldata_a = self.build_xml_request()
server_a_url = self.backend_url
xmldata_b = self.build_xml_request()
server_b_url = reverse('multimodem-backend', kwargs={'server_slug': 'server-b'})
response_a = self.client.post(server_a_url, data={'XMLDATA': xmldata_a})
self.assertEqual(response_a.status_code, 200)
response_b = self.client.post(server_b_url, data={'XMLDATA': xmldata_b})
self.assertEqual(response_b.status_code, 200)
self.assertEqual(len(self.inbound), 2)
message1, message2 = self.inbound
self.assertEqual('backend-1', message1.connection.backend.name)
self.assertEqual('server-b-backend', message2.connection.backend.name)
4 changes: 3 additions & 1 deletion rapidsms_multimodem/tests/urls.py
Expand Up @@ -3,5 +3,7 @@
from rapidsms_multimodem.views import receive_multimodem_message

urlpatterns = [
url(r"^backend/multimodem/$", receive_multimodem_message, name='multimodem-backend'),
url(r"^backend/multimodem/(?P<server_slug>[\w_-]+)/$",
receive_multimodem_message,
name='multimodem-backend'),
]
38 changes: 21 additions & 17 deletions rapidsms_multimodem/views.py
@@ -1,21 +1,23 @@
from __future__ import unicode_literals
import logging
import xml.etree.ElementTree as ET

from django.conf import settings

from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from .utils import ismsformat_to_unicode

from rapidsms.router import receive
from rapidsms.router import lookup_connections

from .utils import ismsformat_to_unicode

logger = logging.getLogger(__name__)


@csrf_exempt
@require_POST
def receive_multimodem_message(request):
def receive_multimodem_message(request, server_slug):
"""
The view to handle requests from multimodem has to be custom because the server can post 1-* messages in a single
request. The Rapid built-in class-based views only accept a single message per form/post.
Expand Down Expand Up @@ -66,28 +68,26 @@ def receive_multimodem_message(request):
for message in root.findall('MessageNotification'):
raw_text = message.find('Message').text
from_number = message.find('ModemNumber').text
"""
Once we have the modem number we have to try to find its matching backend.
Unfortunately, I'll have to dig through the settings.
"""

# ModemNumber is simply 1 for single-port modems and it's a string of
# 'port_numer:phone_number' for multiport modems.
if ':' in from_number:
modem_number, phone_number = from_number.split(':')[0:2]
else:
# This is a single port modem
modem_number = from_number

# Search through backends looking for the single one with this
# server_slug / modem combo
possible_backends = getattr(settings, 'INSTALLED_BACKENDS', {}).items()
"""
Obviously this needs refactoring.

Two iSMS servers would have the same modem number.
Another solution would be to add the phone number to the settings.
"""
backend_names = [backend[0] for backend in possible_backends
if 'sendsms_params' in backend[1]
and 'modem' in backend[1]['sendsms_params']
and int(backend[1]['sendsms_params']['modem']) == int(modem_number)]
backend_names = [name for name, config in possible_backends
if str(config.get('modem_port')) == str(modem_number)
and config.get('server_slug') == server_slug]
if backend_names:
if len(backend_names) > 1:
logger.error("More than 1 backend with this server/port combo: %s / %s",
server_slug, modem_number)
return HttpResponseBadRequest('Improper Configuration: multiple backends with same server / port')
backend_name = backend_names[0]
encoding = message.find('EncodingFlag').text
if encoding.lower() == "unicode":
Expand All @@ -99,5 +99,9 @@ def receive_multimodem_message(request):
data = {'text': msg_text,
'connection': connections[0]}
receive(**data)
else:
logger.error("Can't find backend for this server/port combo: %s / %s",
server_slug, modem_number)
return HttpResponseBadRequest('Unknown server or port.')

return HttpResponse('OK')