def form_session_handler(v, text, msg): """ The form session handler will use the inbound text to answer the next question in the open SQLXformsSession for the associated contact. If no session is open, the handler passes. If multiple sessions are open, they are all closed and an error message is displayed to the user. """ with critical_section_for_smsforms_sessions(v.owner_id): if toggles.ONE_PHONE_NUMBER_MULTIPLE_CONTACTS.enabled(v.domain): channel = get_channel_for_contact(v.owner_id, v.phone_number) running_session_info = XFormsSessionSynchronization.get_running_session_info_for_channel( channel) if running_session_info.session_id: session = SQLXFormsSession.by_session_id( running_session_info.session_id) if not session.session_is_open: # This should never happen. But if it does we should set the channel free # and act like there was no available session notify_error( "The supposedly running session was not open and was released. " 'No known way for this to happen, so worth investigating.' ) XFormsSessionSynchronization.clear_stale_channel_claim( channel) session = None else: session = None else: multiple, session = get_single_open_session_or_close_multiple( v.domain, v.owner_id) if multiple: send_sms_to_verified_number( v, get_message(MSG_MULTIPLE_SESSIONS, v)) return True if session: session.phone_number = v.phone_number session.modified_time = datetime.utcnow() session.save() # Metadata to be applied to the inbound message inbound_metadata = MessageMetadata( workflow=session.workflow, reminder_id=session.reminder_id, xforms_session_couch_id=session._id, ) add_msg_tags(msg, inbound_metadata) try: answer_next_question(v, text, msg, session) except Exception: # Catch any touchforms errors log_sms_exception(msg) send_sms_to_verified_number( v, get_message(MSG_TOUCHFORMS_DOWN, v)) return True else: return False
def get_inbound_phone_entry(msg): if msg.backend_id: backend = SQLMobileBackend.load(msg.backend_id, is_couch_id=True) if toggles.INBOUND_SMS_LENIENCY.enabled(backend.domain): p = None if toggles.ONE_PHONE_NUMBER_MULTIPLE_CONTACTS.enabled( backend.domain): running_session_info = XFormsSessionSynchronization.get_running_session_info_for_channel( SMSChannel(backend_id=msg.backend_id, phone_number=msg.phone_number)) contact_id = running_session_info.contact_id if contact_id: p = PhoneNumber.get_phone_number_for_owner( contact_id, msg.phone_number) if p is not None: return (p, True) elif running_session_info.session_id: # This would be very unusual, as it would mean the supposedly running form session # is linked to a phone number, contact pair that doesn't exist in the PhoneNumber table notify_error( "Contact from running session has no match in PhoneNumber table. " "Only known way for this to happen is if you " "unregister a phone number for a contact " "while they are in an active session.", details={'running_session_info': running_session_info}) if not backend.is_global: p = PhoneNumber.get_two_way_number_with_domain_scope( msg.phone_number, backend.domains_with_access) return (p, p is not None) return (PhoneNumber.get_reserved_number(msg.phone_number), False)
def test_get_inbound_phone_entry__one_phone_number_multiple_contacts_on__session_no_contact( self): """Fall back to lenient query if we can't find the contact returned by the sticky session""" channel = SMSChannel(self.domain2_backend.couch_id, self.phone_number) info = RunningSessionInfo(None, "fake-owner-missing") XFormsSessionSynchronization._set_running_session_info_for_channel( channel, info, 60) self.addCleanup( XFormsSessionSynchronization. _release_running_session_info_for_channel, info, channel) phone_number, has_domain_two_way_scope = self._get_inbound_phone_entry__backend_domain_2( ) self.assertEqual(phone_number.owner_id, "fake-owner-3") self.assertTrue(has_domain_two_way_scope) self.assertTrue( self.domain2_backend.domain_is_authorized(phone_number.domain))
def test_get_inbound_phone_entry__one_phone_number_multiple_contacts_on__with_session( self): """Should return the phone number of the contact associated with the session""" channel = SMSChannel(self.domain2_backend.couch_id, self.phone_number) info = RunningSessionInfo(uuid.uuid4().hex, "fake-owner-4") XFormsSessionSynchronization._set_running_session_info_for_channel( channel, info, 60) self.addCleanup( XFormsSessionSynchronization. _release_running_session_info_for_channel, info, channel) phone_number, has_domain_two_way_scope = self._get_inbound_phone_entry__backend_domain_2( ) self.assertEqual(phone_number.owner_id, "fake-owner-4") self.assertTrue(has_domain_two_way_scope) self.assertTrue( self.domain2_backend.domain_is_authorized(phone_number.domain))
def send_first_message(domain, recipient, phone_entry_or_number, session, responses, logged_subevent, workflow): # This try/except section is just here (temporarily) to support future refactors # If any of these notify, they should be replaced with a comment as to why the two are different # so that someone refactoring in the future will know that this or that param is necessary. try: if session.workflow != workflow: # see if we can eliminate the workflow arg notify_error('Exploratory: session.workflow != workflow', details={ 'session.workflow': session.workflow, 'workflow': workflow}) if session.connection_id != recipient.get_id: # see if we can eliminate the recipient arg notify_error('Exploratory: session.connection_id != recipient.get_id', details={ 'session.connection_id': session.connection_id, 'recipient.get_id': recipient.get_id, 'recipient': recipient }) if session.related_subevent != logged_subevent: # see if we can eliminate the logged_subevent arg notify_error('Exploratory: session.related_subevent != logged_subevent', details={ 'session.connection_id': session.connection_id, 'logged_subevent': logged_subevent}) except Exception: # The above running is not mission critical, so if it errors just leave a message in the log # for us to follow up on. # Absence of the message below and messages above ever notifying # will indicate that we can remove these args. notify_exception(None, "Error in section of code that's just supposed help inform future refactors") if toggles.ONE_PHONE_NUMBER_MULTIPLE_CONTACTS.enabled(domain): if not XFormsSessionSynchronization.claim_channel_for_session(session): send_first_message.apply_async( args=(domain, recipient, phone_entry_or_number, session, responses, logged_subevent, workflow), countdown=60 ) return metrics_counter('commcare.smsforms.session_started', 1, tags={'domain': domain, 'workflow': workflow}) if len(responses) > 0: message = format_message_list(responses) metadata = MessageMetadata( workflow=workflow, xforms_session_couch_id=session.couch_id, ) if isinstance(phone_entry_or_number, PhoneNumber): send_sms_to_verified_number( phone_entry_or_number, message, metadata, logged_subevent=logged_subevent ) else: send_sms( domain, recipient, phone_entry_or_number, message, metadata ) logged_subevent.completed()
def test_get_inbound_phone_entry__one_phone_number_multiple_contacts_on__session_different_domain( self): """Phone number returned belongs to a domain which does not have access to this backend. TODO: This should probably be an error or it should fall back to 'lenient' query""" channel = SMSChannel(self.domain2_backend.couch_id, self.phone_number) info = RunningSessionInfo(uuid.uuid4().hex, "fake-owner-2") XFormsSessionSynchronization._set_running_session_info_for_channel( channel, info, 60) self.addCleanup( XFormsSessionSynchronization. _release_running_session_info_for_channel, info, channel) phone_number, has_domain_two_way_scope = self._get_inbound_phone_entry__backend_domain_2( ) self.assertEqual(phone_number.owner_id, "fake-owner-2") self.assertTrue(has_domain_two_way_scope) self.assertFalse( self.domain2_backend.domain_is_authorized( phone_number.domain)) # bad
def create_tasks(self): survey_sessions_due_for_action = self.get_survey_sessions_due_for_action( ) all_open_session_ids = self.get_open_session_ids() for domain, connection_id, session_id, current_action_due, phone_number in survey_sessions_due_for_action: if skip_domain(domain): continue if toggles.ONE_PHONE_NUMBER_MULTIPLE_CONTACTS.enabled(domain): fake_session = SQLXFormsSession( session_id=session_id, connection_id=connection_id, phone_number=phone_number, ) if not XFormsSessionSynchronization.channel_is_available_for_session( fake_session): running_session_info = XFormsSessionSynchronization.get_running_session_info_for_channel( fake_session.get_channel()) # First confirm the supposedly running session is even open # and if it's not (should be exceedingly rare) release it and act like it wasn't there if running_session_info.session_id \ and running_session_info.session_id not in all_open_session_ids: notify_error( "The supposedly running session was not open and was released. " "No known way for this to happen, so worth investigating.", details={ 'running_session_info': running_session_info }) XFormsSessionSynchronization.clear_stale_channel_claim( fake_session.get_channel()) # This is the 99% case: there's a running session for the channel # so leave this session/action in the queue for later and move on to the next one else: continue enqueue_lock = self.get_enqueue_lock(session_id, current_action_due) if enqueue_lock.acquire(blocking=False): handle_due_survey_action.delay(domain, connection_id, session_id)
def handle_due_survey_action(domain, contact_id, session_id): with critical_section_for_smsforms_sessions(contact_id): session = SQLXFormsSession.by_session_id(session_id) if (not session or not session.session_is_open or session.current_action_due > utcnow()): return if toggles.ONE_PHONE_NUMBER_MULTIPLE_CONTACTS.enabled(domain): if not XFormsSessionSynchronization.claim_channel_for_session( session): from .management.commands import handle_survey_actions # Unless we release this lock, handle_survey_actions will be unable to requeue this task # for the default duration of 1h, which we don't want handle_survey_actions.Command.get_enqueue_lock( session_id, session.current_action_due).release() return if session_is_stale(session): # If a session is having some unrecoverable errors that aren't benefitting from # being retried, those errors should show up in sentry log and the fix should # be dealt with. In terms of the current session itself, we just close it out # to allow new sessions to start. session.mark_completed(False) session.save() return if session.current_action_is_a_reminder: # Resend the current question in the open survey to the contact p = PhoneNumber.get_phone_number_for_owner(session.connection_id, session.phone_number) if p: metadata = MessageMetadata( workflow=session.workflow, xforms_session_couch_id=session._id, ) resp = FormplayerInterface(session.session_id, domain).current_question() send_sms_to_verified_number( p, resp.event.text_prompt, metadata, logged_subevent=session.related_subevent) session.move_to_next_action() session.save() else: # Close the session session.close() session.save()
def test_incoming_sms_linked_form_session__session_contact_matches_incoming( self): session = self._make_session(self.number1) self._claim_channel(session) msg = self._get_sms() handled = form_session_handler(self.number1, msg.text, msg) self.assertTrue(handled) # message gets attached to the correct session self.assertEqual(msg.xforms_session_couch_id, session.couch_id) # sticky session is still 'open' session_info = XFormsSessionSynchronization.get_running_session_info_for_channel( session.get_channel()) self.assertEqual(session_info.session_id, session.session_id)
def test_auto_clear_stale_session_on_claim(): phone_number_a = _clean_up_number('15555555555') session_a_1 = FakeSession(session_id=str(uuid4()), phone_number=phone_number_a, connection_id='Alpha') session_a_2 = FakeSession(session_id=str(uuid4()), phone_number=phone_number_a, connection_id='Beta') # Nothing set yet, so it can be claimed assert XFormsSessionSynchronization.channel_is_available_for_session( session_a_1) # And so can the other one assert XFormsSessionSynchronization.channel_is_available_for_session( session_a_2) # Claim succeeds assert XFormsSessionSynchronization.claim_channel_for_session(session_a_1) # Claim for same channel fails assert not XFormsSessionSynchronization.channel_is_available_for_session( session_a_2) assert not XFormsSessionSynchronization.claim_channel_for_session( session_a_2) # Set the current active session to closed manually, leaving a dangling/stale session claim session_a_1.session_is_open = False # Channel is now available assert XFormsSessionSynchronization.channel_is_available_for_session( session_a_2) # And the call above cleared the channel claim but left the contact assert (XFormsSessionSynchronization.get_running_session_info_for_channel( session_a_2.get_channel()) == RunningSessionInfo(session_id=None, contact_id='Alpha')) # Claim for the channel now succeeds assert XFormsSessionSynchronization.claim_channel_for_session(session_a_2) # And it is now the active session for the channel assert (XFormsSessionSynchronization.get_running_session_info_for_channel( session_a_2.get_channel()).session_id == session_a_2.session_id)
def test_incoming_sms_linked_form_session__session_contact_different_from_incoming( self): session = self._make_session(self.number2) self._claim_channel(session) msg = self._get_sms() # session belongs to `number2` but message comes from `number1` handled = form_session_handler(self.number1, msg.text, msg) self.assertTrue(handled) # msg does not get updated self.assertEqual(msg.xforms_session_couch_id, None) # session should be closed session.refresh_from_db() self.assertFalse(session.session_is_open) self.assertFalse(session.completed) # sticky session is removed session_info = XFormsSessionSynchronization.get_running_session_info_for_channel( session.get_channel()) self.assertIsNone(session_info.session_id)
def _claim_channel(self, session): XFormsSessionSynchronization.claim_channel_for_session(session) self.addCleanup( XFormsSessionSynchronization.release_channel_for_session, session)
def _clean_up_number(phone_number): XFormsSessionSynchronization._set_running_session_info_for_channel( SMSChannel(BACKEND_ID, phone_number), RunningSessionInfo(None, None), expiry=10) return phone_number
def test_session_synchronization(): phone_number_a = _clean_up_number('15555555555') phone_number_b = _clean_up_number('15555555554') session_a_1 = FakeSession(session_id=str(uuid4()), phone_number=phone_number_a, connection_id='Alpha') session_a_2 = FakeSession(session_id=str(uuid4()), phone_number=phone_number_a, connection_id='Beta') session_b_1 = FakeSession(session_id=str(uuid4()), phone_number=phone_number_b, connection_id='Kappa') # Nothing set yet, so it can be claimed assert XFormsSessionSynchronization.channel_is_available_for_session( session_a_1) # And so can the other one assert XFormsSessionSynchronization.channel_is_available_for_session( session_a_2) # Claim succeeds assert XFormsSessionSynchronization.claim_channel_for_session(session_a_1) # Claim for same channel fails assert not XFormsSessionSynchronization.channel_is_available_for_session( session_a_2) assert not XFormsSessionSynchronization.claim_channel_for_session( session_a_2) # But same session can re-claim it assert XFormsSessionSynchronization.claim_channel_for_session(session_a_1) # And another session can claim another channel assert XFormsSessionSynchronization.claim_channel_for_session(session_b_1) # And if the first session releases the channel XFormsSessionSynchronization.release_channel_for_session(session_a_1) # Then the contact is still set assert XFormsSessionSynchronization.get_running_session_info_for_channel( SMSChannel(BACKEND_ID, phone_number_a)).contact_id == 'Alpha' # But the other session (that couldn't before) can claim it now assert XFormsSessionSynchronization.claim_channel_for_session(session_a_2) # Trying to clear the channel claim will fail because the session is still open assert not XFormsSessionSynchronization.clear_stale_channel_claim( SMSChannel(BACKEND_ID, phone_number_a)) # But if we close the session first session_a_2.close() # The session is now "stale" so we can clear that stale channel claim assert XFormsSessionSynchronization.clear_stale_channel_claim( SMSChannel(BACKEND_ID, phone_number_a)) # If we try to clear it again it'll be a no-op and return false, since it's already cleared assert not XFormsSessionSynchronization.clear_stale_channel_claim( SMSChannel(BACKEND_ID, phone_number_a))