diff --git a/README.rst b/README.rst index 37bb752..bbd714c 100644 --- a/README.rst +++ b/README.rst @@ -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[\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 diff --git a/docs/quick-start.rst b/docs/quick-start.rst index 7160d42..ff18e8a 100644 --- a/docs/quick-start.rst +++ b/docs/quick-start.rst @@ -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 @@ -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://:81/sendmsg", "sendsms_user": "", "sendsms_pass": "", + "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[\w_-]+)/$", + receive_multimodem_message, name='multimodem-backend'), + ] -Now inbound MultiModem messages can be received at -``/backend/multimodem/`` and outbound messages will be +Now inbound MultiModem messages can be received at +``/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. diff --git a/rapidsms_multimodem/outgoing.py b/rapidsms_multimodem/outgoing.py index e1d5ba3..6a73693 100644 --- a/rapidsms_multimodem/outgoing.py +++ b/rapidsms_multimodem/outgoing.py @@ -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): @@ -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) diff --git a/rapidsms_multimodem/tests/test_outgoing.py b/rapidsms_multimodem/tests/test_outgoing.py index b83c8f5..bf288d1 100644 --- a/rapidsms_multimodem/tests/test_outgoing.py +++ b/rapidsms_multimodem/tests/test_outgoing.py @@ -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) diff --git a/rapidsms_multimodem/tests/test_views.py b/rapidsms_multimodem/tests/test_views.py index c51cd87..e7b56b3 100644 --- a/rapidsms_multimodem/tests/test_views.py +++ b/rapidsms_multimodem/tests/test_views.py @@ -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): @@ -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. @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/rapidsms_multimodem/tests/urls.py b/rapidsms_multimodem/tests/urls.py index ac98e25..b1e66fd 100644 --- a/rapidsms_multimodem/tests/urls.py +++ b/rapidsms_multimodem/tests/urls.py @@ -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[\w_-]+)/$", + receive_multimodem_message, + name='multimodem-backend'), ] diff --git a/rapidsms_multimodem/views.py b/rapidsms_multimodem/views.py index 2e94d69..fd84842 100644 --- a/rapidsms_multimodem/views.py +++ b/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. @@ -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": @@ -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')