diff --git a/go/config.py b/go/config.py index 19ecd2c50..7c7913e9e 100644 --- a/go/config.py +++ b/go/config.py @@ -136,6 +136,10 @@ def get_router_definition(router_type, router=None): 'namespace': 'keyword', 'display_name': 'Keyword', }, + 'go.routers.app_multiplexer': { + 'namespace': 'app_multiplexer', + 'display_name': 'Application Multiplexer', + }, } _VUMI_OBSOLETE_ROUTERS = [ diff --git a/go/router/templates/router/edit.html b/go/router/templates/router/edit.html index 8c3f3f5c3..36697fa17 100644 --- a/go/router/templates/router/edit.html +++ b/go/router/templates/router/edit.html @@ -23,6 +23,9 @@
{% for edit_form in edit_forms %}
+ {% if edit_form.non_field_errors %} + {{ edit_form.non_field_errors }} + {% endif %} {{ edit_form|crispy }}
{% endfor %} diff --git a/go/routers/app_multiplexer/__init__.py b/go/routers/app_multiplexer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/go/routers/app_multiplexer/common.py b/go/routers/app_multiplexer/common.py new file mode 100644 index 000000000..bb06e68d8 --- /dev/null +++ b/go/routers/app_multiplexer/common.py @@ -0,0 +1,13 @@ +# helpers lifted from GFM and Wikipedia +# +# TODO: move these common text helpers to Vumi Core +# https://github.com/praekelt/vumi/issues/727 + + +def clean(content): + return (content or '').strip() + + +def mkmenu(options, start=1, format='%s) %s'): + items = [format % (idx, opt) for idx, opt in enumerate(options, start)] + return '\n'.join(items) diff --git a/go/routers/app_multiplexer/definition.py b/go/routers/app_multiplexer/definition.py new file mode 100644 index 000000000..89fc331c3 --- /dev/null +++ b/go/routers/app_multiplexer/definition.py @@ -0,0 +1,9 @@ +from go.vumitools.router.definition import RouterDefinitionBase + + +class RouterDefinition(RouterDefinitionBase): + router_type = 'app_multiplexer' + + def configured_outbound_endpoints(self, config): + endpoints = [entry['endpoint'] for entry in config.get('entries', [])] + return list(set(endpoints)) diff --git a/go/routers/app_multiplexer/forms.py b/go/routers/app_multiplexer/forms.py new file mode 100644 index 000000000..d9ba3433b --- /dev/null +++ b/go/routers/app_multiplexer/forms.py @@ -0,0 +1,49 @@ +from django import forms + + +class ApplicationMultiplexerTitleForm(forms.Form): + content = forms.CharField( + label="Menu title", + max_length=100 + ) + + +class ApplicationMultiplexerForm(forms.Form): + application_label = forms.CharField( + label="Application label" + ) + endpoint_name = forms.CharField( + label="Endpoint name" + ) + + +class BaseApplicationMultiplexerFormSet(forms.formsets.BaseFormSet): + + @staticmethod + def initial_from_config(data): + initial_data = [] + for entry in data: + initial_data.append({ + 'application_label': entry['label'], + 'endpoint_name': entry['endpoint'], + }) + return initial_data + + def to_config(self): + entries = [] + for form in self.ordered_forms: + if not form.is_valid(): + continue + entries.append({ + "label": form.cleaned_data['application_label'], + "endpoint": form.cleaned_data['endpoint_name'], + }) + return entries + + +ApplicationMultiplexerFormSet = forms.formsets.formset_factory( + ApplicationMultiplexerForm, + can_delete=True, + can_order=True, + extra=1, + formset=BaseApplicationMultiplexerFormSet) diff --git a/go/routers/app_multiplexer/tests/__init__.py b/go/routers/app_multiplexer/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/go/routers/app_multiplexer/tests/test_views.py b/go/routers/app_multiplexer/tests/test_views.py new file mode 100644 index 000000000..277caee88 --- /dev/null +++ b/go/routers/app_multiplexer/tests/test_views.py @@ -0,0 +1,123 @@ +from go.base.tests.helpers import GoDjangoTestCase +from go.routers.tests.view_helpers import RouterViewsHelper +from go.vumitools.api import VumiApiCommand + + +class ApplicationMultiplexerViewTests(GoDjangoTestCase): + + def setUp(self): + self.router_helper = self.add_helper( + RouterViewsHelper(u'app_multiplexer') + ) + self.user_helper = self.router_helper.vumi_helper.get_or_create_user() + self.client = self.router_helper.get_client() + + def test_new_router(self): + router_store = self.user_helper.user_api.router_store + self.assertEqual([], router_store.list_routers()) + + response = self.client.post(self.router_helper.get_new_view_url(), { + 'name': u"myrouter", + 'router_type': u'app_multiplexer', + }) + [router_key] = router_store.list_routers() + rtr_helper = self.router_helper.get_router_helper_by_key(router_key) + self.assertRedirects(response, rtr_helper.get_view_url('edit')) + + def test_show_stopped(self): + rtr_helper = self.router_helper.create_router_helper(name=u"myrouter") + response = self.client.get(rtr_helper.get_view_url('show')) + router = response.context[0].get('router') + self.assertEqual(router.name, u"myrouter") + self.assertContains(response, rtr_helper.get_view_url('start')) + self.assertNotContains(response, rtr_helper.get_view_url('stop')) + + def test_show_running(self): + rtr_helper = self.router_helper.create_router_helper( + name=u"myrouter", started=True) + response = self.client.get(rtr_helper.get_view_url('show')) + router = response.context[0].get('router') + self.assertEqual(router.name, u"myrouter") + self.assertNotContains(response, rtr_helper.get_view_url('start')) + self.assertContains(response, rtr_helper.get_view_url('stop')) + + def test_start(self): + rtr_helper = self.router_helper.create_router_helper(started=False) + + response = self.client.post(rtr_helper.get_view_url('start')) + self.assertRedirects(response, rtr_helper.get_view_url('show')) + router = rtr_helper.get_router() + self.assertTrue(router.starting()) + [start_cmd] = self.router_helper.get_api_commands_sent() + self.assertEqual( + start_cmd, VumiApiCommand.command( + '%s_router' % (router.router_type,), 'start', + user_account_key=router.user_account.key, + router_key=router.key)) + + def test_stop(self): + rtr_helper = self.router_helper.create_router_helper(started=True) + + response = self.client.post(rtr_helper.get_view_url('stop')) + self.assertRedirects(response, rtr_helper.get_view_url('show')) + router = rtr_helper.get_router() + self.assertTrue(router.stopping()) + [start_cmd] = self.router_helper.get_api_commands_sent() + self.assertEqual( + start_cmd, VumiApiCommand.command( + '%s_router' % (router.router_type,), 'stop', + user_account_key=router.user_account.key, + router_key=router.key)) + + def test_initial_config(self): + rtr_helper = self.router_helper.create_router_helper( + started=True, config={ + 'menu_title': {'content': 'Please select an application'}, + 'entries': [ + { + 'label': 'Flappy Bird', + 'endpoint': 'flappy-bird', + }, + ]}) + response = self.client.get(rtr_helper.get_view_url('edit')) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Please select an application') + self.assertContains(response, 'Flappy Bird') + self.assertContains(response, 'flappy-bird') + + def test_initial_config_empty(self): + rtr_helper = self.router_helper.create_router_helper(started=True) + response = self.client.get(rtr_helper.get_view_url('edit')) + self.assertEqual(response.status_code, 200) + + def test_user_input_good(self): + rtr_helper = self.router_helper.create_router_helper(started=True) + router = rtr_helper.get_router() + self.assertEqual(router.config, {}) + response = self.client.post(rtr_helper.get_view_url('edit'), { + 'menu_title-content': ['Please select an application'], + 'entries-TOTAL_FORMS': ['2'], + 'entries-INITIAL_FORMS': ['0'], + 'entries-MAX_NUM_FORMS': [''], + 'entries-0-application_label': ['Flappy Bird'], + 'entries-0-endpoint_name': ['flappy-bird'], + 'entries-0-DELETE': [''], + 'entries-0-ORDER': ['0'], + 'entries-1-application_label': ['Mama'], + 'entries-1-endpoint_name': ['mama'], + 'entries-1-DELETE': [''], + 'entries-1-ORDER': ['1'], + }) + self.assertRedirects(response, rtr_helper.get_view_url('show')) + router = rtr_helper.get_router() + self.assertEqual(router.config, { + u'menu_title': {u'content': u'Please select an application'}, + u'entries': [ + { + u'label': u'Flappy Bird', + u'endpoint': u'flappy-bird', + }, + { + u'label': u'Mama', + u'endpoint': u'mama', + }]}) diff --git a/go/routers/app_multiplexer/tests/test_vumi_app.py b/go/routers/app_multiplexer/tests/test_vumi_app.py new file mode 100644 index 000000000..77ec260ec --- /dev/null +++ b/go/routers/app_multiplexer/tests/test_vumi_app.py @@ -0,0 +1,464 @@ +import copy + +from twisted.internet.defer import inlineCallbacks + +from vumi.tests.helpers import VumiTestCase +from go.routers.app_multiplexer.vumi_app import ApplicationMultiplexer +from go.routers.tests.helpers import RouterWorkerHelper + + +def raise_error(*args, **kw): + raise RuntimeError("An anomaly has been detected") + + +class TestApplicationMultiplexerRouter(VumiTestCase): + + ROUTER_CONFIG = { + 'invalid_input_message': 'Bad choice.\n1) Try Again', + 'error_message': 'Oops! Sorry!', + 'entries': [ + { + 'label': 'Flappy Bird', + 'endpoint': 'flappy-bird', + }, + ] + } + + @inlineCallbacks + def setUp(self): + self.router_helper = self.add_helper( + RouterWorkerHelper(ApplicationMultiplexer)) + self.router_worker = yield self.router_helper.get_router_worker({}) + + def dynamic_config_with_router(self, router): + msg = self.router_helper.make_inbound(None, router=router) + return self.router_worker.get_config(msg) + + @inlineCallbacks + def setup_session(self, router, user_id, data): + config = yield self.dynamic_config_with_router(router) + session_manager = yield self.router_worker.session_manager(config) + # Initialize session data + yield session_manager.save_session(user_id, data) + + @inlineCallbacks + def assert_session(self, router, user_id, expected_session): + config = yield self.dynamic_config_with_router(router) + session_manager = yield self.router_worker.session_manager(config) + session = yield session_manager.load_session(user_id) + if 'created_at' in session: + del session['created_at'] + self.assertEqual(session, expected_session, + msg="Unexpected session data") + + @inlineCallbacks + def test_start(self): + router = yield self.router_helper.create_router() + self.assertTrue(router.stopped()) + self.assertFalse(router.running()) + + yield self.router_helper.start_router(router) + router = yield self.router_helper.get_router(router.key) + self.assertFalse(router.stopped()) + self.assertTrue(router.running()) + + @inlineCallbacks + def test_stop(self): + router = yield self.router_helper.create_router(started=True) + self.assertFalse(router.stopped()) + self.assertTrue(router.running()) + + yield self.router_helper.stop_router(router) + router = yield self.router_helper.get_router(router.key) + self.assertTrue(router.stopped()) + self.assertFalse(router.running()) + + @inlineCallbacks + def test_no_messages_processed_while_stopped(self): + router = yield self.router_helper.create_router() + + yield self.router_helper.ri.make_dispatch_inbound("foo", router=router) + self.assertEqual([], self.router_helper.ro.get_dispatched_inbound()) + + yield self.router_helper.ri.make_dispatch_ack(router=router) + self.assertEqual([], self.router_helper.ro.get_dispatched_events()) + + yield self.router_helper.ro.make_dispatch_outbound( + "foo", router=router) + self.assertEqual([], self.router_helper.ri.get_dispatched_outbound()) + [nack] = self.router_helper.ro.get_dispatched_events() + self.assertEqual(nack['event_type'], 'nack') + + @inlineCallbacks + def test_new_session_display_menu(self): + """ + Prompt user to choice an application endpoint. + """ + router = yield self.router_helper.create_router( + started=True, config=self.ROUTER_CONFIG) + # msg sent from user + yield self.router_helper.ri.make_dispatch_inbound( + None, router=router, from_addr='123') + + # assert that the user received a response + [msg] = self.router_helper.ri.get_dispatched_outbound() + self.assertEqual(msg['content'], + 'Please select a choice.\n1) Flappy Bird') + # assert that session data updated correctly + yield self.assert_session(router, '123', { + 'state': ApplicationMultiplexer.STATE_SELECT, + 'endpoints': '["flappy-bird"]', + }) + + @inlineCallbacks + def test_select_application_endpoint(self): + """ + Retrieve endpoint choice from user and set currently active + endpoint. + """ + router = yield self.router_helper.create_router( + started=True, config=self.ROUTER_CONFIG) + + yield self.setup_session(router, '123', { + 'state': ApplicationMultiplexer.STATE_SELECT, + 'endpoints': '["flappy-bird"]', + }) + + # msg sent from user + msg = yield self.router_helper.ri.make_dispatch_inbound( + '1', router=router, from_addr='123', session_event='resume') + + # assert that message is forwarded to application + [msg] = self.router_helper.ro.get_dispatched_inbound() + self.assertEqual(msg['content'], None) + self.assertEqual(msg['session_event'], 'new') + + # application sends reply + yield self.router_helper.ro.make_dispatch_reply(msg, 'Flappy Flappy!') + + # assert that the user received a response + [msg] = self.router_helper.ri.get_dispatched_outbound() + self.assertEqual(msg['content'], 'Flappy Flappy!') + + yield self.assert_session(router, '123', { + 'state': ApplicationMultiplexer.STATE_SELECTED, + 'active_endpoint': 'flappy-bird', + 'endpoints': '["flappy-bird"]', + }) + + @inlineCallbacks + def test_session_with_selected_endpoint(self): + """ + Tests an ongoing USSD session with a previously selected endpoint + """ + router = yield self.router_helper.create_router( + started=True, config=self.ROUTER_CONFIG) + + yield self.setup_session(router, '123', { + 'state': ApplicationMultiplexer.STATE_SELECTED, + 'active_endpoint': 'flappy-bird', + 'endpoints': '["flappy-bird"]', + }) + + # msg sent from user + msg = yield self.router_helper.ri.make_dispatch_inbound( + 'Up!', router=router, from_addr='123', session_event='resume') + + # assert that message is forwarded to application + [msg] = self.router_helper.ro.get_dispatched_inbound() + self.assertEqual(msg['content'], 'Up!') + self.assertEqual(msg['session_event'], 'resume') + + # application sends reply + yield self.router_helper.ro.make_dispatch_reply( + msg, 'Game Over!\n1) Try Again!') + + # assert that the user received a response + [msg] = self.router_helper.ri.get_dispatched_outbound() + self.assertEqual(msg['content'], + 'Game Over!\n1) Try Again!') + + yield self.assert_session(router, '123', { + 'state': ApplicationMultiplexer.STATE_SELECTED, + 'active_endpoint': 'flappy-bird', + 'endpoints': '["flappy-bird"]', + }) + + @inlineCallbacks + def test_bad_input_for_endpoint_choice(self): + """ + User entered bad input for the endpoint selection menu. + """ + router = yield self.router_helper.create_router( + started=True, config=self.ROUTER_CONFIG) + + yield self.setup_session(router, '123', { + 'state': ApplicationMultiplexer.STATE_SELECT, + 'endpoints': '["flappy-bird"]', + }) + + # msg sent from user + msg = yield self.router_helper.ri.make_dispatch_inbound( + 'foo', router=router, from_addr='123', session_event='resume') + + # assert that the user received a response + [msg] = self.router_helper.ri.get_dispatched_outbound() + self.assertEqual(msg['content'], + 'Bad choice.\n1) Try Again') + + yield self.assert_session(router, '123', { + 'state': ApplicationMultiplexer.STATE_BAD_INPUT, + 'endpoints': '["flappy-bird"]', + }) + + @inlineCallbacks + def test_state_bad_input_for_bad_input_prompt(self): + """ + User entered bad input for the prompt telling the user + that they entered bad input (ha! recursive). + """ + router = yield self.router_helper.create_router( + started=True, config=self.ROUTER_CONFIG) + + yield self.setup_session(router, '123', { + 'state': ApplicationMultiplexer.STATE_BAD_INPUT, + 'endpoints': '["flappy-bird"]', + }) + + # msg sent from user + msg = yield self.router_helper.ri.make_dispatch_inbound( + 'foo', router=router, from_addr='123', session_event='resume') + + # assert that the user received a response + [msg] = self.router_helper.ri.get_dispatched_outbound() + self.assertEqual(msg['content'], + 'Bad choice.\n1) Try Again') + + yield self.assert_session(router, '123', { + 'state': ApplicationMultiplexer.STATE_BAD_INPUT, + 'endpoints': '["flappy-bird"]', + }) + + @inlineCallbacks + def test_state_good_input_for_bad_input_prompt(self): + """ + User entered good input for the prompt telling the user + that they entered bad input. + """ + router = yield self.router_helper.create_router( + started=True, config=self.ROUTER_CONFIG) + + yield self.setup_session(router, '123', { + 'state': ApplicationMultiplexer.STATE_BAD_INPUT, + 'endpoints': '["flappy-bird"]', + }) + + # msg sent from user + msg = yield self.router_helper.ri.make_dispatch_inbound( + '1', router=router, from_addr='123', session_event='resume') + + # assert that the user received a response + [msg] = self.router_helper.ri.get_dispatched_outbound() + self.assertEqual(msg['content'], + 'Please select a choice.\n1) Flappy Bird') + + yield self.assert_session(router, '123', { + 'state': ApplicationMultiplexer.STATE_SELECT, + 'endpoints': '["flappy-bird"]', + }) + + @inlineCallbacks + def test_runtime_exception_in_selected_handler(self): + """ + Verifies that the worker handles an arbitrary runtime error gracefully, + and sends an appropriate error message back to the user + """ + router = yield self.router_helper.create_router( + started=True, config=self.ROUTER_CONFIG) + + # Make worker.target_endpoints raise an exception + self.patch(self.router_worker, + 'target_endpoints', + raise_error) + + yield self.setup_session(router, '123', { + 'state': ApplicationMultiplexer.STATE_SELECTED, + 'active_endpoint': 'flappy-bird', + 'endpoints': '["flappy-bird"]', + }) + + # msg sent from user + msg = yield self.router_helper.ri.make_dispatch_inbound( + 'Up!', router=router, from_addr='123', session_event='resume') + # assert that the user received a response + [msg] = self.router_helper.ri.get_dispatched_outbound() + self.assertEqual(msg['content'], + 'Oops! Sorry!') + + yield self.assert_session(router, '123', {}) + + errors = self.flushLoggedErrors(RuntimeError) + self.assertEqual(len(errors), 1) + + @inlineCallbacks + def test_session_invalidation_in_state_handler(self): + """ + Verify that the router gracefully handles a configuration + update while there is an active user session. + + A session is aborted if there is no longer an attached endpoint + to which it refers. + """ + config = copy.deepcopy(self.ROUTER_CONFIG) + config['entries'][0]['endpoint'] = 'mama' + router = yield self.router_helper.create_router( + started=True, config=config) + yield self.setup_session(router, '123', { + 'state': ApplicationMultiplexer.STATE_SELECTED, + 'active_endpoint': 'flappy-bird', + 'endpoints': '["flappy-bird"]', + }) + + # msg sent from user + msg = yield self.router_helper.ri.make_dispatch_inbound( + 'Up!', router=router, from_addr='123', session_event='resume') + # assert that the user received a response + [msg] = self.router_helper.ri.get_dispatched_outbound() + self.assertEqual(msg['content'], + 'Oops! Sorry!') + yield self.assert_session(router, '123', {}) + + @inlineCallbacks + def test_state_selected_receive_close_inbound(self): + """ + User sends 'close' msg to the active endpoint via the router. + Verify that the message is forwarded and that the session for + the user is cleared. + """ + router = yield self.router_helper.create_router( + started=True, config=self.ROUTER_CONFIG) + + yield self.setup_session(router, '123', { + 'state': ApplicationMultiplexer.STATE_SELECTED, + 'active_endpoint': 'flappy-bird', + 'endpoints': '["flappy-bird"]', + }) + + # msg sent from user + msg = yield self.router_helper.ri.make_dispatch_inbound( + None, router=router, from_addr='123', session_event='close') + + # assert app received forwarded 'close' message + [msg] = self.router_helper.ro.get_dispatched_inbound() + self.assertEqual(msg['content'], None) + self.assertEqual(msg['session_event'], 'close') + + # assert that no response sent to user + msgs = self.router_helper.ri.get_dispatched_outbound() + self.assertEqual(msgs, []) + + # assert that session cleared + yield self.assert_session(router, '123', {}) + + @inlineCallbacks + def test_receive_close_inbound(self): + """ + Same as the above test, but only for the case when + an active endpoint has not yet been selected. + """ + router = yield self.router_helper.create_router( + started=True, config=self.ROUTER_CONFIG) + + yield self.setup_session(router, '123', { + 'state': ApplicationMultiplexer.STATE_SELECT, + 'endpoints': '["flappy-bird"]' + }) + + # msg sent from user + msg = yield self.router_helper.ri.make_dispatch_inbound( + None, router=router, from_addr='123', session_event='close') + + # assert that no app received a forwarded 'close' message + msgs = self.router_helper.ro.get_dispatched_inbound() + self.assertEqual(msgs, []) + self.assertEqual(msg['session_event'], 'close') + + # assert that no response sent to user + msgs = self.router_helper.ri.get_dispatched_outbound() + self.assertEqual(msgs, []) + + # assert that session cleared + yield self.assert_session(router, '123', {}) + + @inlineCallbacks + def test_receive_close_outbound(self): + """ + Application sends a 'close' message to the user via + the router. Verify that the message is forwarded correctly, + and that the session is terminated. + """ + router = yield self.router_helper.create_router( + started=True, config=self.ROUTER_CONFIG) + + yield self.setup_session(router, '123', { + 'state': ApplicationMultiplexer.STATE_SELECTED, + 'active_endpoint': 'flappy-bird', + 'endpoints': '["flappy-bird"]', + }) + + # msg sent from user + msg = yield self.router_helper.ri.make_dispatch_inbound( + "3", router=router, from_addr='123', session_event='resume') + + # application quits session + yield self.router_helper.ro.make_dispatch_reply( + msg, 'Game Over!', session_event='close') + + # assert that user receives the forwarded 'close' message + [msg] = self.router_helper.ri.get_dispatched_outbound() + self.assertEqual(msg['content'], 'Game Over!') + self.assertEqual(msg['session_event'], 'close') + + # assert that session cleared + yield self.assert_session(router, '123', {}) + + def test_get_menu_choice(self): + """ + Verify that we parse user input correctly for menu prompts. + """ + # good + msg = self.router_helper.make_inbound(content='3 ') + choice = self.router_worker.get_menu_choice(msg, (1, 4)) + self.assertEqual(choice, 3) + + # bad - out of range + choice = self.router_worker.get_menu_choice(msg, (1, 2)) + self.assertEqual(choice, None) + + # bad - non-numeric input + msg = self.router_helper.make_inbound(content='Foo ') + choice = self.router_worker.get_menu_choice(msg, (1, 2)) + self.assertEqual(choice, None) + + def test_create_menu(self): + """ + Create a menu prompt to choose between linked endpoints + """ + router_worker = yield self.router_helper.get_router_worker({ + 'menu_title': {'content': 'Please select a choice'}, + 'entries': [ + { + 'label': 'Flappy Bird', + 'endpoint': 'flappy-bird', + }, + { + 'label': 'Mama', + 'endpoint': 'mama', + } + ] + }) + text = router_worker.create_menu(self.router_worker.config) + self.assertEqual( + text, + 'Please select a choice\n1) Flappy Bird\n2) Mama' + ) diff --git a/go/routers/app_multiplexer/view_definition.py b/go/routers/app_multiplexer/view_definition.py new file mode 100644 index 000000000..37a978888 --- /dev/null +++ b/go/routers/app_multiplexer/view_definition.py @@ -0,0 +1,15 @@ +from go.router.view_definition import RouterViewDefinitionBase, EditRouterView + +from go.routers.app_multiplexer.forms import (ApplicationMultiplexerTitleForm, + ApplicationMultiplexerFormSet) + + +class EditApplicationMultiplexerView(EditRouterView): + edit_forms = ( + ('menu_title', ApplicationMultiplexerTitleForm), + ('entries', ApplicationMultiplexerFormSet), + ) + + +class RouterViewDefinition(RouterViewDefinitionBase): + edit_view = EditApplicationMultiplexerView diff --git a/go/routers/app_multiplexer/vumi_app.py b/go/routers/app_multiplexer/vumi_app.py new file mode 100644 index 000000000..65885ad75 --- /dev/null +++ b/go/routers/app_multiplexer/vumi_app.py @@ -0,0 +1,269 @@ +# -*- test-case-name: go.routers.app_multiplexer.tests.test_vumi_app -*- +import json + +from twisted.internet.defer import inlineCallbacks, returnValue + +from vumi import log +from vumi.config import ConfigDict, ConfigList, ConfigInt, ConfigText +from vumi.components.session import SessionManager +from vumi.message import TransportUserMessage + +from go.vumitools.app_worker import GoRouterWorker +from go.routers.app_multiplexer.common import mkmenu, clean + + +class ApplicationMultiplexerConfig(GoRouterWorker.CONFIG_CLASS): + + # Static configuration + session_expiry = ConfigInt( + "Maximum amount of time in seconds to keep session data around", + default=300, static=True) + + # Dynamic, per-message configuration + menu_title = ConfigDict( + "Content for the menu title", + default={'content': "Please select a choice."}) + entries = ConfigList( + "A list of application endpoints and associated labels", + default=[]) + invalid_input_message = ConfigText( + "Prompt to display when warning about an invalid choice", + default=("That is an incorrect choice. Please enter the number " + "of the menu item you wish to choose.\n\n 1) Try Again")) + error_message = ConfigText( + ("Prompt to display when a configuration change invalidates " + "an active session."), + default=("Oops! We experienced a temporary error. " + "Please try and dial the line again.")) + + +class ApplicationMultiplexer(GoRouterWorker): + """ + Router that multiplexes between different endpoints + on the outbound path. + + State Diagram (for fun): + + +----------------+ + | | + | start | + | | + +----+-----------+ + | + | + +----*-----------+ +----------------+ + | *----+ | + | select | | bad_input | + | +----* | + +----+----*------+ +----------------+ + | | + | | + +----*----+------+ + | | + | selected + + | | + +----------------+ +""" + + CONFIG_CLASS = ApplicationMultiplexerConfig + + worker_name = 'application_multiplexer' + + STATE_START = "start" + STATE_SELECT = "select" + STATE_SELECTED = "selected" + STATE_BAD_INPUT = "bad_input" + + def setup_router(self): + d = super(ApplicationMultiplexer, self).setup_router() + self.handlers = { + self.STATE_START: self.handle_state_start, + self.STATE_SELECT: self.handle_state_select, + self.STATE_SELECTED: self.handle_state_selected, + self.STATE_BAD_INPUT: self.handle_state_bad_input, + } + return d + + def session_manager(self, config): + return SessionManager.from_redis_config( + config.redis_manager, + key_prefix=':'.join((self.worker_name, config.router.key)), + max_session_length=config.session_expiry + ) + + def target_endpoints(self, config): + """ + Make sure the currently active endpoint is still valid. + """ + return set([entry['endpoint'] for entry in config.entries]) + + @inlineCallbacks + def handle_inbound(self, config, msg, conn_name): + """ + Main delegation point for handling inbound messages and + managing the state machine. + """ + + log.msg("Processing inbound message: %s" % (msg,)) + + user_id = msg['from_addr'] + session_manager = yield self.session_manager(config) + session = yield session_manager.load_session(user_id) + session_event = msg['session_event'] + if not session or session_event == TransportUserMessage.SESSION_NEW: + log.msg("Creating session for user %s" % user_id) + session = {} + state = self.STATE_START + yield session_manager.create_session(user_id) + elif session_event == TransportUserMessage.SESSION_CLOSE: + yield self.handle_session_close(config, session, msg) + return + else: + log.msg("Loading session for user %s: %s" % (user_id, session,)) + state = session['state'] + + try: + next_state, updated_session = yield self.handlers[state]( + config, session, msg) + if next_state is None: + # Session terminated (right now, just in the case of a + # administrator-initiated configuration change + yield session_manager.clear_session(user_id) + else: + session['state'] = next_state + session.update(updated_session) + if state != next_state: + log.msg("State transition for user %s: %s => %s" % + (user_id, state, next_state)) + yield session_manager.save_session(user_id, session) + except: + log.err() + yield session_manager.clear_session(user_id) + yield self.publish_error_reply(msg, config) + + @inlineCallbacks + def handle_state_start(self, config, session, msg): + """ + When presenting the menu, we also store the list of endpoints + in the session data. Later, in the SELECT state, we load + these endpoints and retrieve the candidate endpoint based + on the user's menu choice. + """ + reply_msg = msg.reply(self.create_menu(config)) + yield self.publish_outbound(reply_msg) + endpoints = json.dumps( + [entry['endpoint'] for entry in config.entries] + ) + returnValue((self.STATE_SELECT, {'endpoints': endpoints})) + + @inlineCallbacks + def handle_state_select(self, config, session, msg): + endpoint = self.get_endpoint_for_choice(msg, session) + if endpoint is None: + reply_msg = msg.reply(config.invalid_input_message) + yield self.publish_outbound(reply_msg) + returnValue((self.STATE_BAD_INPUT, {})) + else: + if endpoint not in self.target_endpoints(config): + log.msg(("Router configuration change forced session " + "termination for user %s" % msg['from_addr'])) + yield self.publish_error_reply(msg, config) + returnValue((None, {})) + else: + forwarded_msg = self.forwarded_message( + msg, + content=None, + session_event=TransportUserMessage.SESSION_NEW + ) + yield self.publish_inbound(forwarded_msg, endpoint) + log.msg("Switched to endpoint '%s' for user %s" % + (endpoint, msg['from_addr'])) + returnValue((self.STATE_SELECTED, + {'active_endpoint': endpoint})) + + @inlineCallbacks + def handle_state_selected(self, config, session, msg): + active_endpoint = session['active_endpoint'] + if active_endpoint not in self.target_endpoints(config): + log.msg(("Router configuration change forced session " + "termination for user %s" % msg['from_addr'])) + yield self.publish_error_reply(msg, config) + returnValue((None, {})) + else: + yield self.publish_inbound(msg, active_endpoint) + returnValue((self.STATE_SELECTED, {})) + + @inlineCallbacks + def handle_state_bad_input(self, config, session, msg): + choice = self.get_menu_choice(msg, (1, 1)) + if choice is None: + reply_msg = msg.reply(config.invalid_input_message) + yield self.publish_outbound(reply_msg) + returnValue((self.STATE_BAD_INPUT, {})) + else: + result = yield self.handle_state_start(config, session, msg) + returnValue(result) + + @inlineCallbacks + def handle_outbound(self, config, msg, conn_name): + log.msg("Processing outbound message: %s" % (msg,)) + user_id = msg['to_addr'] + session_event = msg['session_event'] + session_manager = yield self.session_manager(config) + session = yield session_manager.load_session(user_id) + if session and (session_event == TransportUserMessage.SESSION_CLOSE): + yield session_manager.clear_session(user_id) + yield self.publish_outbound(msg) + + @inlineCallbacks + def handle_session_close(self, config, session, msg): + user_id = msg['from_addr'] + if (session.get('state', None) == self.STATE_SELECTED and + session['active_endpoint'] in self.target_endpoints(config)): + yield self.publish_inbound(msg, session['active_endpoint']) + session_manager = yield self.session_manager(config) + yield session_manager.clear_session(user_id) + + def publish_outbound(self, msg): + return super(ApplicationMultiplexer, self).publish_outbound( + msg, "default") + + def publish_error_reply(self, msg, config): + reply_msg = msg.reply( + config.error_message, + continue_session=False + ) + return self.publish_outbound(reply_msg) + + def forwarded_message(self, msg, **kwargs): + copy = TransportUserMessage(**msg.payload) + for k, v in kwargs.items(): + copy[k] = v + return copy + + def get_endpoint_for_choice(self, msg, session): + """ + Retrieves the candidate endpoint based on the user's numeric choice + """ + endpoints = json.loads(session['endpoints']) + index = self.get_menu_choice(msg, (1, len(endpoints))) + if index is None: + return None + return endpoints[index - 1] + + def get_menu_choice(self, msg, valid_range): + """ + Parse user input for selecting a numeric menu choice + """ + try: + value = int(clean(msg['content'])) + except ValueError: + return None + else: + if value not in range(valid_range[0], valid_range[1] + 1): + return None + return value + + def create_menu(self, config): + labels = [entry['label'] for entry in config.entries] + return (config.menu_title['content'] + "\n" + mkmenu(labels))