From cc5ed99a71319b4db99be5a591de7874b83714dd Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 21 Jul 2022 15:04:39 -0500 Subject: [PATCH 1/5] Add view to interrupt a contact --- temba/contacts/models.py | 7 +++++++ temba/contacts/tests.py | 25 +++++++++++++++++++++++++ temba/contacts/views.py | 22 ++++++++++++++++++++++ temba/settings_common.py | 2 ++ 4 files changed, 56 insertions(+) diff --git a/temba/contacts/models.py b/temba/contacts/models.py index 5b4a59c20bf..005bb233720 100644 --- a/temba/contacts/models.py +++ b/temba/contacts/models.py @@ -1066,6 +1066,13 @@ def open_ticket(self, user, ticketer, topic, body: str, assignee=None): self.modify(user, [mod], refresh=False) return self.tickets.order_by("id").last() + def interrupt(self): + """ + Interrupts this contact's current flow + """ + if self.current_flow: + mailroom.queue_interrupt(self.org, contacts=[self]) + def block(self, user): """ Blocks this contact removing it from all non-smart groups diff --git a/temba/contacts/tests.py b/temba/contacts/tests.py index 6f238df97f3..fedb5dc7a2d 100644 --- a/temba/contacts/tests.py +++ b/temba/contacts/tests.py @@ -1616,6 +1616,31 @@ def test_open_ticket(self, mock_contact_modify): self.assertEqual(self.org.default_ticket_topic, ticket.topic) self.assertEqual("Looks sus", ticket.body) + @mock_mailroom + def test_interrupt(self, mr_mocks): + # noop when contact not in a flow + self.joe.interrupt() + + self.assertEqual([], mr_mocks.queued_batch_tasks) + + flow = self.create_flow("Test") + self.joe.current_flow = flow + self.joe.save(update_fields=("current_flow",)) + + self.joe.interrupt() + + self.assertEqual( + [ + { + "type": "interrupt_sessions", + "org_id": self.org.id, + "task": {"contact_ids": [self.joe.id]}, + "queued_on": matchers.Datetime(), + }, + ], + mr_mocks.queued_batch_tasks, + ) + @mock_mailroom def test_release(self, mr_mocks): # create a contact with a message diff --git a/temba/contacts/views.py b/temba/contacts/views.py index 8a023b95b05..7f0fa99f33b 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -564,6 +564,7 @@ class ContactCRUDL(SmartCRUDL): "update_fields", "update_fields_input", "export", + "interrupt", "block", "restore", "archive", @@ -896,6 +897,14 @@ def get_gear_links(self): modax=_("Open Ticket"), ) ) + if self.has_org_perm("contacts.contact_interrupt"): + links.append( + dict( + title=_("Interrupt"), + js_class="posterize", + href=reverse("contacts.contact_interrupt", args=(self.object.id,)), + ) + ) if self.has_org_perm("contacts.contact_update"): links.append( @@ -1524,6 +1533,19 @@ def save(self, obj): def get_success_url(self): return f"{reverse('tickets.ticket_list')}all/open/{self.ticket.uuid}/" + class Interrupt(OrgObjPermsMixin, SmartUpdateView): + """ + Interrupt this contact + """ + + fields = () + success_url = "uuid@contacts.contact_read" + success_message = "" + + def save(self, obj): + obj.interrupt() + return obj + class Block(OrgObjPermsMixin, SmartUpdateView): """ Block this contact diff --git a/temba/settings_common.py b/temba/settings_common.py index d2100d0cafc..af4ececdc7d 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -390,6 +390,7 @@ "stopped", "filter", "history", + "interrupt", "menu", "omnibox", "open_ticket", @@ -601,6 +602,7 @@ "contacts.contact_export", "contacts.contact_filter", "contacts.contact_history", + "contacts.contact_interrupt", "contacts.contact_list", "contacts.contact_menu", "contacts.contact_omnibox", From 54bb87c03ac99412cb1c10c2aaea399736e9a092 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 22 Jul 2022 12:16:46 -0500 Subject: [PATCH 2/5] Use client method to interrupt contact instead of task --- temba/contacts/models.py | 6 ++++-- temba/contacts/tests.py | 21 +++------------------ temba/contacts/views.py | 2 +- temba/mailroom/client.py | 11 ++++++----- temba/mailroom/tests.py | 13 +++++++++++++ temba/tests/mailroom.py | 31 +++++++++++++++++++++++++++++++ 6 files changed, 58 insertions(+), 26 deletions(-) diff --git a/temba/contacts/models.py b/temba/contacts/models.py index 005bb233720..3d5c038fc01 100644 --- a/temba/contacts/models.py +++ b/temba/contacts/models.py @@ -1066,12 +1066,14 @@ def open_ticket(self, user, ticketer, topic, body: str, assignee=None): self.modify(user, [mod], refresh=False) return self.tickets.order_by("id").last() - def interrupt(self): + def interrupt(self, user) -> bool: """ Interrupts this contact's current flow """ if self.current_flow: - mailroom.queue_interrupt(self.org, contacts=[self]) + sessions = mailroom.get_client().contact_interrupt(self.org.id, user.id, self.id) + return len(sessions) > 0 + return False def block(self, user): """ diff --git a/temba/contacts/tests.py b/temba/contacts/tests.py index fedb5dc7a2d..ec5c762c02f 100644 --- a/temba/contacts/tests.py +++ b/temba/contacts/tests.py @@ -1619,27 +1619,12 @@ def test_open_ticket(self, mock_contact_modify): @mock_mailroom def test_interrupt(self, mr_mocks): # noop when contact not in a flow - self.joe.interrupt() - - self.assertEqual([], mr_mocks.queued_batch_tasks) + self.assertFalse(self.joe.interrupt(self.admin)) flow = self.create_flow("Test") - self.joe.current_flow = flow - self.joe.save(update_fields=("current_flow",)) - - self.joe.interrupt() + MockSessionWriter(self.joe, flow).wait().save() - self.assertEqual( - [ - { - "type": "interrupt_sessions", - "org_id": self.org.id, - "task": {"contact_ids": [self.joe.id]}, - "queued_on": matchers.Datetime(), - }, - ], - mr_mocks.queued_batch_tasks, - ) + self.assertTrue(self.joe.interrupt(self.admin)) @mock_mailroom def test_release(self, mr_mocks): diff --git a/temba/contacts/views.py b/temba/contacts/views.py index 7f0fa99f33b..8d3f694a3db 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -1543,7 +1543,7 @@ class Interrupt(OrgObjPermsMixin, SmartUpdateView): success_message = "" def save(self, obj): - obj.interrupt() + obj.interrupt(self.request.user) return obj class Block(OrgObjPermsMixin, SmartUpdateView): diff --git a/temba/mailroom/client.py b/temba/mailroom/client.py index 5de5a2279bb..c3cb55ea2b4 100644 --- a/temba/mailroom/client.py +++ b/temba/mailroom/client.py @@ -230,14 +230,15 @@ def contact_modify(self, org_id, user_id, contact_ids, modifiers: list[Modifier] return self._request("contact/modify", payload) def contact_resolve(self, org_id: int, channel_id: int, urn: str): - payload = { - "org_id": org_id, - "channel_id": channel_id, - "urn": urn, - } + payload = {"org_id": org_id, "channel_id": channel_id, "urn": urn} return self._request("contact/resolve", payload) + def contact_interrupt(self, org_id: int, user_id: int, contact_id: int): + payload = {"org_id": org_id, "user_id": user_id, "contact_id": contact_id} + + return self._request("contact/interrupt", payload) + def contact_search(self, org_id, group_uuid, query, sort, offset=0, exclude_ids=()) -> SearchResults: payload = { "org_id": org_id, diff --git a/temba/mailroom/tests.py b/temba/mailroom/tests.py index 024eacf4858..60632d034ac 100644 --- a/temba/mailroom/tests.py +++ b/temba/mailroom/tests.py @@ -348,6 +348,19 @@ def test_contact_resolve(self, mock_post): json={"org_id": self.org.id, "channel_id": 345, "urn": "tel:+1234567890"}, ) + @patch("requests.post") + def test_contact_interrupt(self, mock_post): + mock_post.return_value = MockResponse(200, '{"sessions": 1}') + + response = get_client().contact_interrupt(self.org.id, 3, 345) + + self.assertEqual({"sessions": 1}, response) + mock_post.assert_called_once_with( + "http://localhost:8090/mr/contact/interrupt", + headers={"User-Agent": "Temba"}, + json={"org_id": self.org.id, "user_id": 3, "contact_id": 345}, + ) + @patch("requests.post") def test_contact_search(self, mock_post): mock_post.return_value = MockResponse( diff --git a/temba/tests/mailroom.py b/temba/tests/mailroom.py index 4f5d7aa7d5e..150befb4600 100644 --- a/temba/tests/mailroom.py +++ b/temba/tests/mailroom.py @@ -16,6 +16,7 @@ from temba import mailroom from temba.campaigns.models import CampaignEvent, EventFire from temba.contacts.models import URN, Contact, ContactField, ContactGroup, ContactURN +from temba.flows.models import FlowRun, FlowSession from temba.locations.models import AdminBoundary from temba.mailroom.client import ContactSpec, MailroomClient, MailroomException from temba.mailroom.modifiers import Modifier @@ -172,6 +173,17 @@ def contact_resolve(self, org_id: int, channel_id: int, urn: str): "urn": {"id": contact_urn.id, "identity": contact_urn.identity}, } + @_client_method + def contact_interrupt(self, org_id: int, user_id: int, contact_id: int): + contact = Contact.objects.get(id=contact_id) + + # get the waiting session IDs + session_ids = list(contact.sessions.filter(status=FlowSession.STATUS_WAITING).values_list("id", flat=True)) + + exit_sessions(session_ids, FlowSession.STATUS_INTERRUPTED) + + return {"sessions": len(session_ids)} + @_client_method def parse_query(self, org_id: int, query: str, parse_only: bool = False, group_uuid: str = ""): org = Org.objects.get(id=org_id) @@ -669,3 +681,22 @@ def decrement_credit(org): r.decr(f"org:{org.id}:cache:credits_remaining:{active_topup_id}", 1) return active_topup_id or None + + +def exit_sessions(session_ids: list, status: str): + FlowRun.objects.filter(session_id__in=session_ids).update( + status=status, exited_on=timezone.now(), modified_on=timezone.now() + ) + FlowSession.objects.filter(id__in=session_ids).update( + status=status, + ended_on=timezone.now(), + wait_started_on=None, + wait_expires_on=None, + timeout_on=None, + current_flow_id=None, + ) + + for session in FlowSession.objects.filter(id__in=session_ids): + session.contact.current_flow = None + session.contact.modified_on = timezone.now() + session.contact.save(update_fields=("current_flow", "modified_on")) From e01cf4f434b4e50a3fd855e80a16e0c8becc5993 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 22 Jul 2022 15:14:27 -0500 Subject: [PATCH 3/5] Add tests --- temba/contacts/tests.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/temba/contacts/tests.py b/temba/contacts/tests.py index ec5c762c02f..446c49b478e 100644 --- a/temba/contacts/tests.py +++ b/temba/contacts/tests.py @@ -801,6 +801,43 @@ def test_restore(self, mr_mocks): other_org_contact.refresh_from_db() self.assertEqual(Contact.STATUS_STOPPED, other_org_contact.status) + @mock_mailroom + def test_interrupt(self, mr_mocks): + contact = self.create_contact("Joe", phone="+593979000111") + other_org_contact = self.create_contact("Hans", phone="+593979123456", org=self.org2) + + MockSessionWriter(contact, self.create_flow("Test")).wait().save() + MockSessionWriter(other_org_contact, self.create_flow("Test", org=self.org2)).wait().save() + + interrupt_url = reverse("contacts.contact_interrupt", args=[contact.id]) + + # can't interrupt if not logged in + response = self.client.post(interrupt_url, {"id": contact.id}) + self.assertLoginRedirect(response) + + self.login(self.user) + + # can't interrupt if just regular user + response = self.client.post(interrupt_url, {"id": contact.id}) + self.assertLoginRedirect(response) + + self.login(self.admin) + + response = self.client.post(interrupt_url, {"id": contact.id}) + self.assertEqual(302, response.status_code) + + contact.refresh_from_db() + self.assertIsNone(contact.current_flow) + + # can't interrupt contact in other org + restore_url = reverse("contacts.contact_interrupt", args=[other_org_contact.id]) + response = self.client.post(restore_url, {"id": other_org_contact.id}) + self.assertLoginRedirect(response) + + # contact should be unchanged + other_org_contact.refresh_from_db() + self.assertIsNotNone(other_org_contact.current_flow) + def test_delete(self): contact = self.create_contact("Joe", phone="+593979000111") other_org_contact = self.create_contact("Hans", phone="+593979123456", org=self.org2) From b9371f4d65fdd3991bfd62f3d97f833fcf9d9809 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 26 Jul 2022 09:23:14 -0500 Subject: [PATCH 4/5] Contact interrupt menu option should only appear when they're in a flow --- temba/contacts/tests.py | 14 +++++++++++++- temba/contacts/views.py | 10 ++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/temba/contacts/tests.py b/temba/contacts/tests.py index 446c49b478e..f1b84aaebdc 100644 --- a/temba/contacts/tests.py +++ b/temba/contacts/tests.py @@ -806,12 +806,24 @@ def test_interrupt(self, mr_mocks): contact = self.create_contact("Joe", phone="+593979000111") other_org_contact = self.create_contact("Hans", phone="+593979123456", org=self.org2) + read_url = reverse("contacts.contact_read", args=[contact.uuid]) + interrupt_url = reverse("contacts.contact_interrupt", args=[contact.id]) + + self.login(self.admin) + + # no interrupt option if not in a flow + response = self.client.get(read_url) + self.assertNotContains(response, interrupt_url) + MockSessionWriter(contact, self.create_flow("Test")).wait().save() MockSessionWriter(other_org_contact, self.create_flow("Test", org=self.org2)).wait().save() - interrupt_url = reverse("contacts.contact_interrupt", args=[contact.id]) + # now it's an option + response = self.client.get(read_url) + self.assertContains(response, interrupt_url) # can't interrupt if not logged in + self.client.logout() response = self.client.post(interrupt_url, {"id": contact.id}) self.assertLoginRedirect(response) diff --git a/temba/contacts/views.py b/temba/contacts/views.py index 91debe1eca4..d436ac5e343 100644 --- a/temba/contacts/views.py +++ b/temba/contacts/views.py @@ -880,14 +880,8 @@ def build_content_menu(self, menu): menu.add_modax( _("Open Ticket"), "open-ticket", reverse("contacts.contact_open_ticket", args=[self.object.id]) ) - if self.has_org_perm("contacts.contact_interrupt"): - links.append( - dict( - title=_("Interrupt"), - js_class="posterize", - href=reverse("contacts.contact_interrupt", args=(self.object.id,)), - ) - ) + if self.has_org_perm("contacts.contact_interrupt") and self.object.current_flow: + menu.add_url_post(_("Interrupt"), reverse("contacts.contact_interrupt", args=(self.object.id,))) if self.has_org_perm("contacts.contact_update"): menu.add_modax( From 75b783810cbea3a3937e91272074c891e5576367 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 26 Jul 2022 09:32:14 -0500 Subject: [PATCH 5/5] Let editors interrupt contacts --- temba/settings_common.py | 1 + 1 file changed, 1 insertion(+) diff --git a/temba/settings_common.py b/temba/settings_common.py index 7d4a2b9c184..18bf33ba36f 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -745,6 +745,7 @@ "contacts.contact_export", "contacts.contact_filter", "contacts.contact_history", + "contacts.contact_interrupt", "contacts.contact_list", "contacts.contact_menu", "contacts.contact_omnibox",