diff --git a/decisiontree/admin.py b/decisiontree/admin.py index acabaaa..fb47b37 100644 --- a/decisiontree/admin.py +++ b/decisiontree/admin.py @@ -69,6 +69,10 @@ class SessionAdmin(admin.ModelAdmin): list_display = ('id', 'connection', 'tree', 'canceled') +class TranscriptMessageAdmin(admin.ModelAdmin): + pass + + admin.site.register(models.Tree, TreeAdmin) admin.site.register(models.Question, QuestionAdmin) admin.site.register(models.Answer, AnswerAdmin) @@ -78,3 +82,4 @@ class SessionAdmin(admin.ModelAdmin): admin.site.register(models.TagNotification, TagNotificationAdmin) admin.site.register(models.Entry, EntryAdmin) admin.site.register(models.Session, SessionAdmin) +admin.site.register(models.TranscriptMessage, TranscriptMessageAdmin) diff --git a/decisiontree/app.py b/decisiontree/app.py index 22dcbde..2552e6a 100644 --- a/decisiontree/app.py +++ b/decisiontree/app.py @@ -9,7 +9,7 @@ from rapidsms.models import Connection from . import conf -from .models import Entry, Session, TagNotification, Transition +from .models import Entry, Session, TagNotification, Transition, TranscriptMessage from .signals import session_end_signal from .utils import get_survey @@ -21,6 +21,21 @@ class App(AppBase): registered_functions = {} session_listeners = {} + def record_message(self, msg, session): + TranscriptMessage.objects.create( + session=session, + direction=TranscriptMessage.INCOMING, + message=msg.text, + ) + + def send_response(self, msg, session, response): + TranscriptMessage.objects.create( + session=session, + direction=TranscriptMessage.OUTGOING, + message=response, + ) + msg.respond(response) + def handle(self, msg): sessions = msg.connection.session_set.open().select_related('state') @@ -38,13 +53,14 @@ def handle(self, msg): # the caller is part-way though a question # tree, so check their answer and respond session = sessions[0] + self.record_message(msg, session) state = session.state logger.debug(state) end_trigger = conf.SESSION_END_TRIGGER if end_trigger is not None and msg.text == end_trigger: response = _("Your session with '%s' has ended") - msg.respond(response % session.tree.trigger) + self.send_response(msg, session, response % session.tree.trigger) self._end_session(session, True, message=msg) return True @@ -63,7 +79,7 @@ def handle(self, msg): if not found_transition: if transitions.count() == 0: logger.error('No questions found!') - msg.respond(_("No questions found")) + self.send_response(msg, session, _("No questions found")) self._end_session(session, message=msg) else: # update the number of times the user has tried @@ -74,17 +90,19 @@ def handle(self, msg): if state.num_retries is not None: if session.num_tries >= state.num_retries: session.state = None - msg.respond("Sorry, invalid answer %d times. " - "Your session will now end. Please try again " - "later." % session.num_tries) + self.send_response( + msg, session, + "Sorry, invalid answer %d times. " + "Your session will now end. Please try again " + "later." % session.num_tries) # send them some hints about how to respond elif state.question.error_response: - msg.respond(state.question.error_response) + self.send_response(msg, session, state.question.error_response) else: answers = [t.answer.helper_text() for t in transitions] answers = " or ".join(answers) response = '"%s" is not a valid answer. You must enter ' + answers - msg.respond(response % msg.text) + self.send_response(msg, session, response % msg.text) session.save() return True @@ -128,7 +146,7 @@ def handle(self, msg): # completion text and if so send it if not session.state: if session.tree.completion_text: - msg.respond(session.tree.completion_text) + self.send_response(msg, session, session.tree.completion_text) # end the connection so the caller can start a new session self._end_session(session, message=msg) @@ -155,12 +173,13 @@ def tick(self, session): self.router.incoming(msg) msg.flush_responses() # make sure response goes out - def start_tree(self, tree, connection, msg=None): + def start_tree(self, tree, connection, msg): """Initiates a new tree sequence, terminating any active sessions""" self.end_sessions(connection) session = Session(connection=connection, tree=tree, state=tree.root_state, num_tries=0) session.save() + self.record_message(msg, session) logger.debug("new session %s saved", session) # also notify any session listeners of this @@ -177,7 +196,7 @@ def _send_question(self, session, msg=None): response = state.question.text logger.info("Sending: %s", response) if msg: - msg.respond(response) + self.send_response(msg, session, response) else: # we need to get the real backend from the router # to properly send it diff --git a/decisiontree/migrations/0005_num_tries_default.py b/decisiontree/migrations/0005_num_tries_default.py new file mode 100644 index 0000000..348347e --- /dev/null +++ b/decisiontree/migrations/0005_num_tries_default.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('decisiontree', '0004_answer_color'), + ] + + operations = [ + migrations.AlterField( + model_name='session', + name='num_tries', + field=models.PositiveIntegerField(default=0, help_text=b'The number of times the user has tried to answer the current question.'), + preserve_default=True, + ), + ] diff --git a/decisiontree/migrations/0006_transcriptmessage.py b/decisiontree/migrations/0006_transcriptmessage.py new file mode 100644 index 0000000..f90ebdd --- /dev/null +++ b/decisiontree/migrations/0006_transcriptmessage.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('decisiontree', '0005_num_tries_default'), + ] + + operations = [ + migrations.CreateModel( + name='TranscriptMessage', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('direction', models.CharField(max_length=1, choices=[(b'I', b'Incoming'), (b'O', b'Outgoing')])), + ('message', models.CharField(max_length=255)), + ('created', models.DateTimeField(auto_now_add=True)), + ('session', models.ForeignKey(to='decisiontree.Session')), + ], + options={ + 'ordering': ['created'], + }, + bases=(models.Model,), + ), + ] diff --git a/decisiontree/models.py b/decisiontree/models.py index f6c4bc9..1cd4bdf 100644 --- a/decisiontree/models.py +++ b/decisiontree/models.py @@ -227,6 +227,7 @@ class Session(models.Model): TreeState, blank=True, null=True, help_text="None if the session is complete.") num_tries = models.PositiveIntegerField( + default=0, help_text="The number of times the user has tried to answer the " "current question.") # this flag stores the difference between completed @@ -329,3 +330,24 @@ def save(self, **kwargs): if not self.pk: self.date_added = datetime.datetime.now() super(TagNotification, self).save(**kwargs) + + +@python_2_unicode_compatible +class TranscriptMessage(models.Model): + INCOMING = 'I' + OUTGOING = 'O' + DIRECTION_CHOICES = ( + (INCOMING, 'Incoming'), + (OUTGOING, 'Outgoing'), + ) + + session = models.ForeignKey('Session') + direction = models.CharField(max_length=1, choices=DIRECTION_CHOICES) + message = models.CharField(max_length=255) + created = models.DateTimeField(auto_now_add=True) + + class Meta(object): + ordering = ['created'] + + def __str__(self): + return self.message diff --git a/decisiontree/multitenancy/forms.py b/decisiontree/multitenancy/forms.py index b485ab7..ba22ce2 100644 --- a/decisiontree/multitenancy/forms.py +++ b/decisiontree/multitenancy/forms.py @@ -33,5 +33,5 @@ def save(self, *args, **kwargs): obj = super(TenancyModelForm, self).save(*args, **kwargs) if utils.multitenancy_enabled(): TenantLink = utils.get_link_class_from_model(obj._meta.model) - TenantLink.all_tenants.get_or_create(tenant=self.tenant, linked=obj) + TenantLink.objects.get_or_create(tenant=self.tenant, linked=obj) return obj diff --git a/decisiontree/multitenancy/migrations/0002_transcriptmessagelink.py b/decisiontree/multitenancy/migrations/0002_transcriptmessagelink.py new file mode 100644 index 0000000..d1706db --- /dev/null +++ b/decisiontree/multitenancy/migrations/0002_transcriptmessagelink.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('decisiontree', '0006_transcriptmessage'), + ('multitenancy', '0003_auto_20141115_1029'), + ('decisiontree_multitenancy', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='TranscriptMessageLink', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('linked', models.OneToOneField(related_name='tenantlink', to='decisiontree.TranscriptMessage')), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.SET_NULL, default=None, to='multitenancy.Tenant', null=True)), + ], + options={ + 'abstract': False, + }, + bases=(models.Model,), + ), + ] diff --git a/decisiontree/multitenancy/models.py b/decisiontree/multitenancy/models.py index 140b2b8..c3be6e5 100644 --- a/decisiontree/multitenancy/models.py +++ b/decisiontree/multitenancy/models.py @@ -1,11 +1,11 @@ -from multitenancy.models import TenantEnabled - from django.db import models from django.utils.encoding import python_2_unicode_compatible @python_2_unicode_compatible -class TenantLink(TenantEnabled): +class TenantLink(models.Model): + tenant = models.ForeignKey( + 'multitenancy.Tenant', null=True, default=None, on_delete=models.SET_NULL) # Whether the linked object's relationship to a tenant is direct or # derived. Derived relationships are handled by the signals in # decisiontree/multitenancy/signals.py. @@ -45,6 +45,11 @@ class TagNotificationLink(TenantLink): linked = models.OneToOneField('decisiontree.TagNotification', related_name='tenantlink') +class TranscriptMessageLink(TenantLink): + direct = False + linked = models.OneToOneField('decisiontree.TranscriptMessage', related_name='tenantlink') + + class TransitionLink(TenantLink): linked = models.OneToOneField('decisiontree.Transition', related_name='tenantlink') diff --git a/decisiontree/multitenancy/signals.py b/decisiontree/multitenancy/signals.py index 46424e5..a5c4e30 100644 --- a/decisiontree/multitenancy/signals.py +++ b/decisiontree/multitenancy/signals.py @@ -15,27 +15,25 @@ def create_session_tenant_link(sender, instance, **kwargs): """Infer a tenant link from the associated connection.""" tenant_id = instance.connection.backend.tenantlink.tenant_id - link_class = utils.get_link_class_from_model(sender) - tenant_link, _ = link_class.all_tenants.get_or_create(linked=instance) - tenant_link.tenant_id = tenant_id - tenant_link.save() + utils.create_tenant_link(instance, tenant_id) @receiver(post_save, sender=tree_models.Entry) def create_entry_tenant_link(sender, instance, **kwargs): """Infer a tenant link from the associated session.""" tenant_id = instance.session.tenantlink.tenant_id - link_class = utils.get_link_class_from_model(sender) - tenant_link, _ = link_class.all_tenants.get_or_create(linked=instance) - tenant_link.tenant_id = tenant_id - tenant_link.save() + utils.create_tenant_link(instance, tenant_id) @receiver(post_save, sender=tree_models.TagNotification) def create_tag_notification_tenant_link(sender, instance, **kwargs): """Infer a tenant link from the associated tag.""" tenant_id = instance.tag.tenantlink.tenant_id - link_class = utils.get_link_class_from_model(sender) - tenant_link, _ = link_class.all_tenants.get_or_create(linked=instance) - tenant_link.tenant_id = tenant_id - tenant_link.save() + utils.create_tenant_link(instance, tenant_id) + + +@receiver(post_save, sender=tree_models.TranscriptMessage) +def create_transcript_message_tenant_link(sender, instance, **kwargs): + """Infer a tenant link from the associated session.""" + tenant_id = instance.session.tenantlink.tenant_id + utils.create_tenant_link(instance, tenant_id) diff --git a/decisiontree/multitenancy/utils.py b/decisiontree/multitenancy/utils.py index 5d2cfa9..cc45821 100644 --- a/decisiontree/multitenancy/utils.py +++ b/decisiontree/multitenancy/utils.py @@ -7,6 +7,14 @@ def multitenancy_enabled(): return "decisiontree.multitenancy" in settings.INSTALLED_APPS +def create_tenant_link(instance, tenant_id): + """Link the instance to a tenant.""" + link_class = get_link_class_from_model(instance) + tenant_link, _ = link_class.objects.get_or_create(linked=instance) + tenant_link.tenant_id = tenant_id + tenant_link.save() + + def get_tenants_for_user(user): """Return all tenants that the user can manage.""" from multitenancy.models import Tenant @@ -27,8 +35,8 @@ def get_link_class_from_model(model): related = getattr(link_field, 'related', None) if related: link_model = getattr(related, 'model', None) - from multitenancy.models import TenantEnabled - if link_model and issubclass(link_model, TenantEnabled): + from decisiontree.multitenancy.models import TenantLink + if link_model and issubclass(link_model, TenantLink): return link_model raise TypeError("This method should only be used on tenant-enabled models.") diff --git a/decisiontree/templates/tree/answers/list.html b/decisiontree/templates/tree/answers/list.html index e189f41..f7861bd 100644 --- a/decisiontree/templates/tree/answers/list.html +++ b/decisiontree/templates/tree/answers/list.html @@ -1,5 +1,16 @@ {% extends "tree/cbv/list.html" %} +{% block extra_stylesheets %} + +{% endblock extra_stylesheets %} + {% load tree_tags %} {% block list_table %} @@ -7,6 +18,7 @@ ID + Color Name Type Answer @@ -19,6 +31,12 @@ {% for answer in object_list %} {{ answer.pk }} + + +   + + {{ answer.color }} + {{ answer.name }} {{ answer.get_type_display }} {{ answer.answer }} diff --git a/decisiontree/tests/test_app.py b/decisiontree/tests/test_app.py index eaded02..fd1b215 100644 --- a/decisiontree/tests/test_app.py +++ b/decisiontree/tests/test_app.py @@ -39,6 +39,17 @@ def test_valid_trigger(self): question = self.transition.current_state.question.text self.assertTrue(question in msg.responses[0]['text']) + messages = list(dt.TranscriptMessage.objects.all()) + self.assertEqual(len(messages), 2) + incoming, outgoing = messages + self.assertEqual(incoming.session.connection, self.connection) + self.assertEqual(incoming.direction, dt.TranscriptMessage.INCOMING) + self.assertEqual(incoming.message, "food") + self.assertEqual(outgoing.session.connection, self.connection) + self.assertEqual(outgoing.direction, dt.TranscriptMessage.OUTGOING) + self.assertEqual(outgoing.message, msg.responses[0]['text']) + self.assertTrue(incoming.created < outgoing.created) + def test_basic_response(self): self._send('food') answer = self.transition.answer.answer @@ -46,11 +57,33 @@ def test_basic_response(self): next_question = self.transition.next_state.question.text self.assertTrue(next_question in msg.responses[0]['text']) + messages = list(dt.TranscriptMessage.objects.all()) + self.assertEqual(len(messages), 4) + incoming, outgoing = messages[-2:] + self.assertEqual(incoming.session.connection, self.connection) + self.assertEqual(incoming.direction, dt.TranscriptMessage.INCOMING) + self.assertEqual(incoming.message, answer) + self.assertEqual(outgoing.session.connection, self.connection) + self.assertEqual(outgoing.direction, dt.TranscriptMessage.OUTGOING) + self.assertEqual(outgoing.message, msg.responses[0]['text']) + self.assertTrue(incoming.created < outgoing.created) + def test_error_response(self): self._send('food') msg = self._send('bad-answer') self.assertTrue('is not a valid answer' in msg.responses[0]['text']) + messages = list(dt.TranscriptMessage.objects.all()) + self.assertEqual(len(messages), 4) + incoming, outgoing = messages[-2:] + self.assertEqual(incoming.session.connection, self.connection) + self.assertEqual(incoming.direction, dt.TranscriptMessage.INCOMING) + self.assertEqual(incoming.message, "bad-answer") + self.assertEqual(outgoing.session.connection, self.connection) + self.assertEqual(outgoing.direction, dt.TranscriptMessage.OUTGOING) + self.assertEqual(outgoing.message, msg.responses[0]['text']) + self.assertTrue(incoming.created < outgoing.created) + def test_error_response_from_question(self): self.survey.root_state.question.error_response = 'my error response' self.survey.root_state.question.save() @@ -58,6 +91,17 @@ def test_error_response_from_question(self): msg = self._send('bad-answer') self.assertTrue('my error response' == msg.responses[0]['text']) + messages = list(dt.TranscriptMessage.objects.all()) + self.assertEqual(len(messages), 4) + incoming, outgoing = messages[-2:] + self.assertEqual(incoming.session.connection, self.connection) + self.assertEqual(incoming.direction, dt.TranscriptMessage.INCOMING) + self.assertEqual(incoming.message, "bad-answer") + self.assertEqual(outgoing.session.connection, self.connection) + self.assertEqual(outgoing.direction, dt.TranscriptMessage.OUTGOING) + self.assertEqual(outgoing.message, msg.responses[0]['text']) + self.assertTrue(incoming.created < outgoing.created) + def test_sequence_start(self): self._send('food') answer = self.transition.answer.answer @@ -89,6 +133,17 @@ def test_sequence_end(self): self.assertEqual(msg.responses[0]['text'], "Your session with 'food' has ended") + messages = list(dt.TranscriptMessage.objects.all()) + self.assertEqual(len(messages), 4) + incoming, outgoing = messages[-2:] + self.assertEqual(incoming.session.connection, self.connection) + self.assertEqual(incoming.direction, dt.TranscriptMessage.INCOMING) + self.assertEqual(incoming.message, "end") + self.assertEqual(outgoing.session.connection, self.connection) + self.assertEqual(outgoing.direction, dt.TranscriptMessage.OUTGOING) + self.assertEqual(outgoing.message, msg.responses[0]['text']) + self.assertTrue(incoming.created < outgoing.created) + def test_num_tries_increments(self): self.survey.root_state.num_retries = 3 self.survey.root_state.save() diff --git a/decisiontree/utils.py b/decisiontree/utils.py index bcc943a..1e2f147 100644 --- a/decisiontree/utils.py +++ b/decisiontree/utils.py @@ -1,11 +1,13 @@ +from django.db.models import get_model from django.utils.encoding import force_text -from .models import Tree +from rapidsms.router import send def get_survey(trigger, connection): """Returns a survey only if it matches the connection's tenant.""" from decisiontree.multitenancy.utils import multitenancy_enabled + Tree = get_model('decisiontree', 'Tree') queryset = Tree.objects.filter(trigger__iexact=trigger) if multitenancy_enabled(): tenant = connection.backend.tenantlink.tenant @@ -126,3 +128,36 @@ def edit_string_for_tags(tags): else: names.append(name) return u', '.join(sorted(names)) + + +def start_survey(survey, connections): + """For each connection, establish a new Session and send the first question. + + Cancels any open sessions the connections might have had. + """ + Session = get_model('decisiontree', 'Session') + TranscriptMessage = get_model('decisiontree', 'TranscriptMessage') + + # First, close any open survey sessions. + open_sessions = Session.objects.open().filter(connection__in=connections) + open_sessions.update(state=None, canceled=True) + + # Create a new, open survey session for each connection + # and send the first message. + message = survey.root_state.question.text + sessions = [] + for connection in connections: + session = Session.objects.create( + connection=connection, + tree=survey, + state=survey.root_state, + ) + TranscriptMessage.objects.create( + session=session, + direction=TranscriptMessage.OUTGOING, + message=message, + ) + # TODO: notify app listeners? + send(message, [connection]) + sessions.append(session) + return sessions